diff --git a/AGENTS.md b/AGENTS.md index 260c4a06a..17c7a155a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,7 +11,7 @@ Each of these breaks the product, a release, or the build with **no loud error** 3. **Native deps must be allow-listed.** pnpm blocks install scripts by default; a native dep (e.g. `better-sqlite3`) missing from `allowBuilds:` in `pnpm-workspace.yaml` installs fine but fails at `require()` time with missing bindings. 4. **`check:ci` does not run `pnpm test`.** CI runs vitest as a separate TypeScript-job step, while `check:ci` remains format/lint/typecheck only. Run both commands before every commit; passing either one alone is not the complete JavaScript gate. 5. **A release is a version+tag pair.** Bump `apps/desktop/package.json` `version`, then push a `v*.*.*` tag; CI fails unless `v${version}` equals the tag. Never hand-tag or hand-edit workspace versions ([`docs/RELEASE.md`](docs/RELEASE.md)). -6. **Every daemon-side child process sets `windowsHide: true`.** Node defaults it to `false`, and the daemon runs console-less (Electron `utilityProcess`), so a console-subsystem child spawned without it — agent CLI, git, sidecar, bsdtar — pops a visible console window on packaged Windows only; silent everywhere else, dev included. Applies to every `spawn`/`exec*`/cross-spawn call in `apps/daemon` and `packages/host/{engine,agent-adapter,assets}` (CODE-236 swept all sites 2026-07 — keep new ones consistent). SDK-internal spawns are out of reach: claude's SDK hides its own, opencode's `createOpencodeServer` does not. +6. **Every daemon-side child process sets `windowsHide: true`.** Node defaults it to `false`, and the daemon runs console-less (Electron `utilityProcess`), so a console-subsystem child spawned without it — agent CLI, git, sidecar, bsdtar — pops a visible console window on packaged Windows only; silent everywhere else, dev included. Applies to every `spawn`/`exec*`/cross-spawn call in `apps/daemon` and `packages/host/{engine,agent-adapter,assets}` (CODE-236 swept all sites 2026-07 — keep new ones consistent). SDK-internal spawns are out of reach: claude's SDK hides its own; opencode servers are spawned by our own `native/opencode/serve.ts` (CODE-76), not the SDK, so the flag applies there too. ## Routing — touching X, read Y first diff --git a/apps/daemon/AGENTS.md b/apps/daemon/AGENTS.md index 0fdb34629..99cbc98fb 100644 --- a/apps/daemon/AGENTS.md +++ b/apps/daemon/AGENTS.md @@ -64,8 +64,9 @@ Runs via `tsx` in dev (`pnpm -F @linkcode/daemon dev`) and a `tsup` bundle in pr kicks off — it subscribes to the AssetManager and forwards install progress to clients (`asset.progress`/`asset.settled`), re-probing and pushing `agent-runtime.changed` when an - agent install completes (CODE-112). opencode self-spawns the `opencode` command via PATH - (CODE-76); pi runs in-process and spawns nothing — its SDK is a managed npm-closure download + agent install completes (CODE-112). opencode spawns `opencode serve` from the probe-resolved + binary (managed `agent:opencode` asset → detected user install, CODE-76; bare-name PATH + fallback for unprobed hosts); pi runs in-process and spawns nothing — its SDK is a managed npm-closure download (CODE-219): packaged apps exclude the closure from node_modules and the adapter imports the store's installed entry (`agentRuntimeProber.resolveEntry`), same consent/refresh rules. - **PTY sidecar** is a Rust binary (`linkcode-pty`, `pnpm -F @linkcode/daemon run build:rust`); diff --git a/packages/client/workbench/src/agent-runtime/hooks.ts b/packages/client/workbench/src/agent-runtime/hooks.ts index 2db9c6b4b..61e6d003d 100644 --- a/packages/client/workbench/src/agent-runtime/hooks.ts +++ b/packages/client/workbench/src/agent-runtime/hooks.ts @@ -6,7 +6,7 @@ import { useData } from '../runtime/tayori'; /** * Which agent CLIs the host can actually spawn, keyed by kind (`AgentRuntimes`); the * `agent-runtime.changed` push revalidates the snapshot here. Kinds the host has not evaluated - * are absent (opencode until CODE-76). + * are absent. */ export function useAgentRuntimes() { const client = useLinkCodeClient(); diff --git a/packages/client/workbench/src/agent-runtime/onboarding.ts b/packages/client/workbench/src/agent-runtime/onboarding.ts index b40e63dc5..f91c30472 100644 --- a/packages/client/workbench/src/agent-runtime/onboarding.ts +++ b/packages/client/workbench/src/agent-runtime/onboarding.ts @@ -94,8 +94,8 @@ function versionKey(runtime: AgentRuntimeAvailability): string { /** * Pure cue derivation for the onboarding UI (CODE-112). A kind absent from `runtimes` is - * unevaluated (opencode until CODE-76) and yields no cue; `runtimes` still loading yields no - * cues at all rather than a flash of "missing". + * unevaluated and yields no cue; `runtimes` still loading yields no cues at all rather than a + * flash of "missing". */ export function deriveAgentRuntimeCues( runtimes: AgentRuntimes | undefined, diff --git a/packages/client/workbench/src/mock/dev-mock-host.ts b/packages/client/workbench/src/mock/dev-mock-host.ts index a0e594def..872bbeaf5 100644 --- a/packages/client/workbench/src/mock/dev-mock-host.ts +++ b/packages/client/workbench/src/mock/dev-mock-host.ts @@ -185,7 +185,7 @@ export class DevMockHost { /** * Onboarding fixtures (CODE-112), one kind per state: claude-code missing (downloadable), codex - * out-of-range (unverified-continue + paired-download), pi builtin, opencode absent until CODE-76. + * out-of-range (unverified-continue + paired-download), pi builtin, opencode absent (unevaluated). */ private agentRuntimes(): AgentRuntimes { return { diff --git a/packages/host/agent-adapter/AGENTS.md b/packages/host/agent-adapter/AGENTS.md index 12a4b4b19..eeb0f0289 100644 --- a/packages/host/agent-adapter/AGENTS.md +++ b/packages/host/agent-adapter/AGENTS.md @@ -5,7 +5,7 @@ ## Layout - One native adapter per agent under `src/native/`: `claude-code.ts`, `pi.ts`, and directory modules `codex/` (`adapter.ts`, `app-server.ts` (JSON-RPC client + node_modules binary resolution), `config.ts` (config.toml read), `history.ts` (rollout reads), `unified-diff.ts`), `opencode/` (`adapter.ts`, `history.ts` (server-API replay mapping), `history-server.ts` (the shared history server)), and `grok-build/` (headless `grok -p`, not ACP). Shared spine: `src/adapter.ts` (the `AgentAdapter` interface + id generators), `src/base.ts` (`BaseAgentAdapter`), `src/registry.ts`, `src/util.ts`, `src/history-util.ts`. -- CLI runtime probing under `src/probe/`: one `AgentCliProbe` subclass per external-CLI agent (`ClaudeCodeProbe`, `CodexProbe`, `GrokBuildProbe` — the daemon's PATH (absolute entries, in order) then fallback install locations, each candidate `--version` vendor-marker verified; CODE-220), orchestrated by `AgentRuntimeProber` (`prober.ts`). The daemon probes the shared `agentRuntimeProber` instance once per boot and serves the result as the `agent-runtime.list` wire resource; adapters resolve their spawn path through `resolveBinary(kind)`. +- CLI runtime probing under `src/probe/`: one `AgentCliProbe` subclass per external-CLI agent (`ClaudeCodeProbe`, `CodexProbe`, `GrokBuildProbe`, `OpencodeProbe` — the daemon's PATH (absolute entries, in order) then fallback install locations, each candidate `--version` vendor-marker verified; CODE-220), orchestrated by `AgentRuntimeProber` (`prober.ts`). The daemon probes the shared `agentRuntimeProber` instance once per boot and serves the result as the `agent-runtime.list` wire resource; adapters resolve their spawn path through `resolveBinary(kind)`. - `createAdapter(kind)` in `registry.ts` is the ONLY factory — a `switch` over `AgentKind` ending in foxts `never(kind, 'agent kind')`, so an unhandled kind fails typecheck. **Adding an agent** = new native adapter class + registry case + extend the `AgentKind` enum. - Id generators (`adapter.ts`): `nextMessageId()`→`msg-`, `nextToolCallId()`→`tool-`, `nextRequestId()`→`req-` (module counter + `Date.now().toString(36)`). These are the FALLBACK — adapters prefer provider-native ids (claude `toolu_`/message uuid; codex/opencode item/part ids) so live turns and cold-resume history converge by id. - Each SDK is lazy-loaded via `loadSdk(name, () => import(...))`; on import failure the adapter emits `AgentEvent {type:'error', code:'sdk-unavailable', recoverable:false}` and rethrows — a missing SDK degrades to a clear error, it does not crash the daemon. codex spawns `codex app-server` instead of importing an SDK; a failed resolution/spawn/handshake emits the same `sdk-unavailable` shape (and reaps the half-started child). @@ -87,7 +87,7 @@ Every new adapter MUST honor these (`base.ts`); downstream relies on them, they - **opencode turn lifecycle** (all verified live on 1.17.11, CODE-136): prompts go through `session.promptAsync` — the blocking `session.prompt` holds its HTTP response open for the whole turn, so `send()` would not return until the turn ended. `session.status {busy|retry}` is the on-stream acknowledgement that the active turn is running, and it ALWAYS precedes the turn's own error/idle — the `turnStarted` gate built on it is what keeps the previous turn's post-settle stragglers (an abort's DUPLICATE idle; the error re-fired with a stack after the settle) from falsely settling or poisoning a next turn that was already dispatched. An abort delivers `session.error {MessageAbortedError}` + `session.idle` — the error folds into the cancel path (stop `cancelled`), never surfaces as an error. Other `session.error`s fail the turn: `ProviderAuthError` → `AUTH_FAILED_ERROR_CODE` (non-recoverable, triggers the daemon login re-probe), everything else recoverable; `sessionID` is OPTIONAL on this one event — an unattributed error still counts as ours. A failed turn's idle settle emits status `idle` but NO `end_turn` stop. An idle absorbed before the busy acknowledgement logs a `console.warn` — the one trace if a server never emits `session.status` (the turn would then hang at `running`). - **opencode RPC results resolve, they don't reject**: the generated client returns `{error}` for HTTP and network failures alike (`throwOnError` is never set) — every RPC result goes through `okOrThrow` or a failure silently reads as success (a permission reply that never landed, a prompt that never started). - **opencode permissions & questions** (CODE-136): opencode's default posture is allow-all — asks only fire when the user's own config (or a future preset) says `ask`. `permission.asked` → the shared `requestPermission` round-trip → `permission.reply({reply: 'once'|'always'|'reject'})`; `always` is persisted server-side as a saved rule. `question.asked` → `requestQuestion` → `question.reply({answers})` (one label array per question) or `question.reject`. An UNANSWERED ask gates the turn server-side forever, so a teardown-cancelled permission replies `reject` and a cancelled question calls `reject` — reply failures after a cancel are swallowed (the abort already discarded the ask). Asks cite their tool via `tool.callID`, but tool cards are announced under the PART id — `toolPartIdByCallId` re-joins them. A custom "Other" answer rides as an extra label: upstream `Question.reply` hands the answer arrays to the asking tool verbatim, with no validation against option labels (verified in anomalyco/opencode source). -- **opencode history** (CODE-171, live-verified on 1.17.11): `list`/`read` are served by a daemon-shared, lazily-spawned, idle-reaped `opencode serve` (`opencode/history-server.ts`) — NOT a per-session server: HistoryService calls history on never-started factory instances. The manager self-spawns with a **neutral cwd** (`~/.linkcode/opencode-history`) because opencode indexes its cwd as the default workspace and the SDK's `createOpencode()` cannot set cwd (a daemon launched from `$HOME` would index the whole home tree); `--port=0` does NOT auto-allocate (falls back to 4096) so the free port is found up front; shutdown escalates SIGTERM→SIGKILL (the SDK's `close()` is a bare SIGTERM). The neutral-cwd instance lists/reads sessions across every project with no `directory` scoping. `readHistory` full-fetches then slices at event level — the messages RPC's `limit` returns the LAST n messages, so it cannot page forward — and truncates at `Session.revert.messageID` (partial `partID` reverts keep everything rather than over-cut). Replay reuses the live part ids (`streamDelta`'s message keys, `toolCallFromPart` snapshots) so live and cold cards converge by id. `resumeHistory` is a native continue: adopt the existing session id (no `session.create`) and scope every call by the session's OWN directory, not the resume cwd. Fresh sessions defer `session-ref` until the first on-stream busy acknowledgement — announcing at create would seed the client against an empty transcript and the `uptoSeq` cut would swallow the first prompt (same deferral as codex). +- **opencode history** (CODE-171, live-verified on 1.17.11): `list`/`read` are served by a daemon-shared, lazily-spawned, idle-reaped `opencode serve` (`opencode/history-server.ts`) — NOT a per-session server: HistoryService calls history on never-started factory instances. The manager spawns with a **neutral cwd** (`~/.linkcode/opencode-history`) because opencode indexes its cwd as the default workspace (a daemon launched from `$HOME` would index the whole home tree); `--port=0` does NOT auto-allocate (falls back to 4096) so the free port is found up front; shutdown escalates SIGTERM→SIGKILL. All `opencode serve` spawns (this manager and the per-session live server) go through `opencode/serve.ts` (CODE-76): binary from `agentRuntimeProber.resolveBinary('opencode')` (managed `agent:opencode` asset → detected user install, incl. `~/.opencode/bin`) with a bare-name PATH fallback for unprobed hosts — the SDK's `createOpencodeServer` is no longer used (it hard-codes bare-name PATH resolution, no cwd, no `windowsHide`). The neutral-cwd instance lists/reads sessions across every project with no `directory` scoping. `readHistory` full-fetches then slices at event level — the messages RPC's `limit` returns the LAST n messages, so it cannot page forward — and truncates at `Session.revert.messageID` (partial `partID` reverts keep everything rather than over-cut). Replay reuses the live part ids (`streamDelta`'s message keys, `toolCallFromPart` snapshots) so live and cold cards converge by id. `resumeHistory` is a native continue: adopt the existing session id (no `session.create`) and scope every call by the session's OWN directory, not the resume cwd. Fresh sessions defer `session-ref` until the first on-stream busy acknowledgement — announcing at create would seed the client against an empty transcript and the `uptoSeq` cut would swallow the first prompt (same deferral as codex). - **pi** — pure JS in-process (`createAgentSession()`), no binary spawn, unaffected by asar-spawn, not staged. The SDK import resolves store-first (CODE-219): `agentRuntimeProber.resolveEntry('pi')` — the managed npm-closure entry installed by `@linkcode/assets` — then the bare `import()` (dev/standalone). The SDK is deliberately a devDependency: `--prod` deploys (desktop staging, standalone daemon) carry no closure, so packaged hosts have only the store. The prober reports pi three-state: managed entry → `source:'managed'`, node_modules → `source:'sdk'`, neither → `missing` (onboarding offers the download). auth via `authStorage.setRuntimeApiKey(provider, apiKey)` overriding `~/.pi/agent/auth.json` + env; model `provider/rest`, falls back to `modelRegistry.getAvailable()[0]`, and the SDK-selected session model is reflected as `provider/id` after creation. `agent_end {willRetry:true}` is intermediate; only `agent_settled` finalizes the turn, sweeps unfinished tools, and maps the final assistant's success/error/aborted outcome. Pi has no approval callback: it advertises one fixed `bypassPermissions` policy so the UI exposes that all tools run without prompts, while policy changes still reject. ## Capability, auth & cancel matrices @@ -98,7 +98,7 @@ Product code must branch on `historyCapabilities` — never assume an op is supp | --- | --- | --- | --- | --- | --- | --- | --- | | claude-code | ✓ | ✓ (live) | ✓ (live) | ✓ | ✓ | ✗ | detected user install / managed dir | | codex | ✓ | ✓ (next turn) | ✓ (next turn, low–xhigh) | ✓ (3 tiers) | ✓ | ✓ | detected user install / managed dir | -| opencode | ✓ | ✓ (next turn, same provider) | ✗ | ✓ (agent axis: build/plan/custom; hidden if discovery fails) | ✓ | ✓ | self-spawns server via PATH (CODE-76) | +| opencode | ✓ | ✓ (next turn, same provider) | ✗ | ✓ (agent axis: build/plan/custom; hidden if discovery fails) | ✓ | ✓ | detected user install / managed dir (CODE-76; PATH-name fallback for unprobed hosts) | | pi | ✗ | ✗ | ✗ | fixed bypass | ✗ | ✗ | in-process JS: managed npm-closure import (CODE-219) / dev node_modules | | grok-build | ✗ | ✓ (next turn) | ✓ (next turn, low–high) | fixed bypass | ✗ | ✗ | detected user install | diff --git a/packages/host/agent-adapter/src/__tests__/opencode-history-adapter.test.ts b/packages/host/agent-adapter/src/__tests__/opencode-history-adapter.test.ts index a345d37e1..8ee119e46 100644 --- a/packages/host/agent-adapter/src/__tests__/opencode-history-adapter.test.ts +++ b/packages/host/agent-adapter/src/__tests__/opencode-history-adapter.test.ts @@ -6,19 +6,40 @@ import { OpenCodeAdapter } from '../native/opencode'; import type { OpencodeHistoryServerLike } from '../native/opencode/history-server'; import { FakeEventStream } from './fake-event-stream'; -const sdkMock = vi.hoisted(() => ({ - createOpencode: null as ((opts: unknown) => unknown) | null, - createOpencodeClient: null as ((opts: unknown) => unknown) | null, -})); +const sdkMock = vi.hoisted( + (): { + createOpencode: ((opts: unknown) => unknown) | null; + createOpencodeClient: ((opts: unknown) => unknown) | null; + liveClient: unknown; + } => ({ createOpencode: null, createOpencodeClient: null, liveClient: null }), +); vi.mock('@opencode-ai/sdk/v2', () => ({ - createOpencode(opts: unknown) { - if (!sdkMock.createOpencode) throw new Error('createOpencode mock not installed'); - return sdkMock.createOpencode(opts); + // History reads pass the stub server's url; the live session client is whatever the last + // bridged serve spawn produced (see the serve mock below). + createOpencodeClient(opts: { baseUrl?: string }) { + if (opts.baseUrl === 'http://stub') { + if (!sdkMock.createOpencodeClient) throw new Error('createOpencodeClient mock not installed'); + return sdkMock.createOpencodeClient(opts); + } + if (sdkMock.liveClient === null) throw new Error('no live server was started'); + return sdkMock.liveClient; }, - createOpencodeClient(opts: unknown) { - if (!sdkMock.createOpencodeClient) throw new Error('createOpencodeClient mock not installed'); - return sdkMock.createOpencodeClient(opts); +})); + +// The adapter spawns its per-session server through the owned serve helper (CODE-76), then builds +// the client separately — bridge that spawn back onto the `createOpencode`-shaped mock the cases +// install, so each case keeps stubbing one function that yields both client and server. +vi.mock('../native/opencode/serve', async (importOriginal) => ({ + ...(await importOriginal()), + async startOpencodeServe(opts: unknown) { + if (!sdkMock.createOpencode) throw new Error('createOpencode mock not installed'); + const started = (await sdkMock.createOpencode(opts)) as { + client: unknown; + server: { url?: string; close(): void }; + }; + sdkMock.liveClient = started.client; + return { url: started.server.url ?? 'http://fake', close: () => started.server.close() }; }, })); diff --git a/packages/host/agent-adapter/src/__tests__/opencode.test.ts b/packages/host/agent-adapter/src/__tests__/opencode.test.ts index cec855b04..5113f6762 100644 --- a/packages/host/agent-adapter/src/__tests__/opencode.test.ts +++ b/packages/host/agent-adapter/src/__tests__/opencode.test.ts @@ -4,14 +4,33 @@ import { wait } from 'foxts/wait'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { OpenCodeAdapter } from '../native/opencode'; -const sdkMock = vi.hoisted(() => ({ - createOpencode: null as ((opts: unknown) => unknown) | null, -})); +const sdkMock = vi.hoisted( + (): { + createOpencode: ((opts: unknown) => unknown) | null; + liveClient: unknown; + } => ({ createOpencode: null, liveClient: null }), +); vi.mock('@opencode-ai/sdk/v2', () => ({ - createOpencode(opts: unknown) { + createOpencodeClient() { + if (sdkMock.liveClient === null) throw new Error('no live server was started'); + return sdkMock.liveClient; + }, +})); + +// The adapter spawns its per-session server through the owned serve helper (CODE-76), then builds +// the client separately — bridge that spawn back onto the `createOpencode`-shaped mock the cases +// install, so each case keeps stubbing one function that yields both client and server. +vi.mock('../native/opencode/serve', async (importOriginal) => ({ + ...(await importOriginal()), + async startOpencodeServe(opts: unknown) { if (!sdkMock.createOpencode) throw new Error('createOpencode mock not installed'); - return sdkMock.createOpencode(opts); + const started = (await sdkMock.createOpencode(opts)) as { + client: unknown; + server: { url?: string; close(): void }; + }; + sdkMock.liveClient = started.client; + return { url: started.server.url ?? 'http://fake', close: () => started.server.close() }; }, })); diff --git a/packages/host/agent-adapter/src/native/opencode/adapter.ts b/packages/host/agent-adapter/src/native/opencode/adapter.ts index 350b63c52..1dd5ba83b 100644 --- a/packages/host/agent-adapter/src/native/opencode/adapter.ts +++ b/packages/host/agent-adapter/src/native/opencode/adapter.ts @@ -34,6 +34,8 @@ import { } from './history'; import type { OpencodeHistoryServerLike } from './history-server'; import { sharedOpencodeHistoryServer } from './history-server'; +import type { OpencodeServeHandle } from './serve'; +import { startOpencodeServe } from './serve'; type PermissionAsked = Extract['properties']; type QuestionAsked = Extract['properties']; @@ -103,7 +105,7 @@ function parseModelRef(model: string): { providerID: string; modelID: string } | } type OpencodeModule = typeof import('@opencode-ai/sdk/v2'); -type OpencodeClient = Awaited>['client']; +type OpencodeClient = ReturnType; /** * OpenCode adapter — the server/client model: `createOpencode()` spawns a local OpenCode server @@ -170,7 +172,7 @@ export class OpenCodeAdapter extends BaseAgentAdapter { protected async onStart(opts: StartOptions): Promise { const mod = await this.loadSdk('@opencode-ai/sdk', () => import('@opencode-ai/sdk/v2')); - let started: Awaited>; + let server: OpencodeServeHandle; // OpenCode routes by provider: inject the account's key + base URL under the model's // providerID (the half before '/') so the spawned server authenticates against that provider. const cred = readAgentCredential(opts.config); @@ -204,24 +206,30 @@ export class OpenCodeAdapter extends BaseAgentAdapter { // set-model can refuse a cross-provider switch the running server holds no credentials for. this.credentialProviderId = serverOptions ? (providerID ?? null) : null; try { - // The SDK's server port is a FIXED default of 4096 (opencode's own `--port=0` does not + // The server port is a FIXED default of 4096 (opencode's own `--port=0` does not // auto-allocate either), and this adapter spawns one server per session — without an // explicitly allocated free port, the second concurrent session's server dies at bind // (exit 1, ServeError) and the session never starts. allocatePort is check-then-use (the // port can be stolen between the probe and the child's bind), so one failed spawn retries // with a fresh port — the same discipline as the shared history server. try { - started = await mod.createOpencode({ ...serverOptions, port: await allocatePort() }); + server = await startOpencodeServe({ + port: await allocatePort(), + config: serverOptions?.config, + }); } catch { - started = await mod.createOpencode({ ...serverOptions, port: await allocatePort() }); + server = await startOpencodeServe({ + port: await allocatePort(), + config: serverOptions?.config, + }); } } catch (err) { const detail = extractErrorMessage(err) ?? 'Unknown error'; this.emitError(`opencode: failed to start server (${detail})`, 'sdk-unavailable', false); throw new Error(detail, { cause: err }); } - this.client = started.client; - this.closeServer = () => started.server.close(); + this.client = mod.createOpencodeClient({ baseUrl: server.url }); + this.closeServer = server.close; let resumedAgent: string | null = null; let resumedModel: string | undefined; if (this.resumeFrom) { diff --git a/packages/host/agent-adapter/src/native/opencode/history-server.ts b/packages/host/agent-adapter/src/native/opencode/history-server.ts index 43576e101..395f302d2 100644 --- a/packages/host/agent-adapter/src/native/opencode/history-server.ts +++ b/packages/host/agent-adapter/src/native/opencode/history-server.ts @@ -3,40 +3,23 @@ import { homedir } from 'node:os'; import { join } from 'node:path'; import { allocatePort } from '@linkcode/common/node'; import { linkcodeStateDirName } from '@linkcode/schema/daemon-runtime'; -import crossSpawn from 'cross-spawn'; -import { extractErrorMessage } from 'foxts/extract-error-message'; -/** Startup output kept for the failure message when the server never reports readiness. */ -const STARTUP_OUTPUT_CAP = 8192; -/** Rolling window the readiness regex runs over — bounded (unlike a capped-then-frozen buffer) so - * a chatty startup cannot stop it matching. */ -const READINESS_WINDOW = 512; -/** The server's readiness line: `opencode server listening on http://127.0.0.1:`. The - * trailing newline keeps a URL split across chunks from matching on its half-arrived prefix. */ -const RE_LISTENING = /listening on\s+(https?:\/\/\S+)\s*[\r\n]/; +import type { OpencodeServeProcess } from './serve'; +import { + awaitServeReady, + ServeStartupExitError, + serveProcessExited, + spawnOpencodeServe, + terminateServe, +} from './serve'; /** The slice of `ChildProcess` this manager drives — a structural type so tests can hand in a * plain fake instead of force-casting through `unknown`. */ -export interface HistoryServerProcess { - readonly exitCode: number | null; - readonly signalCode: NodeJS.Signals | null; - readonly stdout: { - on(event: 'data', cb: (chunk: Buffer) => void): unknown; - destroy(): void; - } | null; - readonly stderr: { - on(event: 'data', cb: (chunk: Buffer) => void): unknown; - destroy(): void; - } | null; - once(event: 'exit', cb: (code: number | null, signal: NodeJS.Signals | null) => void): unknown; - once(event: 'error', cb: (err: Error) => void): unknown; - kill(signal?: NodeJS.Signals): boolean; - unref(): void; -} +export type HistoryServerProcess = OpencodeServeProcess; export interface OpencodeHistoryServerOptions { - /** Test seam — the real thing resolves `opencode` from PATH (vendoring is CODE-76) and spawns - * `opencode serve`. */ + /** Test seam — the real thing spawns `opencode serve` from the probe-resolved binary + * ({@link spawnOpencodeServe}). */ spawnServer?: (args: { port: number; cwd: string }) => HistoryServerProcess; allocatePort?: () => Promise; /** Spawn cwd. opencode indexes its cwd as the default workspace, so this must be a neutral @@ -60,18 +43,6 @@ interface ServerGeneration { ready: Promise; } -/** Exit is read off the process itself — both fields stay null until it is truly gone, so there - * is no shadow flag to drift out of sync with reality. */ -function processExited(proc: HistoryServerProcess): boolean { - return proc.exitCode !== null || proc.signalCode !== null; -} - -/** A startup-window death, as opposed to a timeout or spawn failure — the one failure mode worth - * one automatic retry, because the pre-allocated port can be stolen between probe and bind. */ -class StartupExitError extends Error { - override name = 'StartupExitError'; -} - /** * Daemon-shared `opencode serve` for history reads (CODE-171): history `list`/`read` run on * never-started adapter instances, so the backing server belongs to no live session — it is a @@ -103,7 +74,7 @@ export class OpencodeHistoryServer implements OpencodeHistoryServerLike { * daemon exit.) Process 'exit' handlers cannot wait; SIGKILL is the only reliable cleanup left. */ private readonly onProcessExit = (): void => { const proc = this.current?.proc; - if (proc && !processExited(proc)) proc.kill('SIGKILL'); + if (proc && !serveProcessExited(proc)) proc.kill('SIGKILL'); }; constructor(options: OpencodeHistoryServerOptions = {}) { @@ -136,8 +107,8 @@ export class OpencodeHistoryServer implements OpencodeHistoryServerLike { const generation = this.current; this.current = null; this.startPromise = null; - if (!generation || processExited(generation.proc)) return; - const stopping = terminate(generation.proc, this.shutdownGraceMs).finally(() => { + if (!generation || serveProcessExited(generation.proc)) return; + const stopping = terminateServe(generation.proc, this.shutdownGraceMs).finally(() => { if (this.stopping === stopping) this.stopping = null; }); this.stopping = stopping; @@ -145,7 +116,7 @@ export class OpencodeHistoryServer implements OpencodeHistoryServerLike { } private async ensureRunning(): Promise { - if (this.current && !processExited(this.current.proc)) return this.current.ready; + if (this.current && !serveProcessExited(this.current.proc)) return this.current.ready; if (this.startPromise) return this.startPromise; this.startPromise = this.start().finally(() => { this.startPromise = null; @@ -160,7 +131,7 @@ export class OpencodeHistoryServer implements OpencodeHistoryServerLike { } catch (err) { // One retry with a fresh port: allocatePort is check-then-use, so the port can be stolen // between probe and bind (an immediate startup-window exit). A config failure just fails twice. - if (err instanceof StartupExitError) return this.spawnGeneration(); + if (err instanceof ServeStartupExitError) return this.spawnGeneration(); throw err; } } @@ -171,7 +142,10 @@ export class OpencodeHistoryServer implements OpencodeHistoryServerLike { if (this.stopping) await this.stopping; const port = await this.allocatePort(); const proc = this.spawnServer({ port, cwd: this.neutralCwd }); - const generation: ServerGeneration = { proc, ready: this.awaitReadiness(proc) }; + const generation: ServerGeneration = { + proc, + ready: awaitServeReady(proc, { timeoutMs: this.readyTimeoutMs, what: 'history server' }), + }; this.current = generation; if (!this.exitHookInstalled) { this.exitHookInstalled = true; @@ -185,72 +159,13 @@ export class OpencodeHistoryServer implements OpencodeHistoryServerLike { return await generation.ready; } catch (err) { if (this.current === generation) this.current = null; - if (!processExited(proc)) proc.kill('SIGKILL'); + if (!serveProcessExited(proc)) proc.kill('SIGKILL'); throw err; } } - private awaitReadiness(proc: HistoryServerProcess): Promise { - return new Promise((resolve, reject) => { - /** Capped capture for failure messages only — readiness matching never depends on it. */ - let output = ''; - /** Bounded rolling tail the readiness regex runs over; grows with every chunk regardless of - * how much came before, so verbose startup output cannot freeze detection. */ - let tail = ''; - let settled = false; - let timer: ReturnType | undefined; - // Listeners stay attached after settling (guarded by `settled`): success destroys the pipes - // anyway, and a failed startup kills the process, so explicit removal buys nothing. - const settle = (fn: () => void): void => { - if (settled) return; - settled = true; - if (timer !== undefined) clearTimeout(timer); - fn(); - }; - const fail = (headline: string, startupExit = false): void => { - settle(() => { - const detail = output.trim(); - const message = detail ? `${headline}\n${detail}` : headline; - reject(startupExit ? new StartupExitError(message) : new Error(message)); - }); - }; - timer = setTimeout(() => { - fail(`opencode: history server startup timed out after ${this.readyTimeoutMs}ms`); - }, this.readyTimeoutMs); - timer.unref(); - proc.stdout?.on('data', (chunk: Buffer) => { - if (settled) return; - const text = chunk.toString(); - if (output.length < STARTUP_OUTPUT_CAP) output += text; - tail = (tail + text).slice(-READINESS_WINDOW); - const match = RE_LISTENING.exec(tail); - if (!match) return; - const url = match[1]; - settle(() => { - // Detach the pipes and unref so an idle-but-running server never keeps the daemon's - // event loop alive; crash detection stays on the 'exit' listener. - proc.stdout?.destroy(); - proc.stderr?.destroy(); - proc.unref(); - resolve(url); - }); - }); - proc.stderr?.on('data', (chunk: Buffer) => { - if (!settled && output.length < STARTUP_OUTPUT_CAP) output += chunk.toString(); - }); - proc.once('error', (err) => { - fail( - `opencode: failed to spawn history server (${extractErrorMessage(err) ?? 'unknown error'})`, - ); - }); - proc.once('exit', (code) => { - fail(`opencode: history server exited during startup (code ${code})`, true); - }); - }); - } - private armIdleTimer(): void { - if (!this.current || processExited(this.current.proc)) return; + if (!this.current || serveProcessExited(this.current.proc)) return; this.clearIdleTimer(); this.idleTimer = setTimeout(() => { this.idleTimer = null; @@ -270,26 +185,7 @@ export class OpencodeHistoryServer implements OpencodeHistoryServerLike { } function defaultSpawnServer(args: { port: number; cwd: string }): HistoryServerProcess { - // `--port=0` does NOT auto-allocate (falls back to 4096, verified live on 1.17.11) — the free - // port must be found up front. cross-spawn matches the SDK's PATH resolution, incl. Windows `.cmd`. - return crossSpawn('opencode', ['serve', '--hostname=127.0.0.1', `--port=${args.port}`], { - cwd: args.cwd, - stdio: ['ignore', 'pipe', 'pipe'], - windowsHide: true, - }); -} - -function terminate(proc: HistoryServerProcess, graceMs: number): Promise { - if (processExited(proc)) return Promise.resolve(); - return new Promise((resolve) => { - const killTimer = setTimeout(() => proc.kill('SIGKILL'), graceMs); - killTimer.unref(); - proc.once('exit', () => { - clearTimeout(killTimer); - resolve(); - }); - proc.kill('SIGTERM'); - }); + return spawnOpencodeServe({ port: args.port, cwd: args.cwd }); } let shared: OpencodeHistoryServer | null = null; diff --git a/packages/host/agent-adapter/src/native/opencode/serve.ts b/packages/host/agent-adapter/src/native/opencode/serve.ts new file mode 100644 index 000000000..102fb2695 --- /dev/null +++ b/packages/host/agent-adapter/src/native/opencode/serve.ts @@ -0,0 +1,197 @@ +import process from 'node:process'; +import crossSpawn from 'cross-spawn'; +import { extractErrorMessage } from 'foxts/extract-error-message'; +import { agentRuntimeProber } from '../../probe/prober'; + +/** Startup output kept for the failure message when the server never reports readiness. */ +const STARTUP_OUTPUT_CAP = 8192; +/** Rolling window the readiness regex runs over — bounded (unlike a capped-then-frozen buffer) so + * a chatty startup cannot stop it matching. */ +const READINESS_WINDOW = 512; +/** The server's readiness line: `opencode server listening on http://127.0.0.1:`. The + * trailing newline keeps a URL split across chunks from matching on its half-arrived prefix. */ +const RE_LISTENING = /listening on\s+(https?:\/\/\S+)\s*[\r\n]/; + +/** The slice of `ChildProcess` the serve helpers drive — a structural type so tests can hand in a + * plain fake instead of force-casting through `unknown`. */ +export interface OpencodeServeProcess { + readonly exitCode: number | null; + readonly signalCode: NodeJS.Signals | null; + readonly stdout: { + on(event: 'data', cb: (chunk: Buffer) => void): unknown; + destroy(): void; + } | null; + readonly stderr: { + on(event: 'data', cb: (chunk: Buffer) => void): unknown; + destroy(): void; + } | null; + once(event: 'exit', cb: (code: number | null, signal: NodeJS.Signals | null) => void): unknown; + once(event: 'error', cb: (err: Error) => void): unknown; + kill(signal?: NodeJS.Signals): boolean; + unref(): void; +} + +/** Exit is read off the process itself — both fields stay null until it is truly gone, so there + * is no shadow flag to drift out of sync with reality. */ +export function serveProcessExited(proc: OpencodeServeProcess): boolean { + return proc.exitCode !== null || proc.signalCode !== null; +} + +/** A startup-window death, as opposed to a timeout or spawn failure — the one failure mode worth + * one automatic retry, because a pre-allocated port can be stolen between probe and bind. */ +export class ServeStartupExitError extends Error { + override name = 'ServeStartupExitError'; +} + +/** + * The `opencode` binary every serve spawn runs: managed install → detected user install (the + * boot probe, CODE-76) → the bare name for hosts that never probed (dev/standalone daemons), + * where the inherited PATH still resolves it. GUI-launched packaged apps inherit a stripped + * PATH, which is why bare-name resolution alone used to fail there with `spawn opencode ENOENT`. + */ +export function resolveOpencodeBinary(): string { + return agentRuntimeProber.resolveBinary('opencode') ?? 'opencode'; +} + +export interface OpencodeServeSpawnOptions { + port: number; + cwd?: string; + /** Inline opencode config, delivered the way the SDK's `createOpencodeServer` does — + * serialized into the `OPENCODE_CONFIG_CONTENT` env var. Omitted = no env override at all. */ + config?: unknown; +} + +/** Spawn `opencode serve` on loopback. cross-spawn matches the SDK's resolution semantics for a + * bare name (incl. Windows `.cmd`); `--port=0` does NOT auto-allocate (falls back to 4096, + * verified live on 1.17.11), so callers must pre-allocate a free port. */ +export function spawnOpencodeServe(opts: OpencodeServeSpawnOptions): OpencodeServeProcess { + return crossSpawn( + resolveOpencodeBinary(), + ['serve', '--hostname=127.0.0.1', `--port=${opts.port}`], + { + ...(opts.cwd !== undefined && { cwd: opts.cwd }), + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + ...(opts.config !== undefined && { + env: { ...process.env, OPENCODE_CONFIG_CONTENT: JSON.stringify(opts.config) }, + }), + }, + ); +} + +/** + * Wait for the serve process to report readiness and resolve its base URL. On success the pipes + * are destroyed and the process unref'd so an idle server never keeps the daemon's event loop + * alive; crash detection stays on the caller's own 'exit' listener. Failures reject with the + * captured startup output; a startup-window exit rejects {@link ServeStartupExitError} so callers + * can retry once with a fresh port. + */ +export function awaitServeReady( + proc: OpencodeServeProcess, + { timeoutMs, what }: { timeoutMs: number; what: string }, +): Promise { + return new Promise((resolve, reject) => { + /** Capped capture for failure messages only — readiness matching never depends on it. */ + let output = ''; + /** Bounded rolling tail the readiness regex runs over; grows with every chunk regardless of + * how much came before, so verbose startup output cannot freeze detection. */ + let tail = ''; + let settled = false; + let timer: ReturnType | undefined; + // Listeners stay attached after settling (guarded by `settled`): success destroys the pipes + // anyway, and a failed startup kills the process, so explicit removal buys nothing. + const settle = (fn: () => void): void => { + if (settled) return; + settled = true; + if (timer !== undefined) clearTimeout(timer); + fn(); + }; + const fail = (headline: string, startupExit = false): void => { + settle(() => { + const detail = output.trim(); + const message = detail ? `${headline}\n${detail}` : headline; + reject(startupExit ? new ServeStartupExitError(message) : new Error(message)); + }); + }; + timer = setTimeout(() => { + fail(`opencode: ${what} startup timed out after ${timeoutMs}ms`); + }, timeoutMs); + timer.unref(); + proc.stdout?.on('data', (chunk: Buffer) => { + if (settled) return; + const text = chunk.toString(); + if (output.length < STARTUP_OUTPUT_CAP) output += text; + tail = (tail + text).slice(-READINESS_WINDOW); + const match = RE_LISTENING.exec(tail); + if (!match) return; + const url = match[1]; + settle(() => { + proc.stdout?.destroy(); + proc.stderr?.destroy(); + proc.unref(); + resolve(url); + }); + }); + proc.stderr?.on('data', (chunk: Buffer) => { + if (!settled && output.length < STARTUP_OUTPUT_CAP) output += chunk.toString(); + }); + proc.once('error', (err) => { + fail(`opencode: failed to spawn ${what} (${extractErrorMessage(err) ?? 'unknown error'})`); + }); + proc.once('exit', (code) => { + fail(`opencode: ${what} exited during startup (code ${code})`, true); + }); + }); +} + +/** Graceful shutdown: SIGTERM, escalating to SIGKILL after the grace window (the SDK's own + * `close()` is a bare SIGTERM with no escalation). Resolves once the process is gone. */ +export function terminateServe(proc: OpencodeServeProcess, graceMs: number): Promise { + if (serveProcessExited(proc)) return Promise.resolve(); + return new Promise((resolve) => { + const killTimer = setTimeout(() => proc.kill('SIGKILL'), graceMs); + killTimer.unref(); + proc.once('exit', () => { + clearTimeout(killTimer); + resolve(); + }); + proc.kill('SIGTERM'); + }); +} + +export interface OpencodeServeHandle { + url: string; + close: () => void; +} + +/** SIGTERM→SIGKILL grace for a per-session server shutdown. */ +const SESSION_SHUTDOWN_GRACE_MS = 5000; + +/** + * Spawn a per-session `opencode serve` and wait for readiness — the owned replacement for the + * SDK's `createOpencodeServer`, which hard-codes bare-name PATH resolution (and no + * `windowsHide`). A failed startup kills the process before rejecting. + */ +export async function startOpencodeServe(opts: { + port: number; + config?: unknown; + readyTimeoutMs?: number; +}): Promise { + // Always deliver a config env (`{}` when none) to mirror the SDK spawn this replaces. + const proc = spawnOpencodeServe({ port: opts.port, config: opts.config ?? {} }); + try { + const url = await awaitServeReady(proc, { + timeoutMs: opts.readyTimeoutMs ?? 30000, + what: 'server', + }); + return { + url, + close() { + void terminateServe(proc, SESSION_SHUTDOWN_GRACE_MS); + }, + }; + } catch (err) { + if (!serveProcessExited(proc)) proc.kill('SIGKILL'); + throw err; + } +} diff --git a/packages/host/agent-adapter/src/probe/base.ts b/packages/host/agent-adapter/src/probe/base.ts index 4efc735f7..420daff29 100644 --- a/packages/host/agent-adapter/src/probe/base.ts +++ b/packages/host/agent-adapter/src/probe/base.ts @@ -15,8 +15,8 @@ export interface DetectedAgentRuntime { version: string; } -/** The agent kinds that spawn an external CLI (pi is in-process; opencode is PATH-based until CODE-76). */ -export type ProbeableKind = 'claude-code' | 'codex' | 'grok-build'; +/** The agent kinds that spawn an external CLI (pi is in-process). */ +export type ProbeableKind = 'claude-code' | 'codex' | 'grok-build' | 'opencode'; /** * One agent's CLI probe: where its user-installed binary may live and how to verify a candidate is diff --git a/packages/host/agent-adapter/src/probe/index.ts b/packages/host/agent-adapter/src/probe/index.ts index f72bb156a..a33e07d3f 100644 --- a/packages/host/agent-adapter/src/probe/index.ts +++ b/packages/host/agent-adapter/src/probe/index.ts @@ -3,5 +3,6 @@ export { AgentCliProbe } from './base'; export { ClaudeCodeProbe, parseClaudeAuthStatus } from './claude-code'; export { CodexProbe, parseCodexLoginStatus } from './codex'; export { GrokBuildProbe } from './grok-build'; +export { OpencodeProbe } from './opencode'; export type { DetectedAgentRuntimes, ManagedEntryRuntime } from './prober'; export { AgentRuntimeProber, agentRuntimeProber } from './prober'; diff --git a/packages/host/agent-adapter/src/probe/opencode.ts b/packages/host/agent-adapter/src/probe/opencode.ts new file mode 100644 index 000000000..6bc85f24c --- /dev/null +++ b/packages/host/agent-adapter/src/probe/opencode.ts @@ -0,0 +1,59 @@ +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import process from 'node:process'; +import { AgentCliProbe } from './base'; + +/** `opencode --version` prints the bare semver (`1.18.2`, verified) — no vendor marker exists, + * so the anchored whole-output shape is the only impostor screen available. */ +const RE_OPENCODE_VERSION = /^(\d+\.\d+\.\d+(?:-\S+)?)$/; + +function opencodeInstallLocations(): string[] { + const home = homedir(); + const name = process.platform === 'win32' ? 'opencode.exe' : 'opencode'; + switch (process.platform) { + case 'darwin': + case 'linux': + // The official install script targets ~/.opencode/bin (verified against + // https://opencode.ai/install); homebrew/npm installs ride the shared PATH/fallback scan. + return [join(home, '.opencode', 'bin', name)]; + default: + return []; + } +} + +/** + * Detect a user-installed opencode CLI (CODE-76). The managed tier is the `agent:opencode` + * asset (npm `opencode--` platform packages); in-tree there is no CLI carrier — + * `@opencode-ai/sdk` is pure JS — so sdk presence checks fail closed like grok's. + */ +export class OpencodeProbe extends AgentCliProbe { + readonly kind = 'opencode' as const; + protected readonly binaryBase = 'opencode'; + protected readonly sdkPackage = '@opencode-ai/sdk'; + + /** @param locations test seam — overrides the per-platform known install locations. */ + constructor(private readonly locationOverrides?: string[]) { + super(locationOverrides); + } + + override knownLocations(): string[] { + if (this.locationOverrides) return this.locationOverrides; + return [...new Set([...super.knownLocations(), ...opencodeInstallLocations()])]; + } + + parseVersion(stdout: string): string | undefined { + return RE_OPENCODE_VERSION.exec(stdout.trim())?.[1]; + } + + protected platformPackageBase(): string { + return 'opencode'; + } + + override sdkPlatformPackagePresent(): boolean { + return false; + } + + override sdkPlatformBinaryPath(): string | undefined { + return undefined; + } +} diff --git a/packages/host/agent-adapter/src/probe/prober.ts b/packages/host/agent-adapter/src/probe/prober.ts index 78b44e25a..9fb23522b 100644 --- a/packages/host/agent-adapter/src/probe/prober.ts +++ b/packages/host/agent-adapter/src/probe/prober.ts @@ -3,6 +3,7 @@ import type { AgentCliProbe, DetectedAgentRuntime, ProbeableKind } from './base' import { ClaudeCodeProbe } from './claude-code'; import { CodexProbe } from './codex'; import { GrokBuildProbe } from './grok-build'; +import { OpencodeProbe } from './opencode'; import { piSdkPresent } from './pi'; export type DetectedAgentRuntimes = Partial>; @@ -28,6 +29,7 @@ export class AgentRuntimeProber { new ClaudeCodeProbe(), new CodexProbe(), new GrokBuildProbe(), + new OpencodeProbe(), ], ) {} @@ -87,8 +89,8 @@ export class AgentRuntimeProber { /** * Availability of every evaluated agent runtime, for the `agent-runtime.list` wire resource; - * re-runs the detection probe. opencode is absent until it moves off PATH-spawning (CODE-76); - * `source: 'sdk'` entries carry no binary facts (SDK resolution happens only at session start). + * re-runs the detection probe. `source: 'sdk'` entries carry no binary facts (SDK resolution + * happens only at session start). */ async collect(): Promise { const detected = await this.probe(); diff --git a/packages/host/agent-adapter/tests/integration/probe.test.ts b/packages/host/agent-adapter/tests/integration/probe.test.ts index 408c74a01..e122634fa 100644 --- a/packages/host/agent-adapter/tests/integration/probe.test.ts +++ b/packages/host/agent-adapter/tests/integration/probe.test.ts @@ -7,6 +7,7 @@ import { ClaudeCodeProbe, CodexProbe, GrokBuildProbe, + OpencodeProbe, parseClaudeAuthStatus, parseCodexLoginStatus, } from '../../src/probe'; @@ -49,6 +50,11 @@ describe('version parsers', () => { expect(codex.parseVersion('0.142.4')).toBeUndefined(); expect(grok.parseVersion('grok 0.2.102 (ab5ebf69acec)\n')).toBe('0.2.102'); expect(grok.parseVersion('0.2.102')).toBeUndefined(); + // opencode prints a bare semver — the anchored whole-output shape is the only marker. + const opencode = new OpencodeProbe(); + expect(opencode.parseVersion('1.18.2\n')).toBe('1.18.2'); + expect(opencode.parseVersion('opencode 1.18.2')).toBeUndefined(); + expect(opencode.parseVersion('not a version')).toBeUndefined(); }); }); @@ -213,6 +219,16 @@ describe('AgentCliProbe.knownLocations', () => { version: '0.2.102', }); }); + + it('detects opencode through PATH before its vendor-specific fallbacks', async () => { + const dir = mkdtempSync(join(tmpdir(), 'probe-')); + const real = fakeCli(dir, 'opencode', '1.18.2'); + vi.stubEnv('PATH', dir); + await expect(new OpencodeProbe().detect()).resolves.toEqual({ + path: real, + version: '1.18.2', + }); + }); }); describe('AgentCliProbe.detect', () => {