Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 3 additions & 2 deletions apps/daemon/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
Expand Down
2 changes: 1 addition & 1 deletion packages/client/workbench/src/agent-runtime/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
4 changes: 2 additions & 2 deletions packages/client/workbench/src/agent-runtime/onboarding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion packages/client/workbench/src/mock/dev-mock-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
6 changes: 3 additions & 3 deletions packages/host/agent-adapter/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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
Expand All @@ -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 |

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<object>()),
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() };
},
}));

Expand Down
29 changes: 24 additions & 5 deletions packages/host/agent-adapter/src/__tests__/opencode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<object>()),
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() };
},
}));

Expand Down
Loading