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
Original file line number Diff line number Diff line change
Expand Up @@ -238,10 +238,11 @@ describe('dev mock transport', () => {

let mark = piEvents.length;
client.attachSession(piSessionId);
await eventually(() => piEvents.length === mark + 2);
await eventually(() => piEvents.length === mark + 3);
expect(piEvents.slice(mark).map((event) => event.type)).toEqual([
'status',
'capabilities-update',
'available-commands-update',
]);

const opencodeSessionId = await client.startSession({
Expand Down
2 changes: 1 addition & 1 deletion packages/foundation/schema/src/model/agent/input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ export const AGENT_INPUT_CAPABILITIES = {
'claude-code': { slashCommands: true, shellCommand: false },
codex: { slashCommands: true, shellCommand: true },
opencode: { slashCommands: true, shellCommand: true },
pi: { slashCommands: false, shellCommand: false },
pi: { slashCommands: true, shellCommand: false },
'grok-build': { slashCommands: false, shellCommand: false },
} as const satisfies Readonly<Record<AgentKind, AgentCapabilities>>;

Expand Down
10 changes: 5 additions & 5 deletions packages/host/agent-adapter/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ Pins as of 2026-07 (package.json ranges are caret; the lockfile is the real pin)
| --- | --- | --- |
| claude-code | `@anthropic-ai/claude-agent-sdk` | 0.3.206 |
| codex | `@openai/codex` (CLI carrier — no JS SDK) | 0.144.1 |
| opencode | `@opencode-ai/sdk` | 1.17.18 |
| opencode | `@opencode-ai/sdk` | 1.18.3 |
| pi | `@earendil-works/pi-coding-agent` | 0.80.6 |
| grok-build | none (user-installed `grok` CLI; headless `-p`, not ACP) | detect `grok --version` (verified 0.2.102) |

Expand All @@ -45,8 +45,8 @@ Every new adapter MUST honor these (`base.ts`); downstream relies on them, they
| --- | --- | --- | --- |
| claude-code | ✓ | `Query#supportedCommands()` at Query creation + `commands_changed` full-replace push | ✗ (`!` bash mode is a TUI input-box feature with no verified programmatic entry) |
| codex | ✓ (`/compact` + enabled skills) | `skills/list` at start + `skills/changed` invalidation | ✓ `thread/shellCommand` |
| opencode | ✓ | `command.list({directory})` once at start (no change events exist — snapshot only) | ✓ `session.shell` |
| pi | ✗ (the SDK's own `session.prompt` still expands `/` text it receives — untouched pass-through) | — | ✗ |
| opencode | ✓ (commands + skills) | `command.list({directory})` once at start (no change events exist — snapshot only); binary ≥1.18 includes skills as `source:'skill'` entries | ✓ `session.shell` |
| pi | ✓ (prompt templates + skills as `skill:<name>`) | `DefaultResourceLoader.getPrompts()` + `getSkills()` once at start (snapshot only) | ✗ |

- **claude-code**: invocation = pushing `/name args` as a plain user message — the vendored CLI parses a leading `/` on every user message even in SDK streaming-input mode (verified in the binary), so `onCommand` rides `onPrompt` and the normal result-frame settle. Live-verified on the pinned 0.3.206×2.1.206 pair: a local command run through that path returns a synthetic assistant message + a zero-cost `result` — status does not hang. `SlashCommand.aliases` ride the normalized `AgentCommand.aliases` field (`agentCommandMatches` accepts them in composer/engine; menus display only the canonical name; invocation pushes the typed alias, which the CLI resolves itself). `system/local_command_output` (never observed live, handled per SDK types) renders as assistant text bracketed by `freshSegment()`.
- **claude-code `/usage` is the one intercepted command (CODE-213)**: mirroring the CLI's own TUI (a dialog, never transcript text), `onCommand` matching the catalog's `usage` entry (canonical or aliases `/cost`/`/stats`; literal name only until the async catalog lands) never forwards a prompt — `reportUsage()` serves it from the Query's get-usage control request and emits a structured `usage-report` event; failures emit a recoverable `error`, never a text fallback and never a silent no-op. No `result` frame follows, so `reportUsage` brackets itself with status `running`→`idle` per the base.ts turn contract — the control request is network-bound and can span a process respawn (crash / effort-max rebuild), and without the bracket the engine's input gate rejects concurrent input while the composer looks idle. The SDK method is EXPERIMENTAL (`usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET` — the name itself warns it will be renamed); it is feature-detected and confined to `reportUsage()` + `mapClaudeUsageReport()` (which zod-parses the reply at the trust boundary), verified against SDK 0.3.206 (live: the pinned pair advertises `aliases:['cost','stats']`).
Expand Down Expand Up @@ -88,7 +88,7 @@ Every new adapter MUST honor these (`base.ts`); downstream relies on them, they
- **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).
- **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.
- **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. Slash commands (CODE-313) snapshot prompt templates and skills from the session's resource loader; invocation sends `/name args` through the same lifecycle-safe prompt path so the SDK performs expansion.

## Capability, auth & cancel matrices

Expand All @@ -99,7 +99,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) |
| pi | ✗ | ✗ | ✗ | fixed bypass | | ✗ | in-process JS: managed npm-closure import (CODE-219) / dev node_modules |
| 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 |

`StartOptions.effort` enters through the same `onSetEffort` hook before `onStart`, so startup-only
Expand Down
2 changes: 1 addition & 1 deletion packages/host/agent-adapter/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"@linkcode/schema": "workspace:*",
"@linkcode/transport": "workspace:*",
"@openai/codex": "^0.144.1",
"@opencode-ai/sdk": "^1.17.18",
"@opencode-ai/sdk": "^1.18.3",
"cross-spawn": "^7.0.6",
"foxts": "^5.8.0",
"smol-toml": "^1.7.0",
Expand Down
2 changes: 1 addition & 1 deletion packages/host/agent-adapter/src/__tests__/base.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ describe('BaseAgentAdapter command/shell defaults', () => {
await a.start({ kind: 'pi', cwd: '/repo' });
expect(a.seen).toContainEqual({
type: 'capabilities-update',
capabilities: { slashCommands: false, shellCommand: false },
capabilities: { slashCommands: true, shellCommand: false },
});
});

Expand Down
4 changes: 2 additions & 2 deletions packages/host/agent-adapter/src/__tests__/opencode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1042,7 +1042,7 @@ describe('OpenCodeAdapter command catalog', () => {
client.command.list.mockReturnValueOnce({
data: [
{ name: 'review', description: 'Review code', template: 't', hints: ['<file>'] },
{ name: 'noop', template: 't', hints: [] },
{ name: 'karpathy-guidelines', source: 'skill', template: 'skill body', hints: [] },
],
});
return Promise.resolve({ client, server: { url: 'http://fake', close: closeServer } });
Expand All @@ -1056,7 +1056,7 @@ describe('OpenCodeAdapter command catalog', () => {
expect(catalog).toHaveLength(1);
expect(catalog[0].commands).toEqual([
{ name: 'review', description: 'Review code', argumentHint: '<file>' },
{ name: 'noop', description: undefined, argumentHint: undefined },
{ name: 'karpathy-guidelines', description: undefined, argumentHint: undefined },
]);
});

Expand Down
108 changes: 106 additions & 2 deletions packages/host/agent-adapter/src/__tests__/pi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ import { agentRuntimeProber } from '../probe';

let listener: ((event: AgentSessionEvent) => void) | undefined;
const prompt = vi.fn<AgentSession['prompt']>();
const resources = {
promptFail: false,
skillFail: false,
prompts: [] as Array<{ name: string; description: string; argumentHint?: string }>,
skills: [] as Array<{ name: string; description: string }>,
};

const session = {
abort: vi.fn(),
Expand All @@ -25,6 +31,14 @@ vi.mock('@earendil-works/pi-coding-agent', () => ({
AuthStorage: { create: () => ({ setRuntimeApiKey: vi.fn() }) },
DefaultResourceLoader: class {
reload = vi.fn();
getPrompts() {
if (resources.promptFail) throw new Error('prompt discovery failed');
return { prompts: resources.prompts, diagnostics: [] };
}
getSkills() {
if (resources.skillFail) throw new Error('skill discovery failed');
return { skills: resources.skills, diagnostics: [] };
}
},
ModelRegistry: {
create: () => ({
Expand All @@ -42,14 +56,20 @@ function record(adapter: PiAdapter): AgentEvent[] {
return events;
}

async function startedAdapter(): Promise<{ adapter: PiAdapter; events: AgentEvent[] }> {
async function startAdapter(): Promise<{ adapter: PiAdapter; events: AgentEvent[] }> {
const adapter = new PiAdapter();
const events = record(adapter);
await adapter.start({ kind: 'pi', cwd: '/tmp/repo' });
events.length = 0;
return { adapter, events };
}

async function startedAdapter(): Promise<{ adapter: PiAdapter; events: AgentEvent[] }> {
const result = await startAdapter();
const { events } = result;
events.length = 0;
return result;
}

function assistant(stopReason: 'aborted' | 'error' | 'stop', errorMessage?: string): object {
return { role: 'assistant', stopReason, errorMessage };
}
Expand All @@ -62,9 +82,93 @@ describe('PiAdapter lifecycle', () => {
beforeEach(() => {
listener = undefined;
prompt.mockReset();
resources.promptFail = false;
resources.skillFail = false;
resources.prompts = [];
resources.skills = [];
vi.spyOn(agentRuntimeProber, 'resolveEntry').mockReturnValue(undefined);
});

it('advertises prompt templates and skills in its slash-command catalog', async () => {
resources.prompts = [
{ name: 'review', description: 'Review changes', argumentHint: '<target>' },
{ name: 'bare', description: '' },
];
resources.skills = [
{ name: 'pdf', description: 'PDF tools' },
{ name: 'user-only', description: '' },
];

const { adapter, events } = await startAdapter();

expect(adapter.capabilities.slashCommands).toBe(true);
expect(events).toContainEqual({
type: 'available-commands-update',
commands: [
{ name: 'review', description: 'Review changes', argumentHint: '<target>' },
{ name: 'bare', description: undefined, argumentHint: undefined },
{ name: 'skill:pdf', description: 'PDF tools' },
{ name: 'skill:user-only', description: undefined },
],
});
});

it('publishes an empty catalog without failing the session when discovery fails', async () => {
resources.promptFail = true;
resources.skillFail = true;

const { events } = await startAdapter();

expect(events).toContainEqual({ type: 'available-commands-update', commands: [] });
expect(events.at(-1)).toEqual({ type: 'status', status: 'idle' });
});

it.each([
{
failed: 'prompt templates',
configure() {
resources.promptFail = true;
resources.skills = [{ name: 'pdf', description: 'PDF tools' }];
},
commands: [{ name: 'skill:pdf', description: 'PDF tools' }],
},
{
failed: 'skills',
configure() {
resources.skillFail = true;
resources.prompts = [{ name: 'review', description: 'Review changes' }];
},
commands: [{ name: 'review', description: 'Review changes', argumentHint: undefined }],
},
])('preserves valid commands when $failed discovery fails', async (testCase) => {
testCase.configure();

const { events } = await startAdapter();

expect(events).toContainEqual({
type: 'available-commands-update',
commands: testCase.commands,
});
expect(events.at(-1)).toEqual({ type: 'status', status: 'idle' });
});

it('runs slash commands through the prompt lifecycle', async () => {
const { adapter, events } = await startedAdapter();

await adapter.send({ type: 'command', name: 'skill:pdf', arguments: 'extract a.pdf' });
expect(prompt).toHaveBeenCalledWith('/skill:pdf extract a.pdf', undefined);
expect(events).toEqual([{ type: 'status', status: 'running' }]);

emit({ type: 'agent_end', messages: [assistant('stop')], willRetry: false });
emit({ type: 'agent_settled' });

expect(events).toEqual([
{ type: 'status', status: 'running' },
{ type: 'stop', stopReason: 'end_turn' },
{ type: 'status', status: 'idle' },
]);
});

it('unwinds a prompt rejected after announcing running', async () => {
prompt.mockImplementationOnce(() => {
emit({
Expand Down
Loading