From b10d30349fc38191d53f3ebc2545c06410a4abc2 Mon Sep 17 00:00:00 2001 From: n33pm <12273891+n33pm@users.noreply.github.com> Date: Thu, 17 Sep 2026 12:14:53 +0200 Subject: [PATCH] fix(opencode): discover services through the info endpoint --- docs/features/README.md | 13 ++++++ docs/features/voice-action.md | 2 + docs/opencode-compatibility.md | 25 ++++++++++- src/dictation_processor.rs | 79 ++++++++++++++++++++++++---------- 4 files changed, 94 insertions(+), 25 deletions(-) diff --git a/docs/features/README.md b/docs/features/README.md index e769713..e3375c3 100644 --- a/docs/features/README.md +++ b/docs/features/README.md @@ -291,6 +291,19 @@ Selection and stage checks start in [personal_commands.rs](../../src/personal_commands.rs). They do not prove live provider availability or real application/Brave context changes. +```ts +OpenCode discovery // modes.opencode-discovery; shared with Voice Action +├── GET /api/info -> Match PID/version to private service registration +├── Exact CLI 404 -> Try /api/status, then /api/health, within the same deadline +└── Other failure / invalid response -> Show error; explicit Retry +``` + +HEX 2.1.18 uses the two older names, both of which return 404 on OpenCode +`0.0.0-dev-19726`. The source fix adds `/api/info`. +[OpenCode compatibility](../opencode-compatibility.md#september-17-2026-endpoint-change) +records endpoint observations and fixture coverage. Endpoint availability alone +does not establish provider-backed processing or installed-app recovery. + Application activations compare the picker's bundle name with the foreground application's localized name. When Finder shows all filename extensions, the picker name arrives as `Ghostty.app`; [context.rs](../../src/context.rs) strips diff --git a/docs/features/voice-action.md b/docs/features/voice-action.md index b55a77b..889be0a 100644 --- a/docs/features/voice-action.md +++ b/docs/features/voice-action.md @@ -26,6 +26,8 @@ reachable when OpenCode is unavailable; the pane offers setup/retry actions. HEX discovers `opencode2` and its managed service through [dictation_processor.rs](../../src/dictation_processor.rs), not a separate HEX-owned provider service. +See [shared OpenCode discovery](README.md#process-text-with-modes) for current +endpoint compatibility and verification limits. Discovery uses `opencode2 api get /api/status`; an exact CLI 404 falls back to `/api/health` for older V2 services. Both attempts share the original deadline diff --git a/docs/opencode-compatibility.md b/docs/opencode-compatibility.md index f268c83..39c02e0 100644 --- a/docs/opencode-compatibility.md +++ b/docs/opencode-compatibility.md @@ -28,8 +28,12 @@ HEX discovers a separately installed `opencode2`, optionally overridden by managed service, or restart or update it. `opencode2 api` discovers or starts the service through OpenCode's own lifecycle. -Catalog loading and generation use `opencode2 api get /api/health` to identify +Catalog loading and generation use `opencode2 api get /api/info` to identify the authenticated active server, starting it through the CLI when needed. +For older servers, discovery tries `/api/status`, then `/api/health`, only when +the preceding command fails with the exact `HTTP 404 Not Found` diagnostic. +All attempts share the original deadline and cancellation signal. Other failures +and invalid responses stop discovery without trying another endpoint. `opencode2 debug paths` locates its state directory. HEX reads endpoint and password together from the bounded, owner-only service registration matching that server's PID and version. @@ -66,9 +70,26 @@ published to npm's `dev` tag; `next` was an older beta, not the newest V2 build. Resolve the published version rather than assuming a moving tag identifies the desired source commit. -Check the connected server's `/api/health` version as well as the CLI version: +Check the connected server's `/api/info` version as well as the CLI version: the CLI's `api` command permits a server version mismatch. Do not replace a user's running service to perform compatibility validation. Keep regression coverage for body/credential privacy, curl configuration escaping, large bodies, loopback-only admission, blocked input pipes, and cancellation. + +## September 17, 2026 endpoint change + +OpenCode CLI and server `0.0.0-dev-19726` returned HTTP 404 and exit code 1 for +both `/api/status` and `/api/health`. `/api/info` succeeded, with PID and version +matching the active service registration. Authenticated reads of `/api/model` +and `/api/model/default` also returned HTTP 200. The +[current troubleshooting guide](https://opencode.ai/v2/docs/troubleshooting) +documents `/api/info`. + +HEX 2.1.18 supports the two older names and fails discovery on this version. +The source fix prefers `/api/info` while retaining both older names. +`service_discovery_supports_current_and_legacy_endpoints` covers each endpoint +generation, fallback order, non-404 failures, invalid responses, and private +diagnostics through a fixture CLI. The shared-deadline/cancellation fixture +covers the three-endpoint path. These checks do not prove provider-backed +generation or installed-app recovery. diff --git a/src/dictation_processor.rs b/src/dictation_processor.rs index c4f71c7..5108960 100644 --- a/src/dictation_processor.rs +++ b/src/dictation_processor.rs @@ -593,12 +593,15 @@ fn discover_opencode_service_with( pid: u32, version: String, } - let mut output = run(&["api", "get", "/api/status"])?; - // Older OpenCode versions expose only /api/health. Other failures are not retries. - if !output.status.success() - && String::from_utf8_lossy(&output.stderr).trim() == "HTTP 404 Not Found" - { - output = run(&["api", "get", "/api/health"])?; + let mut output = run(&["api", "get", "/api/info"])?; + // Try older endpoint names only when the previous endpoint is missing. + for path in ["/api/status", "/api/health"] { + if output.status.success() + || String::from_utf8_lossy(&output.stderr).trim() != "HTTP 404 Not Found" + { + break; + } + output = run(&["api", "get", path])?; } let health: Health = serde_json::from_str(&decode(output)?) .map_err(|_| eyre!("OpenCode returned an invalid service health response"))?; @@ -1560,54 +1563,84 @@ mod tests { .unwrap(); fs::set_permissions(®istration, fs::Permissions::from_mode(0o600)).unwrap(); let executable = root.join("opencode2"); - for (status, health, succeeds, expected_calls) in [ + let available = "printf '%s\\n' '{\"pid\":42,\"version\":\"fixture-version\"}'"; + let missing = "echo 'HTTP 404 Not Found' >&2; exit 1"; + for (info, status, health, succeeds, expected_calls) in [ ( - "printf '%s\\n' '{\"pid\":42,\"version\":\"fixture-version\"}'", - "echo 'HTTP 404 Not Found' >&2; exit 1", + available, + missing, + missing, true, - "api get /api/status\ndebug paths\n", + "api get /api/info\ndebug paths\n", ), ( - "echo 'HTTP 404 Not Found' >&2; exit 1", - "printf '%s\\n' '{\"pid\":42,\"version\":\"fixture-version\"}'", + missing, + available, + missing, true, - "api get /api/status\napi get /api/health\ndebug paths\n", + "api get /api/info\napi get /api/status\ndebug paths\n", + ), + ( + missing, + missing, + available, + true, + "api get /api/info\napi get /api/status\napi get /api/health\ndebug paths\n", ), ( "echo 'HTTP 401 Unauthorized' >&2; exit 1", "exit 0", + "exit 0", false, - "api get /api/status\n", + "api get /api/info\n", ), ( "echo 'HTTP 500 Internal Server Error' >&2; exit 1", "exit 0", + "exit 0", false, - "api get /api/status\n", + "api get /api/info\n", ), ( "echo 'private diagnostic mentioning HTTP 404 Not Found' >&2; exit 1", "exit 0", + "exit 0", false, - "api get /api/status\n", + "api get /api/info\n", ), ( "echo 'private invalid response'", "exit 0", + "exit 0", false, - "api get /api/status\n", + "api get /api/info\n", ), ( - "echo 'HTTP 404 Not Found' >&2; exit 1", + missing, + "echo 'HTTP 401 Unauthorized' >&2; exit 1", + "exit 0", + false, + "api get /api/info\napi get /api/status\n", + ), + ( + missing, + missing, "echo 'private diagnostic' >&2; exit 1", false, - "api get /api/status\napi get /api/health\n", + "api get /api/info\napi get /api/status\napi get /api/health\n", + ), + ( + missing, + missing, + missing, + false, + "api get /api/info\napi get /api/status\napi get /api/health\n", ), ] { fs::write( &executable, format!( - "#!/bin/sh\nprintf '%s\\n' \"$*\" >> calls\ncase \"$*\" in\n'api get /api/status') {status} ;;\n'api get /api/health') {health} ;;\n'debug paths') printf 'state %s\\n' \"$PWD\" ;;\n*) exit 2 ;;\nesac\n" + "#!/bin/sh\nprintf '%s\\n' \"$*\" >> calls\ncase \"$*\" in\n'api get /api/info') {info} ;;\n'api get /api/status') {status} ;;\n'api get /api/health') {health} ;;\n'debug paths') printf 'state %s\\n' \"$PWD\" ;;\n*) exit 2 ;;\nesac\n" ), ) .unwrap(); @@ -1622,7 +1655,7 @@ mod tests { assert_eq!( fs::read_to_string(root.join("calls")).unwrap(), expected_calls, - "status fixture: {status}" + "info: {info}; status: {status}; health: {health}" ); if succeeds { assert_eq!( @@ -1650,7 +1683,7 @@ mod tests { let executable = root.join("opencode2"); fs::write( &executable, - "#!/bin/sh\nprintf '%s\\n' \"$*\" >> calls\nsleep 2\ncase \"$*\" in\n'api get /api/status') echo 'HTTP 404 Not Found' >&2; exit 1 ;;\n'api get /api/health') echo '{\"pid\":42,\"version\":\"fixture-version\"}' ;;\n*) exit 2 ;;\nesac\n", + "#!/bin/sh\nprintf '%s\\n' \"$*\" >> calls\ncase \"$*\" in\n'api get /api/info') echo 'HTTP 404 Not Found' >&2; exit 1 ;;\n'api get /api/status') sleep 2; echo 'HTTP 404 Not Found' >&2; exit 1 ;;\n'api get /api/health') sleep 2; echo '{\"pid\":42,\"version\":\"fixture-version\"}' ;;\n*) exit 2 ;;\nesac\n", ) .unwrap(); fs::set_permissions(&executable, fs::Permissions::from_mode(0o755)).unwrap(); @@ -1664,7 +1697,7 @@ mod tests { assert!(error.to_string().contains("exceeded"), "{error}"); assert_eq!( fs::read_to_string(root.join("calls")).unwrap(), - "api get /api/status\napi get /api/health\n" + "api get /api/info\napi get /api/status\napi get /api/health\n" ); fs::remove_file(root.join("calls")).unwrap(); let error = discover_opencode_service_with(