From d072ff47aec090cd77cbdb31d0731c23c3553699 Mon Sep 17 00:00:00 2001 From: Nathan Font-Fife Date: Thu, 9 Jul 2026 14:34:57 -0600 Subject: [PATCH 1/9] =?UTF-8?q?docs(spec):=20interactive=20transport=20pil?= =?UTF-8?q?lar=204=20=E2=80=94=20opencode=20ACP=20design?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- ...ctive-transport-pillar4-opencode-design.md | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-09-interactive-transport-pillar4-opencode-design.md diff --git a/docs/superpowers/specs/2026-07-09-interactive-transport-pillar4-opencode-design.md b/docs/superpowers/specs/2026-07-09-interactive-transport-pillar4-opencode-design.md new file mode 100644 index 0000000..9929e54 --- /dev/null +++ b/docs/superpowers/specs/2026-07-09-interactive-transport-pillar4-opencode-design.md @@ -0,0 +1,62 @@ +# Interactive Run Transport — Pillar 4: opencode ACP — Design + +**Date:** 2026-07-09 +**Status:** Approved (approach A — provider-profile AcpSession) +**Depends on:** Pillars 1–3 (PR #3/#4, #5, #6): the `TransportSession` seam, canonical `AgentEvent` union, permission cards, tool rows, `usage.updated`, thinking rows, fallback ladder, hydration/endTurn sanitizers. + +## Goal + +opencode runs (the local-model carrier harness) become interactive behind the same seam: streaming, permission cards, tool rows, real cancel, restart revival — plus live per-session model switching over ACP (hosted ↔ LM Studio local, no respawn), native live context/window/cost metering, and three riders queued from the pillar-3 final review. This closes the interactive-transport milestone. + +## Live-verified protocol surface (opencode 1.17.11, probed 2026-07-09) + +1. **`opencode acp`** speaks standard ACP over stdio JSON-RPC — the same protocol family AcpSession (pillar 1) already implements for copilot. `initialize` reports `loadSession: true` and `sessionCapabilities: {close, fork, list, resume}`. +2. **`session/new {cwd, mcpServers}`** → `{sessionId, configOptions}`. `configOptions` includes `{id:'model', type:'select', currentValue, options:[{value,name}...]}` — the options list includes LM Studio local models (`lmstudio/...`, `lmstudio-remote/...`). +3. **`session/set_config_option {sessionId, configId:'model', value}`** → responds with updated configOptions (currentValue flipped). Verified live: switched to an `lmstudio/...` model per session, no respawn. (Param is `configId` — `configOptionId` is rejected with -32602.) +4. **Turn**: `session/prompt` responds at turn end with `{stopReason, usage:{inputTokens, outputTokens, totalTokens, thoughtTokens, cachedReadTokens}}`. `session/update` notification kinds observed: `agent_message_chunk` (streaming text), `agent_thought_chunk` (streaming reasoning), `tool_call` / `tool_call_update` (with `rawInput`, `locations`, `kind`, `status` — richer than copilot's), `usage_update {used, size, cost:{amount, currency}}`, `available_commands_update` (ignore). +5. **Cancel**: `session/cancel` notification; the in-flight `session/prompt` then resolves — observed stopReason was `end_turn` (NOT `cancelled`) in the probe, so the session must remember its own cancel and map any terminal to canceled. +6. **Revival**: fresh process → `session/load {sessionId, cwd, mcpServers}` → codeword recalled ("durian"). Load may replay history via session/update — pillar 1's `replaying` guard already covers this. +7. **Silent empty turn**: prompting while the selected local model is NOT loaded in LM Studio returns immediately with `stopReason:'end_turn'`, zero tokens, zero text — no error. Fail-honest handling required. +8. **Permissions**: no `session/request_permission` surfaced in probes (default opencode agent config auto-allowed a cwd write). AcpSession's existing generic handling covers it if/when opencode sends one; the live matrix checks with a restrictive scenario and records the observed behavior. + +## Architecture + +Approach A: parameterize `AcpSession` with a provider profile rather than adding a fourth session class — opencode is a protocol twin of copilot. + +### Changed units + +- **`src/main/runtime/acp/acpSession.ts`** — constructor gains a profile: `{ provider: 'copilot' | 'opencode'; command: string; args: string[] }` (default = copilot's current spawn). Internally: + - Spawn uses the profile's command/args. + - The `session/update` switch gains profile-gated cases (opencode only): + - `usage_update` → `usage.updated { runId, inputTokens: 0, outputTokens: 0, contextUsedTokens: used, contextWindow: size }`; the latest `cost.amount` is remembered and folded into `run.completed.usage.costUsd`. + - `agent_thought_chunk` → thinking row (`toolCallId: thinking_`, kind `reasoning`, title "Thinking…", running; completed on the first `agent_message_chunk` or `tool_call` of the turn — mirrors pillar 3). + - `available_commands_update` → ignored. + - **Model config**: the session records `appliedModel` (seeded from the connect response's `configOptions` `currentValue`). `prompt(runId, text, opts)`: when `opts.model` is set and differs, `await session/set_config_option {configId:'model', value: opts.model}` BEFORE `session/prompt`; update `appliedModel` on ack; on error, proceed with the prompt (the harness keeps its current model — fail-open, ledger records the outcome honestly). Copilot profile ignores `opts.model` exactly as today. + - **Cancel mapping**: `cancel()` sets an `interrupted` flag (cleared per turn); when resolving the prompt response, `interrupted` maps ANY stopReason to `canceled` (pillar 2/3 precedent; covers opencode's observed `end_turn`-after-cancel). + - **Empty-turn notice**: for the opencode profile, if the turn completes `end_turn` with zero streamed `agent_message_chunk` text AND `usage.outputTokens === 0`, emit a render-only notice row (`toolCallId: empty_`, kind `notice`, status `failed`, title "model returned nothing — is the local model loaded?") BEFORE `run.completed`. Never `content.delta`. +- **`src/main/runtime/acp/sessionManager.ts`** — factory: `opencode` → `new AcpSession(sink, yolo, OPENCODE_PROFILE)`; provider union widens to all four. Entry stays provider-tagged. +- **`src/main/runtime/ipc.ts`** — opencode joins the interactive guard; fallback ternary gains the opencode case dispatching the existing `startOpenCodeRun` (with sessionId + `variant: req.effort`) after the standard `fallback_` notice row. The lower one-shot block keeps only the stub. Ledger gate unchanged (opencode verdicts live — model is honored via set_config_option). + +### Riders (from the pillar-3 final review) + +- **`src/main/runtime/capabilities/jsonRpc.ts`**: `notify()`/`request()`/`answer()` writes guard on `closed`; constructor adds a swallowed stdin `'error'` listener — parity with streamJson (ab1b129). `request()` on a closed client already rejects; the guard covers the raw writes. Tests mirror streamJson's. +- **`src/main/runtime/acp/mapClaude.ts`**: `tool_result.content` array form (`[{type:'text', text}...]`) → joined text into row detail (string form unchanged). Test with the array fixture. +- **`src/main/runtime/acp/claudeSession.ts`**: `FRESH_VERIFY_MS` 500 → 1000 (cold-start flag-rejection headroom; cost is one-time at fresh-chat connect). Constant test updated. + +## Error handling + +- Connect failure (spawn dead, initialize/session-new error, session/load failure) → throw → `{ok:false}` → one-shot fallback with notice row (unchanged pillar-1 ladder; opencode's one-shot resume uses `-s `). +- `set_config_option` error → log-free fail-open (prompt proceeds on the harness's current model); the empty-turn notice and ledger capture real outcomes. +- Mid-turn child death → existing `JsonRpcClient` close path → run.errored (pillar-2 hardening, now with the stdin write guards). +- Junk/unknown update kinds → ignored (existing default). + +## Testing + +- Fixture tests for the new update kinds + set_config_option request shape (frames from the probes, recorded in `docs/research/opencode-acp-1.17.11.txt` written during implementation). +- Cancel-mapping and empty-turn-notice unit tests through AcpSession's existing test seams. +- Rider tests as above. +- **Live computer-use matrix (mandatory final task):** local-model turn (LM Studio-loaded model streams a real reply); mid-chat hosted→local model switch with context intact (the SUPER-HARD local leg); permission card behavior recorded (approve/deny if surfaced; note if opencode auto-allows by default); Stop mid-turn → canceled; two-turn continuity; restart revival via session/load; fallback (PATH-shadowed `opencode acp`) + `~` return + recovery; empty-turn notice with an unloaded local model; live context/window/cost in the Inspector; thinking row appears and collapses; copilot regression smoke (one interactive copilot turn — the shared class changed). + +## Non-goals + +- Effort/variant over ACP (one-shot keeps `variant`; configOptions exposed only `model`). pi.dev (still deferred, non-ACP). Fast mode (claude-specific). No-fake-pixels sweep beyond what the riders touch. From a7e79bebbf22a610c0b751725c89d4d56db3e335 Mon Sep 17 00:00:00 2001 From: Nathan Font-Fife Date: Thu, 9 Jul 2026 14:38:20 -0600 Subject: [PATCH 2/9] =?UTF-8?q?docs(plan):=20interactive=20transport=20pil?= =?UTF-8?q?lar=204=20=E2=80=94=20opencode=20ACP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- ...-interactive-transport-pillar4-opencode.md | 350 ++++++++++++++++++ 1 file changed, 350 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-09-interactive-transport-pillar4-opencode.md diff --git a/docs/superpowers/plans/2026-07-09-interactive-transport-pillar4-opencode.md b/docs/superpowers/plans/2026-07-09-interactive-transport-pillar4-opencode.md new file mode 100644 index 0000000..e1ba948 --- /dev/null +++ b/docs/superpowers/plans/2026-07-09-interactive-transport-pillar4-opencode.md @@ -0,0 +1,350 @@ +# Interactive Transport Pillar 4 — opencode ACP Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** opencode runs become interactive behind the `TransportSession` seam via a provider-profiled `AcpSession` — streaming, tool rows, cancel, revival, live per-session model switching (hosted ↔ LM Studio local), native context/window/cost metering — plus three riders from the pillar-3 review. Final transport pillar. + +**Architecture:** opencode is a protocol twin of copilot (standard ACP over `opencode acp`), so `AcpSession` gains a small constructor profile instead of a fourth class. Pure mapper extensions handle opencode-only update kinds; the session adds model-config, cancel-intent, and empty-turn bookkeeping. Manager/ipc widen to four providers. + +**Tech Stack:** Electron main (Node child_process via existing JsonRpcClient), TypeScript, vitest. No new dependencies. + +**Spec:** `docs/superpowers/specs/2026-07-09-interactive-transport-pillar4-opencode-design.md` (all protocol facts live-verified on opencode 1.17.11, 2026-07-09). + +## Global Constraints + +- Wrapper, never a harness; ONE canonical `AgentEvent` union — no new event types this pillar. +- Replay invariant: tool/permission/notice rows are render-only; never `content.delta` for notices; `buildReplayPrompt` reads only `turn.text`. +- Context-preservation doctrine: `session/load` failure THROWS (existing pillar-1 code) → one-shot fallback (`startOpenCodeRun` with sessionId). +- Model config: `session/set_config_option {sessionId, configId:'model', value}` (param is `configId`; `configOptionId` is rejected -32602). Await the ack BEFORE `session/prompt`; on error proceed fail-open (harness keeps its current model). +- Cancel: the session sets an `interrupted` flag; ANY stopReason maps to `canceled` when set (probe showed opencode reports `end_turn` after cancel). +- Empty-turn (opencode only): completed turn with zero streamed text AND `usage.outputTokens === 0` AND not interrupted → render-only notice row `empty_`, kind `notice`, status `failed`, title exactly `model returned nothing — is the local model loaded?` BEFORE `run.completed`. +- usage_update mapping: `{used, size, cost}` → `usage.updated { contextUsedTokens: used, contextWindow: size, inputTokens: 0, outputTokens: 0 }`; latest `cost.amount` → `run.completed.usage.costUsd`. +- Copilot behavior must be bit-identical when the profile is copilot (default) — its tests must pass unchanged. +- All tests green + `npm run typecheck` clean before every commit. Work in a NEW worktree from current main; NEVER touch `/Users/nathanielfife/Code/nac-code` from implementers. + +## Captured frames (fixtures — copy verbatim into tests) + +```jsonc +// session/new result (trimmed) +{"sessionId":"ses_0b781c29cffeuHhwhwfFSxeRlQ","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"opencode/big-pickle","options":[{"value":"lmstudio/qwen/qwen3-coder-30b","name":"LMStudio/Qwen3 Coder 30B"}]}]} +// streaming text +{"sessionUpdate":"agent_message_chunk","messageId":"msg_1","content":{"type":"text","text":"Created"}} +// thought stream +{"sessionUpdate":"agent_thought_chunk","messageId":"msg_2","content":{"type":"text","text":"The user wants me"}} +// tool call (richer than copilot: rawInput + locations) +{"sessionUpdate":"tool_call","toolCallId":"call_c95bdab20b584813b28ef777","title":"write","kind":"edit","status":"pending","locations":[],"rawInput":{}} +{"sessionUpdate":"tool_call_update","toolCallId":"call_c95bdab20b584813b28ef777","status":"in_progress","kind":"edit","title":"write","locations":[{"path":"/tmp/p4/p4-check.txt"}],"rawInput":{"content":"okra","filePath":"/tmp/p4/p4-check.txt"}} +// live context + cost +{"sessionUpdate":"usage_update","used":11524,"size":200000,"cost":{"amount":0,"currency":"USD"}} +// ignore +{"sessionUpdate":"available_commands_update","availableCommands":[]} +// session/prompt result +{"stopReason":"end_turn","usage":{"inputTokens":196,"outputTokens":15,"totalTokens":11552,"thoughtTokens":13,"cachedReadTokens":11328}} +// set_config_option: request params {"sessionId":"ses_x","configId":"model","value":"lmstudio/qwen/qwen3-coder-30b"} → result carries updated configOptions (currentValue flipped) +``` + +Note: opencode's `tool_call_update` uses status `in_progress` — NOT in copilot's observed set. `mapAcpUpdate`'s `TOOL_STATUSES` gate treats unknown statuses as `running` for `tool_call_update`, which is correct here; no change needed (Task 2 pins this with a fixture test). + +--- + +### Task 1: riders — jsonRpc stdin hardening, claude array tool_result, FRESH_VERIFY_MS + +**Files:** +- Modify: `src/main/runtime/capabilities/jsonRpc.ts` (constructor + `notify` + `answer`'s write) +- Modify: `src/main/runtime/acp/mapClaude.ts` (tool_result content extraction) +- Modify: `src/main/runtime/acp/claudeSession.ts` (`FRESH_VERIFY_MS` 500 → 1000) +- Test: `src/main/runtime/capabilities/jsonRpc.test.ts`, `src/main/runtime/acp/mapClaude.test.ts`, `src/main/runtime/acp/claudeSession.test.ts` + +**Interfaces:** no signature changes; `FRESH_VERIFY_MS` value changes to 1000. + +- [ ] **Step 1: Failing tests** + +```ts +// append to the 'JsonRpcClient close handling' describe in jsonRpc.test.ts +it('notify() after the child exited is a no-op (no write attempt on a dead stdin)', async () => { + const client = new JsonRpcClient(process.execPath, ['-e', 'process.exit(0)']) + await new Promise((resolve) => client.onClose(resolve)) + expect(() => client.notify('session/cancel', {})).not.toThrow() + expect(client.isClosed).toBe(true) +}) + +it('registers a stdin error listener (EPIPE while alive must not crash main)', () => { + const client = new JsonRpcClient(process.execPath, ['-e', 'setTimeout(()=>{},200)']) + // @ts-expect-error private child access for the assertion + expect(client.child.stdin.listenerCount('error')).toBeGreaterThan(0) + client.close() +}) +``` + +```ts +// append to the mapClaudeToolResult tests in mapClaude.test.ts +it('extracts detail from array-form tool_result content', () => { + const frame = { type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: 't9', is_error: false, content: [{ type: 'text', text: 'line one' }, { type: 'text', text: 'line two' }] }] } } + expect(mapClaudeToolResult('r', frame)[0]).toMatchObject({ status: 'completed', detail: 'line one\nline two' }) +}) +``` + +```ts +// in claudeSession.test.ts, update the constants test: +expect(FRESH_VERIFY_MS).toBe(1000) +``` + +- [ ] **Step 2: Verify all three fail.** +- [ ] **Step 3: Implement.** jsonRpc.ts: in the constructor after spawn add `this.child.stdin?.on('error', () => { /* EPIPE on a dying child must not crash the transport */ })`; add a private `write(payload: string): void { if (this.closed) return; this.child.stdin?.write(payload + '\n') }` and route `notify`, `answer`'s `write`, and `request`'s trailing write through it. mapClaude.ts: in `mapClaudeToolResult`, replace `const detail = s(b.content)` with: + +```ts +const detail = s(b.content) ?? (Array.isArray(b.content) + ? (b.content as { type?: string; text?: unknown }[]).map((c) => (c?.type === 'text' ? s(c.text) : undefined)).filter(Boolean).join('\n') || undefined + : undefined) +``` + +(widen the block type's `content?: unknown`). claudeSession.ts: `export const FRESH_VERIFY_MS = 1000` and update its doc comment (cold-start flag-rejection headroom; one-time cost at fresh connect). +- [ ] **Step 4: All three test files pass; `npx vitest run` full + `npm run typecheck` clean.** +- [ ] **Step 5: Commit** — `git commit -m "fix(riders): jsonRpc dead-stdin guards; claude array tool_result detail; FRESH_VERIFY_MS 1000"` + +--- + +### Task 2: mapAcp opencode extensions (pure) + +**Files:** +- Modify: `src/main/runtime/acp/mapAcp.ts` +- Create: `docs/research/opencode-acp-1.17.11.txt` (paste this plan's fixture block verbatim, one frame per line, header `opencode 1.17.11 ACP frames, live-captured 2026-07-09`) +- Test: `src/main/runtime/acp/mapAcp.test.ts` + +**Interfaces:** +- `mapAcpUpdate(runId: string, update: unknown, provider?: 'copilot' | 'opencode')` — third param optional, default `'copilot'`; existing call sites stay valid. +- New export: `usageUpdateCost(update: unknown): number | null` — `cost.amount` when the update is a `usage_update` with a numeric amount, else null. +- New export: `THINKING_ROW_PREFIX = 'thinking_'` (same convention as pillar 3). + +- [ ] **Step 1: Failing tests** + +```ts +// append to mapAcp.test.ts +describe('opencode profile extensions', () => { + it('maps usage_update to usage.updated with real window size (opencode only)', () => { + const u = { sessionUpdate: 'usage_update', used: 11524, size: 200000, cost: { amount: 0, currency: 'USD' } } + expect(mapAcpUpdate('r', u, 'opencode')).toEqual([{ type: 'usage.updated', runId: 'r', inputTokens: 0, outputTokens: 0, contextUsedTokens: 11524, contextWindow: 200000 }]) + expect(mapAcpUpdate('r', u)).toEqual([]) // copilot profile ignores it — bit-identical behavior + }) + it('maps agent_thought_chunk to a running thinking row (opencode only)', () => { + const u = { sessionUpdate: 'agent_thought_chunk', content: { type: 'text', text: 'The user wants me' } } + const [e] = mapAcpUpdate('r', u, 'opencode') + expect(e).toMatchObject({ type: 'tool.updated', toolCallId: 'thinking_r', title: 'Thinking…', kind: 'reasoning', status: 'running' }) + expect(mapAcpUpdate('r', u)).toEqual([]) + }) + it('treats in_progress tool_call_update as running (fixture status not in the copilot set)', () => { + const u = { sessionUpdate: 'tool_call_update', toolCallId: 'call_1', status: 'in_progress', kind: 'edit', title: 'write', rawInput: { content: 'okra', filePath: '/tmp/x' } } + expect(mapAcpUpdate('r', u, 'opencode')[0]).toMatchObject({ status: 'running', title: 'write' }) + }) + it('usageUpdateCost extracts cost.amount, null otherwise', () => { + expect(usageUpdateCost({ sessionUpdate: 'usage_update', used: 1, size: 2, cost: { amount: 0.12, currency: 'USD' } })).toBe(0.12) + expect(usageUpdateCost({ sessionUpdate: 'usage_update', used: 1, size: 2 })).toBeNull() + expect(usageUpdateCost({ sessionUpdate: 'agent_message_chunk' })).toBeNull() + expect(usageUpdateCost(null)).toBeNull() + }) + it('ignores available_commands_update in both profiles', () => { + expect(mapAcpUpdate('r', { sessionUpdate: 'available_commands_update', availableCommands: [] }, 'opencode')).toEqual([]) + }) +}) +``` + +- [ ] **Step 2: Verify failure.** +- [ ] **Step 3: Implement** in mapAcp.ts: + +```ts +export const THINKING_ROW_PREFIX = 'thinking_' + +export function usageUpdateCost(update: unknown): number | null { + const u = update as { sessionUpdate?: string; cost?: { amount?: unknown } } | null + if (!u || u.sessionUpdate !== 'usage_update') return null + return typeof u.cost?.amount === 'number' ? u.cost.amount : null +} +``` + +Extend `mapAcpUpdate(runId, update, provider: 'copilot' | 'opencode' = 'copilot')`; before the existing switch's default, add opencode-gated cases: + +```ts +case 'usage_update': { + if (provider !== 'opencode') return [] + const used = typeof (u as { used?: unknown }).used === 'number' ? (u as { used: number }).used : 0 + const size = typeof (u as { size?: unknown }).size === 'number' ? (u as { size: number }).size : undefined + return used > 0 ? [{ type: 'usage.updated', runId, inputTokens: 0, outputTokens: 0, contextUsedTokens: used, ...(size ? { contextWindow: size } : {}) }] : [] +} +case 'agent_thought_chunk': { + if (provider !== 'opencode') return [] + return [{ type: 'tool.updated', runId, toolCallId: `${THINKING_ROW_PREFIX}${runId}`, title: 'Thinking…', kind: 'reasoning', status: 'running' }] +} +``` + +(the `AcpUpdate` interface gains `used?: unknown; size?: unknown; cost?: { amount?: unknown }` as needed for typing). +- [ ] **Step 4: Pass + full suite + typecheck.** +- [ ] **Step 5: Write the research doc, then commit** — `git commit -m "feat(opencode): mapAcp opencode profile extensions from captured fixtures"` + +--- + +### Task 3: AcpSession provider profile + +**Files:** +- Modify: `src/main/runtime/acp/acpSession.ts` +- Test: `src/main/runtime/acp/acpSession.test.ts` + +**Interfaces:** +- New export: `interface AcpProfile { provider: 'copilot' | 'opencode'; command: string; args: string[] }` +- New exports: `COPILOT_PROFILE: AcpProfile = { provider: 'copilot', command: 'copilot', args: ['--acp'] }`, `OPENCODE_PROFILE: AcpProfile = { provider: 'opencode', command: 'opencode', args: ['acp'] }` +- `AcpSession` constructor: `(onEvent, yolo, profile: AcpProfile = COPILOT_PROFILE)`. +- New pure export for tests: `shouldEmitEmptyTurnNotice(provider: 'copilot' | 'opencode', hadText: boolean, outputTokens: number, interrupted: boolean): boolean` — true only for opencode, no text, zero output tokens, not interrupted. + +**Design notes (the class changes, complete):** + +1. Constructor: store `this.profile = profile`; spawn `new JsonRpcClient(profile.command, profile.args)`. The session/update handler becomes: + +```ts +this.client.onNotification('session/update', (params) => { + if (this.replaying || !this.currentRunId) return + const update = (params as { update?: unknown } | null)?.update + const cost = usageUpdateCost(update) + if (cost !== null) this.turnCost = cost + for (const e of mapAcpUpdate(this.currentRunId, update, this.profile.provider)) { + if (e.type === 'content.delta') { + this.turnHadText = true + this.closeThinkingRow() + } else if (e.type === 'tool.updated' && e.kind === 'reasoning') { + this.thinkingOpen = true + } else if (e.type === 'tool.updated') { + this.closeThinkingRow() + } + this.onEvent(e) + } +}) +``` + +with per-turn fields `turnHadText = false`, `turnCost: number | null = null`, `thinkingOpen = false`, `interrupted = false`, `appliedModel: string | null = null`, and: + +```ts +private closeThinkingRow(): void { + if (!this.thinkingOpen || !this.currentRunId) return + this.thinkingOpen = false + this.onEvent({ type: 'tool.updated', runId: this.currentRunId, toolCallId: `${THINKING_ROW_PREFIX}${this.currentRunId}`, title: 'Thinking…', kind: 'reasoning', status: 'completed' }) +} +``` + +2. `connect`: both the session/new and session/load results are read as `{ sessionId?, configOptions? }`; seed `this.appliedModel` from `configOptions?.find(o => o.id === 'model')?.currentValue` (string check). (session/load returns configOptions too — captured fixture.) + +3. `prompt(runId, text, opts)` keeps its sync signature; body resets per-turn state, emits run.started, then `void this.runTurn(runId, text, opts)`: + +```ts +private async runTurn(runId: string, text: string, opts?: PromptOpts): Promise { + try { + if (this.profile.provider === 'opencode' && opts?.model && opts.model !== this.appliedModel) { + try { + await this.client.request('session/set_config_option', { sessionId: this.sessionId, configId: 'model', value: opts.model }, HANDSHAKE_TIMEOUT_MS) + this.appliedModel = opts.model + } catch { + // fail-open: the harness keeps its current model; the ledger records real outcomes + } + } + const res = await this.client.request('session/prompt', { sessionId: this.sessionId, prompt: [{ type: 'text', text }] }, PROMPT_TIMEOUT_MS) + const stop = (res as { stopReason?: string } | null)?.stopReason + const u = (res as { usage?: { inputTokens?: number; outputTokens?: number } } | null)?.usage + this.expirePermissions() + this.closeThinkingRow() + const outputTokens = typeof u?.outputTokens === 'number' ? u.outputTokens : 0 + if (shouldEmitEmptyTurnNotice(this.profile.provider, this.turnHadText, outputTokens, this.interrupted)) { + this.onEvent({ type: 'tool.updated', runId, toolCallId: `empty_${runId}`, title: 'model returned nothing — is the local model loaded?', kind: 'notice', status: 'failed' }) + } + const usage = this.profile.provider === 'opencode' + ? { inputTokens: typeof u?.inputTokens === 'number' ? u.inputTokens : 0, outputTokens, ...(this.turnCost !== null ? { costUsd: this.turnCost } : {}) } + : undefined + this.onEvent({ type: 'run.completed', runId, stopReason: this.interrupted || stop === 'cancelled' ? 'canceled' : 'end_turn', ...(usage ? { usage } : {}) }) + } catch (e) { + this.expirePermissions() + this.onEvent({ type: 'run.errored', runId, message: (e as Error).message }) + } finally { + this.currentRunId = null + } +} +``` + +4. `cancel()` adds `this.interrupted = true` before the existing notify. `prompt` resets it to false at turn start. + +- [ ] **Step 1: Failing tests** (append to acpSession.test.ts — it already tests the pure helpers): + +```ts +import { shouldEmitEmptyTurnNotice, COPILOT_PROFILE, OPENCODE_PROFILE } from './acpSession' + +describe('pillar-4 profile', () => { + it('profiles carry the exact spawn specs', () => { + expect(COPILOT_PROFILE).toEqual({ provider: 'copilot', command: 'copilot', args: ['--acp'] }) + expect(OPENCODE_PROFILE).toEqual({ provider: 'opencode', command: 'opencode', args: ['acp'] }) + }) + it('empty-turn notice fires only for opencode, no text, zero tokens, not interrupted', () => { + expect(shouldEmitEmptyTurnNotice('opencode', false, 0, false)).toBe(true) + expect(shouldEmitEmptyTurnNotice('opencode', true, 0, false)).toBe(false) + expect(shouldEmitEmptyTurnNotice('opencode', false, 5, false)).toBe(false) + expect(shouldEmitEmptyTurnNotice('opencode', false, 0, true)).toBe(false) + expect(shouldEmitEmptyTurnNotice('copilot', false, 0, false)).toBe(false) + }) +}) +``` + +- [ ] **Step 2: Verify failure. Step 3: Implement per the notes (complete code above). Step 4: full suite + typecheck (copilot tests MUST pass unchanged). Step 5: Commit** — `git commit -m "feat(opencode): AcpSession provider profile — model config, cancel intent, empty-turn notice"` + +--- + +### Task 4: manager + ipc four-provider routing + +**Files:** +- Modify: `src/main/runtime/acp/sessionManager.ts` +- Modify: `src/main/runtime/ipc.ts` + +- [ ] **Step 1: sessionManager** — widen unions to `'copilot' | 'codex' | 'claude' | 'opencode'` (promptViaTransport opts + Entry.provider); factory: + +```ts +opts.provider === 'codex' + ? new CodexSession(sink, opts.yolo === true) + : opts.provider === 'claude' + ? new ClaudeSession(sink, opts.yolo === true, { model: opts.model, effort: opts.effort }) + : opts.provider === 'opencode' + ? new AcpSession(sink, opts.yolo === true, OPENCODE_PROFILE) + : new AcpSession(sink, opts.yolo === true) +``` + +Update the header comment (four providers). Import `OPENCODE_PROFILE` from './acpSession'. +- [ ] **Step 2: ipc.ts** — interactive guard: `req.provider === 'copilot' || req.provider === 'codex' || req.provider === 'claude' || req.provider === 'opencode'`; fallback ternary gains: + +```ts +: req.provider === 'opencode' + ? startOpenCodeRun(runId, { prompt: req.prompt, model: req.model, cwd: req.cwd, yolo: req.yolo, sessionId: req.sessionId, variant: req.effort }, handler) +``` + +Lower dispatch keeps ONLY the stub (`startOpenCodeRun` becomes unreachable there for provider 'opencode'; delete its case). Ledger gate unchanged. +- [ ] **Step 3: full suite + typecheck + `npm run build` clean. Step 4: Commit** — `git commit -m "feat(opencode): interactive-first routing — all four providers on the transport seam"` + +--- + +### Task 5: live verification (controller, computer use) + docs + final review + +**Files:** +- Modify: `docs/DECISIONS.md` + +- [ ] **Step 1: Live matrix** (worktree dev app, a fresh opencode chat; controller drives via computer use — adversarial, not just happy-path): + 1. Hosted-default turn: streaming reply, thinking row appears/collapses, live context bar with REAL window size (200K from usage_update), tool row on a file write. + 2. **Local-model turn**: pick an LM Studio model that IS currently loaded (check `curl localhost:1234/v1/models` first) → real streamed reply from the local model. + 3. **Mid-chat hosted→local switch**: same session, context recalled after the switch (set_config_option path; same child PID). + 4. Permission behavior: attempt a write outside cwd / restrictive op — record whether opencode surfaces session/request_permission (card renders + approve/deny work) or auto-allows (note in DECISIONS). + 5. Stop mid-turn → run ends canceled (interrupted-flag mapping covers `end_turn`-after-cancel). + 6. Two-turn continuity + restart revival (quit app → relaunch → recall via session/load; no double-append). + 7. Fallback: PATH-shadow `opencode` (fail only when argv contains `acp`) → notice row + one-shot completes + `~` returns → unshadow → recovery to interactive. + 8. **Empty-turn notice**: select an lmstudio model that is NOT loaded → notice row "model returned nothing — is the local model loaded?" renders, no fake success. + 9. Cost row: hosted turn accumulates real dollars if nonzero; local shows honest $0.00 → footer/Inspector consistent. + 10. Copilot regression smoke: one interactive copilot turn (card or tool row + reply) — the shared class changed. +- [ ] **Step 2: Final gate** — `npm run typecheck && npx vitest run && npm run build`. +- [ ] **Step 3: DECISIONS entry** at the top of Current phase (replace ``) + roadmap update (pillar 4 ✅ — interactive transport milestone COMPLETE; next roadmap item #2 no-fake-pixels sweep): + +```markdown +**✅ Interactive run transport — pillar 4, opencode ACP** (``): opencode runs are INTERACTIVE — the LAST pillar; every provider now runs on the TransportSession seam. opencode speaks standard ACP (`opencode acp`), so pillar 1's AcpSession gained a provider PROFILE instead of a fourth class: shared streaming/tool-rows/permissions/cancel/revival, plus opencode-gated extras — usage_update drives the live context bar with the REAL window size and real cost, agent_thought_chunk renders the thinking row. HEADLINE: per-session model switching over ACP via session/set_config_option (configId 'model'), hosted ↔ LM Studio LOCAL models, live, no respawn — the SUPER-HARD local-model requirement's interactive leg. Cancel intent is remembered session-side (opencode reports end_turn after session/cancel). Fail-honest: a completed turn with zero text and zero output tokens (unloaded local model) renders a notice row instead of a fake success. Riders landed: JsonRpcClient dead-stdin guards (parity with streamJson), claude array-form tool_result detail, FRESH_VERIFY_MS→1000ms. Verified live (computer-use matrix): hosted + LOCAL turns / mid-chat hosted→local switch with context intact / permission behavior recorded / Stop / continuity / restart revival / fallback + recovery / empty-turn notice / real window+cost / copilot regression smoke. Spec: `docs/superpowers/specs/2026-07-09-interactive-transport-pillar4-opencode-design.md`. +``` + +- [ ] **Step 4: Commit** — `git add docs/DECISIONS.md && git commit -m "docs: interactive transport pillar 4 done — opencode ACP verified live; milestone complete"` + +Then: final whole-branch review (most capable model) with a review package from the branch base, one fix subagent for findings, re-review, `superpowers:finishing-a-development-branch`. From 72bab7ca7f90424f4f398f9ff3ab8c9442f3fd4d Mon Sep 17 00:00:00 2001 From: Nathan Font-Fife Date: Thu, 9 Jul 2026 14:41:22 -0600 Subject: [PATCH 3/9] fix(riders): jsonRpc dead-stdin guards; claude array tool_result detail; FRESH_VERIFY_MS 1000 Co-Authored-By: Claude Fable 5 --- src/main/runtime/acp/claudeSession.test.ts | 2 +- src/main/runtime/acp/claudeSession.ts | 2 +- src/main/runtime/acp/mapClaude.test.ts | 4 ++++ src/main/runtime/acp/mapClaude.ts | 4 +++- src/main/runtime/capabilities/jsonRpc.test.ts | 14 ++++++++++++++ src/main/runtime/capabilities/jsonRpc.ts | 12 +++++++++--- 6 files changed, 32 insertions(+), 6 deletions(-) diff --git a/src/main/runtime/acp/claudeSession.test.ts b/src/main/runtime/acp/claudeSession.test.ts index 446f841..290814d 100644 --- a/src/main/runtime/acp/claudeSession.test.ts +++ b/src/main/runtime/acp/claudeSession.test.ts @@ -10,7 +10,7 @@ describe('ClaudeSession constants + respawn predicate', () => { expect(RESUME_VERIFY_MS).toBeLessThan(PROMPT_TIMEOUT_MS) }) it('verifies a FRESH spawn inside a short window well under the resume window', () => { - expect(FRESH_VERIFY_MS).toBe(500) + expect(FRESH_VERIFY_MS).toBe(1000) expect(FRESH_VERIFY_MS).toBeLessThan(RESUME_VERIFY_MS) }) it('needsRespawn: only when a known session exists and model/effort actually changed', () => { diff --git a/src/main/runtime/acp/claudeSession.ts b/src/main/runtime/acp/claudeSession.ts index 81efa0c..7f638f1 100644 --- a/src/main/runtime/acp/claudeSession.ts +++ b/src/main/runtime/acp/claudeSession.ts @@ -24,7 +24,7 @@ import { // without losing the process. export const RESUME_VERIFY_MS = 2000 -export const FRESH_VERIFY_MS = 500 +export const FRESH_VERIFY_MS = 1000 /** Pure + exported for testing: only respawn when a session actually exists to `--resume` into AND * a requested field is BOTH defined and different from what's currently spawned. An undefined diff --git a/src/main/runtime/acp/mapClaude.test.ts b/src/main/runtime/acp/mapClaude.test.ts index 219d38b..30acd3f 100644 --- a/src/main/runtime/acp/mapClaude.test.ts +++ b/src/main/runtime/acp/mapClaude.test.ts @@ -54,6 +54,10 @@ describe('mapClaudeAssistant / mapClaudeToolResult', () => { expect(mapClaudeToolResult('r', { type: 'user', message: { content: 42 } })).toEqual([]) expect(mapClaudeToolResult('r', { type: 'user' })).toEqual([]) }) + it('extracts detail from array-form tool_result content', () => { + const frame = { type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: 't9', is_error: false, content: [{ type: 'text', text: 'line one' }, { type: 'text', text: 'line two' }] }] } } + expect(mapClaudeToolResult('r', frame)[0]).toMatchObject({ status: 'completed', detail: 'line one\nline two' }) + }) it('never renders "undefined" in a title when a tool_use block lacks a name', () => { const [e] = mapClaudeAssistant('r', { type: 'assistant', message: { content: [{ type: 'tool_use', id: 'nx', input: { file_path: '/tmp/x' } }] } }) expect((e as { title: string }).title).toBe('tool /tmp/x') diff --git a/src/main/runtime/acp/mapClaude.ts b/src/main/runtime/acp/mapClaude.ts index 5a210d7..2287823 100644 --- a/src/main/runtime/acp/mapClaude.ts +++ b/src/main/runtime/acp/mapClaude.ts @@ -84,7 +84,9 @@ export function mapClaudeToolResult(runId: string, frame: Record (c?.type === 'text' ? s(c.text) : undefined)).filter(Boolean).join('\n') || undefined + : undefined) // title '' — upsertTool merges by toolCallId, so the running row's title survives. out.push({ type: 'tool.updated', runId, toolCallId: b.tool_use_id, title: '', status: b.is_error === true ? 'failed' : 'completed', ...(detail ? { detail } : {}) }) } diff --git a/src/main/runtime/capabilities/jsonRpc.test.ts b/src/main/runtime/capabilities/jsonRpc.test.ts index 40a31a1..b5daee5 100644 --- a/src/main/runtime/capabilities/jsonRpc.test.ts +++ b/src/main/runtime/capabilities/jsonRpc.test.ts @@ -89,4 +89,18 @@ describe('JsonRpcClient close handling', () => { expect(client.isClosed).toBe(true) await expect(client.request('initialize', {}, 200)).rejects.toThrow() }) + + it('notify() after the child exited is a no-op (no write attempt on a dead stdin)', async () => { + const client = new JsonRpcClient(process.execPath, ['-e', 'process.exit(0)']) + await new Promise((resolve) => client.onClose(resolve)) + expect(() => client.notify('session/cancel', {})).not.toThrow() + expect(client.isClosed).toBe(true) + }) + + it('registers a stdin error listener (EPIPE while alive must not crash main)', () => { + const client = new JsonRpcClient(process.execPath, ['-e', 'setTimeout(()=>{},200)']) + // @ts-expect-error private child access for the assertion + expect(client.child.stdin.listenerCount('error')).toBeGreaterThan(0) + client.close() + }) }) diff --git a/src/main/runtime/capabilities/jsonRpc.ts b/src/main/runtime/capabilities/jsonRpc.ts index d8d9e31..8d65487 100644 --- a/src/main/runtime/capabilities/jsonRpc.ts +++ b/src/main/runtime/capabilities/jsonRpc.ts @@ -62,6 +62,7 @@ export class JsonRpcClient { constructor(command: string, args: string[]) { this.child = spawn(command, args, { stdio: ['pipe', 'pipe', 'ignore'] }) + this.child.stdin?.on('error', () => { /* EPIPE on a dying child must not crash the transport */ }) this.child.stdout?.on('data', (chunk: Buffer) => { for (const line of this.lines.push(chunk)) { const msg = parseRpcLine(line) @@ -108,10 +109,15 @@ export class JsonRpcClient { this.pending.clear() } + private write(payload: string): void { + if (this.closed) return + this.child.stdin?.write(payload + '\n') + } + private answer(msg: RpcMessage): void { const handler = this.requestHandlers.get(msg.method!) const write = (body: object): void => { - this.child.stdin?.write(JSON.stringify({ jsonrpc: '2.0', id: msg.id, ...body }) + '\n') + this.write(JSON.stringify({ jsonrpc: '2.0', id: msg.id, ...body })) } if (!handler) { write({ error: { code: -32601, message: `unhandled: ${msg.method}` } }) @@ -147,7 +153,7 @@ export class JsonRpcClient { } notify(method: string, params?: unknown): void { - this.child.stdin?.write(JSON.stringify({ jsonrpc: '2.0', method, params: params ?? {} }) + '\n') + this.write(JSON.stringify({ jsonrpc: '2.0', method, params: params ?? {} })) } request(method: string, params?: unknown, timeoutMs = 5000): Promise { @@ -169,7 +175,7 @@ export class JsonRpcClient { reject(e) } }) - this.child.stdin?.write(payload + '\n') + this.write(payload) }) } From c3e01a01b38f10e9a93d718cbaf45b073e1fc5e4 Mon Sep 17 00:00:00 2001 From: Nathan Font-Fife Date: Thu, 9 Jul 2026 14:46:39 -0600 Subject: [PATCH 4/9] feat(opencode): mapAcp opencode profile extensions from captured fixtures - Add THINKING_ROW_PREFIX export for reasoning tool rows - Add usageUpdateCost() to extract cost.amount from usage_update events - Extend mapAcpUpdate with optional provider param (copilot | opencode) - Add usage_update case: maps to usage.updated with contextUsedTokens and contextWindow - Add agent_thought_chunk case: maps to a running thinking tool row - Copilot profile returns [] for both opencode-only kinds (bit-identical behavior) - Create docs/research/opencode-acp-1.17.11.txt with live-captured frames Co-Authored-By: Claude Fable 5 --- docs/research/opencode-acp-1.17.11.txt | 10 +++++++++ src/main/runtime/acp/mapAcp.test.ts | 29 +++++++++++++++++++++++++- src/main/runtime/acp/mapAcp.ts | 23 +++++++++++++++++++- 3 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 docs/research/opencode-acp-1.17.11.txt diff --git a/docs/research/opencode-acp-1.17.11.txt b/docs/research/opencode-acp-1.17.11.txt new file mode 100644 index 0000000..78dac67 --- /dev/null +++ b/docs/research/opencode-acp-1.17.11.txt @@ -0,0 +1,10 @@ +opencode 1.17.11 ACP frames, live-captured 2026-07-09 + +{"sessionId":"ses_0b781c29cffeuHhwhwfFSxeRlQ","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"opencode/big-pickle","options":[{"value":"lmstudio/qwen/qwen3-coder-30b","name":"LMStudio/Qwen3 Coder 30B"}]}]} +{"sessionUpdate":"agent_message_chunk","messageId":"msg_1","content":{"type":"text","text":"Created"}} +{"sessionUpdate":"agent_thought_chunk","messageId":"msg_2","content":{"type":"text","text":"The user wants me"}} +{"sessionUpdate":"tool_call","toolCallId":"call_c95bdab20b584813b28ef777","title":"write","kind":"edit","status":"pending","locations":[],"rawInput":{}} +{"sessionUpdate":"tool_call_update","toolCallId":"call_c95bdab20b584813b28ef777","status":"in_progress","kind":"edit","title":"write","locations":[{"path":"/tmp/p4/p4-check.txt"}],"rawInput":{"content":"okra","filePath":"/tmp/p4/p4-check.txt"}} +{"sessionUpdate":"usage_update","used":11524,"size":200000,"cost":{"amount":0,"currency":"USD"}} +{"sessionUpdate":"available_commands_update","availableCommands":[]} +{"stopReason":"end_turn","usage":{"inputTokens":196,"outputTokens":15,"totalTokens":11552,"thoughtTokens":13,"cachedReadTokens":11328}} diff --git a/src/main/runtime/acp/mapAcp.test.ts b/src/main/runtime/acp/mapAcp.test.ts index 39f3307..4b1a5f9 100644 --- a/src/main/runtime/acp/mapAcp.test.ts +++ b/src/main/runtime/acp/mapAcp.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { mapAcpUpdate, mapPermissionRequest } from './mapAcp' +import { mapAcpUpdate, mapPermissionRequest, usageUpdateCost, THINKING_ROW_PREFIX } from './mapAcp' const TOOL_CALL = { sessionUpdate: 'tool_call', toolCallId: 'call_MHx', title: 'Run echo nac-probe-ok', kind: 'execute', status: 'pending', rawInput: { command: 'echo nac-probe-ok', description: 'Run echo nac-probe-ok', mode: 'sync' } } const TOOL_DONE = { sessionUpdate: 'tool_call_update', toolCallId: 'call_MHx', status: 'completed', content: [{ type: 'content', content: { type: 'text', text: 'nac-probe-ok\n' } }], rawOutput: { content: 'nac-probe-ok\n' } } @@ -49,3 +49,30 @@ describe('mapPermissionRequest', () => { expect(mapPermissionRequest('r', 'x', { options: [] })).toBeNull() }) }) + +describe('opencode profile extensions', () => { + it('maps usage_update to usage.updated with real window size (opencode only)', () => { + const u = { sessionUpdate: 'usage_update', used: 11524, size: 200000, cost: { amount: 0, currency: 'USD' } } + expect(mapAcpUpdate('r', u, 'opencode')).toEqual([{ type: 'usage.updated', runId: 'r', inputTokens: 0, outputTokens: 0, contextUsedTokens: 11524, contextWindow: 200000 }]) + expect(mapAcpUpdate('r', u)).toEqual([]) // copilot profile ignores it — bit-identical behavior + }) + it('maps agent_thought_chunk to a running thinking row (opencode only)', () => { + const u = { sessionUpdate: 'agent_thought_chunk', content: { type: 'text', text: 'The user wants me' } } + const [e] = mapAcpUpdate('r', u, 'opencode') + expect(e).toMatchObject({ type: 'tool.updated', toolCallId: 'thinking_r', title: 'Thinking…', kind: 'reasoning', status: 'running' }) + expect(mapAcpUpdate('r', u)).toEqual([]) + }) + it('treats in_progress tool_call_update as running (fixture status not in the copilot set)', () => { + const u = { sessionUpdate: 'tool_call_update', toolCallId: 'call_1', status: 'in_progress', kind: 'edit', title: 'write', rawInput: { content: 'okra', filePath: '/tmp/x' } } + expect(mapAcpUpdate('r', u, 'opencode')[0]).toMatchObject({ status: 'running', title: 'write' }) + }) + it('usageUpdateCost extracts cost.amount, null otherwise', () => { + expect(usageUpdateCost({ sessionUpdate: 'usage_update', used: 1, size: 2, cost: { amount: 0.12, currency: 'USD' } })).toBe(0.12) + expect(usageUpdateCost({ sessionUpdate: 'usage_update', used: 1, size: 2 })).toBeNull() + expect(usageUpdateCost({ sessionUpdate: 'agent_message_chunk' })).toBeNull() + expect(usageUpdateCost(null)).toBeNull() + }) + it('ignores available_commands_update in both profiles', () => { + expect(mapAcpUpdate('r', { sessionUpdate: 'available_commands_update', availableCommands: [] }, 'opencode')).toEqual([]) + }) +}) diff --git a/src/main/runtime/acp/mapAcp.ts b/src/main/runtime/acp/mapAcp.ts index 39bafd2..a5cd2d2 100644 --- a/src/main/runtime/acp/mapAcp.ts +++ b/src/main/runtime/acp/mapAcp.ts @@ -15,6 +15,9 @@ interface AcpUpdate { rawInput?: { command?: string } rawOutput?: { content?: unknown } content?: AcpContentEntry[] | { text?: string } + used?: unknown + size?: unknown + cost?: { amount?: unknown } } const TOOL_STATUSES = new Set(['pending', 'running', 'completed', 'failed']) @@ -33,8 +36,16 @@ function contentText(u: AcpUpdate): string | undefined { return undefined } +export const THINKING_ROW_PREFIX = 'thinking_' + +export function usageUpdateCost(update: unknown): number | null { + const u = update as { sessionUpdate?: string; cost?: { amount?: unknown } } | null + if (!u || u.sessionUpdate !== 'usage_update') return null + return typeof u.cost?.amount === 'number' ? u.cost.amount : null +} + /** One session/update frame → 0..n AgentEvents. Unknown update kinds are ignored. */ -export function mapAcpUpdate(runId: string, update: unknown): AgentEvent[] { +export function mapAcpUpdate(runId: string, update: unknown, provider: 'copilot' | 'opencode' = 'copilot'): AgentEvent[] { const u = update as AcpUpdate | null if (!u || typeof u !== 'object') return [] switch (u.sessionUpdate) { @@ -49,6 +60,16 @@ export function mapAcpUpdate(runId: string, update: unknown): AgentEvent[] { const detail = asStringDetail(u.rawOutput?.content) ?? contentText(u) ?? u.rawInput?.command return [{ type: 'tool.updated', runId, toolCallId: u.toolCallId, title: u.title ?? u.toolCallId, kind: u.kind, status, ...(detail ? { detail } : {}) }] } + case 'usage_update': { + if (provider !== 'opencode') return [] + const used = typeof (u as { used?: unknown }).used === 'number' ? (u as { used: number }).used : 0 + const size = typeof (u as { size?: unknown }).size === 'number' ? (u as { size: number }).size : undefined + return used > 0 ? [{ type: 'usage.updated', runId, inputTokens: 0, outputTokens: 0, contextUsedTokens: used, ...(size ? { contextWindow: size } : {}) }] : [] + } + case 'agent_thought_chunk': { + if (provider !== 'opencode') return [] + return [{ type: 'tool.updated', runId, toolCallId: `${THINKING_ROW_PREFIX}${runId}`, title: 'Thinking…', kind: 'reasoning', status: 'running' }] + } default: return [] } From 5e45b4bdd21931cc0aaf266dfdb37fa6b037e93b Mon Sep 17 00:00:00 2001 From: Nathan Font-Fife Date: Thu, 9 Jul 2026 14:51:27 -0600 Subject: [PATCH 5/9] =?UTF-8?q?feat(opencode):=20AcpSession=20provider=20p?= =?UTF-8?q?rofile=20=E2=80=94=20model=20config,=20cancel=20intent,=20empty?= =?UTF-8?q?-turn=20notice?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- src/main/runtime/acp/acpSession.test.ts | 16 +++- src/main/runtime/acp/acpSession.ts | 114 +++++++++++++++++++----- 2 files changed, 107 insertions(+), 23 deletions(-) diff --git a/src/main/runtime/acp/acpSession.test.ts b/src/main/runtime/acp/acpSession.test.ts index bb4ba0e..4d2bf32 100644 --- a/src/main/runtime/acp/acpSession.test.ts +++ b/src/main/runtime/acp/acpSession.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest' import { homedir } from 'os' -import { pickAutoApprove, acpCwd, shouldAutoCancelPermission } from './acpSession' +import { pickAutoApprove, acpCwd, shouldAutoCancelPermission, shouldEmitEmptyTurnNotice, COPILOT_PROFILE, OPENCODE_PROFILE } from './acpSession' describe('pickAutoApprove', () => { it('picks the first allow-kind option', () => { @@ -37,3 +37,17 @@ describe('acpCwd', () => { expect(acpCwd('')).toBe(process.cwd()) }) }) + +describe('pillar-4 profile', () => { + it('profiles carry the exact spawn specs', () => { + expect(COPILOT_PROFILE).toEqual({ provider: 'copilot', command: 'copilot', args: ['--acp'] }) + expect(OPENCODE_PROFILE).toEqual({ provider: 'opencode', command: 'opencode', args: ['acp'] }) + }) + it('empty-turn notice fires only for opencode, no text, zero tokens, not interrupted', () => { + expect(shouldEmitEmptyTurnNotice('opencode', false, 0, false)).toBe(true) + expect(shouldEmitEmptyTurnNotice('opencode', true, 0, false)).toBe(false) + expect(shouldEmitEmptyTurnNotice('opencode', false, 5, false)).toBe(false) + expect(shouldEmitEmptyTurnNotice('opencode', false, 0, true)).toBe(false) + expect(shouldEmitEmptyTurnNotice('copilot', false, 0, false)).toBe(false) + }) +}) diff --git a/src/main/runtime/acp/acpSession.ts b/src/main/runtime/acp/acpSession.ts index 8bfc5a5..67a17c0 100644 --- a/src/main/runtime/acp/acpSession.ts +++ b/src/main/runtime/acp/acpSession.ts @@ -1,6 +1,6 @@ import { JsonRpcClient } from '../capabilities/jsonRpc' import type { AgentEvent, PermissionOption } from '../../../shared/runtime' -import { mapAcpUpdate, mapPermissionRequest } from './mapAcp' +import { mapAcpUpdate, mapPermissionRequest, usageUpdateCost, THINKING_ROW_PREFIX } from './mapAcp' import { resolveCwd } from '../paths' export const PROMPT_TIMEOUT_MS = 1_800_000 // 30 min — cancellation, not timeout, is the stop lever @@ -11,6 +11,22 @@ export interface PromptOpts { effort?: string } +export interface AcpProfile { + provider: 'copilot' | 'opencode' + command: string + args: string[] +} + +export const COPILOT_PROFILE: AcpProfile = { provider: 'copilot', command: 'copilot', args: ['--acp'] } +export const OPENCODE_PROFILE: AcpProfile = { provider: 'opencode', command: 'opencode', args: ['acp'] } + +/** Pure + exported for testing: the "model returned nothing" notice is opencode-only — it fires when + * a turn produced no assistant text and zero output tokens and wasn't a user-initiated cancel (a + * local model that silently no-ops looks identical to a cancel otherwise). */ +export function shouldEmitEmptyTurnNotice(provider: 'copilot' | 'opencode', hadText: boolean, outputTokens: number, interrupted: boolean): boolean { + return provider === 'opencode' && !hadText && outputTokens === 0 && !interrupted +} + export interface TransportSession { readonly busy: boolean readonly dead: boolean @@ -54,19 +70,44 @@ export class AcpSession implements TransportSession { private pendingPermissions = new Map() private onEvent: (e: AgentEvent) => void private yolo: boolean + private profile: AcpProfile + private turnHadText = false + private turnCost: number | null = null + private thinkingOpen = false + private interrupted = false + private appliedModel: string | null = null - constructor(onEvent: (e: AgentEvent) => void, yolo: boolean) { + constructor(onEvent: (e: AgentEvent) => void, yolo: boolean, profile: AcpProfile = COPILOT_PROFILE) { this.onEvent = onEvent this.yolo = yolo - this.client = new JsonRpcClient('copilot', ['--acp']) + this.profile = profile + this.client = new JsonRpcClient(profile.command, profile.args) this.client.onNotification('session/update', (params) => { if (this.replaying || !this.currentRunId) return const update = (params as { update?: unknown } | null)?.update - for (const e of mapAcpUpdate(this.currentRunId, update)) this.onEvent(e) + const cost = usageUpdateCost(update) + if (cost !== null) this.turnCost = cost + for (const e of mapAcpUpdate(this.currentRunId, update, this.profile.provider)) { + if (e.type === 'content.delta') { + this.turnHadText = true + this.closeThinkingRow() + } else if (e.type === 'tool.updated' && e.kind === 'reasoning') { + this.thinkingOpen = true + } else if (e.type === 'tool.updated') { + this.closeThinkingRow() + } + this.onEvent(e) + } }) this.client.onRequest('session/request_permission', (params) => this.handlePermission(params)) } + private closeThinkingRow(): void { + if (!this.thinkingOpen || !this.currentRunId) return + this.thinkingOpen = false + this.onEvent({ type: 'tool.updated', runId: this.currentRunId, toolCallId: `${THINKING_ROW_PREFIX}${this.currentRunId}`, title: 'Thinking…', kind: 'reasoning', status: 'completed' }) + } + setYolo(y: boolean): void { this.yolo = y } @@ -80,7 +121,8 @@ export class AcpSession implements TransportSession { if (existingSessionId) { try { this.replaying = true // session/load re-emits history as session/update — never re-append it - await this.client.request('session/load', { sessionId: existingSessionId, cwd: acpCwd(cwd), mcpServers: [] }, HANDSHAKE_TIMEOUT_MS) + const res = (await this.client.request('session/load', { sessionId: existingSessionId, cwd: acpCwd(cwd), mcpServers: [] }, HANDSHAKE_TIMEOUT_MS)) as { configOptions?: { id?: string; currentValue?: unknown }[] } | null + this.seedAppliedModel(res?.configOptions) this.sessionId = existingSessionId return existingSessionId } catch (e) { @@ -94,12 +136,18 @@ export class AcpSession implements TransportSession { this.replaying = false } } - const res = (await this.client.request('session/new', { cwd: acpCwd(cwd), mcpServers: [] }, HANDSHAKE_TIMEOUT_MS)) as { sessionId?: string } + const res = (await this.client.request('session/new', { cwd: acpCwd(cwd), mcpServers: [] }, HANDSHAKE_TIMEOUT_MS)) as { sessionId?: string; configOptions?: { id?: string; currentValue?: unknown }[] } if (!res?.sessionId) throw new Error('acp: session/new returned no sessionId') this.sessionId = res.sessionId + this.seedAppliedModel(res.configOptions) return res.sessionId } + private seedAppliedModel(configOptions: { id?: string; currentValue?: unknown }[] | undefined): void { + const model = configOptions?.find((o) => o.id === 'model')?.currentValue + if (typeof model === 'string') this.appliedModel = model + } + get loadedSessionId(): string | null { return this.sessionId } @@ -135,25 +183,46 @@ export class AcpSession implements TransportSession { return this.currentRunId !== null } - prompt(runId: string, text: string, _opts?: PromptOpts): void { - // pillar-1 limitation: copilot ACP runs the account-default model; opts are honored by CodexSession + prompt(runId: string, text: string, opts?: PromptOpts): void { if (!this.sessionId) throw new Error('acp: no session') this.currentRunId = runId + this.turnHadText = false + this.turnCost = null + this.thinkingOpen = false + this.interrupted = false this.onEvent({ type: 'run.started', runId, sessionId: this.sessionId }) - this.client - .request('session/prompt', { sessionId: this.sessionId, prompt: [{ type: 'text', text }] }, PROMPT_TIMEOUT_MS) - .then((res) => { - const stop = (res as { stopReason?: string } | null)?.stopReason - this.expirePermissions() // resolve open cards BEFORE the terminal event unmaps the run (Critical 1: order matters) - this.onEvent({ type: 'run.completed', runId, stopReason: stop === 'cancelled' ? 'canceled' : 'end_turn' }) - }) - .catch((e: Error) => { - this.expirePermissions() - this.onEvent({ type: 'run.errored', runId, message: e.message }) - }) - .finally(() => { - this.currentRunId = null - }) + void this.runTurn(runId, text, opts) + } + + private async runTurn(runId: string, text: string, opts?: PromptOpts): Promise { + try { + if (this.profile.provider === 'opencode' && opts?.model && opts.model !== this.appliedModel) { + try { + await this.client.request('session/set_config_option', { sessionId: this.sessionId, configId: 'model', value: opts.model }, HANDSHAKE_TIMEOUT_MS) + this.appliedModel = opts.model + } catch { + // fail-open: the harness keeps its current model; the ledger records real outcomes + } + } + const res = await this.client.request('session/prompt', { sessionId: this.sessionId, prompt: [{ type: 'text', text }] }, PROMPT_TIMEOUT_MS) + const stop = (res as { stopReason?: string } | null)?.stopReason + const u = (res as { usage?: { inputTokens?: number; outputTokens?: number } } | null)?.usage + this.expirePermissions() // resolve open cards BEFORE the terminal event unmaps the run (Critical 1: order matters) + this.closeThinkingRow() + const outputTokens = typeof u?.outputTokens === 'number' ? u.outputTokens : 0 + if (shouldEmitEmptyTurnNotice(this.profile.provider, this.turnHadText, outputTokens, this.interrupted)) { + this.onEvent({ type: 'tool.updated', runId, toolCallId: `empty_${runId}`, title: 'model returned nothing — is the local model loaded?', kind: 'notice', status: 'failed' }) + } + const usage = this.profile.provider === 'opencode' + ? { inputTokens: typeof u?.inputTokens === 'number' ? u.inputTokens : 0, outputTokens, ...(this.turnCost !== null ? { costUsd: this.turnCost } : {}) } + : undefined + this.onEvent({ type: 'run.completed', runId, stopReason: this.interrupted || stop === 'cancelled' ? 'canceled' : 'end_turn', ...(usage ? { usage } : {}) }) + } catch (e) { + this.expirePermissions() + this.onEvent({ type: 'run.errored', runId, message: (e as Error).message }) + } finally { + this.currentRunId = null + } } respondPermission(requestId: string, optionId: string): void { @@ -178,6 +247,7 @@ export class AcpSession implements TransportSession { } cancel(): void { + this.interrupted = true if (this.sessionId) this.client.notify('session/cancel', { sessionId: this.sessionId }) } From 36c4eb98bbcc0ae60aab50fd6dcdf7003974282a Mon Sep 17 00:00:00 2001 From: Nathan Font-Fife Date: Thu, 9 Jul 2026 14:57:31 -0600 Subject: [PATCH 6/9] fix(opencode): close the thinking row on errored turns too Co-Authored-By: Claude Fable 5 --- src/main/runtime/acp/acpSession.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/runtime/acp/acpSession.ts b/src/main/runtime/acp/acpSession.ts index 67a17c0..b21afcb 100644 --- a/src/main/runtime/acp/acpSession.ts +++ b/src/main/runtime/acp/acpSession.ts @@ -219,6 +219,7 @@ export class AcpSession implements TransportSession { this.onEvent({ type: 'run.completed', runId, stopReason: this.interrupted || stop === 'cancelled' ? 'canceled' : 'end_turn', ...(usage ? { usage } : {}) }) } catch (e) { this.expirePermissions() + this.closeThinkingRow() this.onEvent({ type: 'run.errored', runId, message: (e as Error).message }) } finally { this.currentRunId = null From 2e7fe3a29ab9701407c8bce62edd267eb8b23bf7 Mon Sep 17 00:00:00 2001 From: Nathan Font-Fife Date: Thu, 9 Jul 2026 15:01:16 -0600 Subject: [PATCH 7/9] =?UTF-8?q?feat(opencode):=20interactive-first=20routi?= =?UTF-8?q?ng=20=E2=80=94=20all=20four=20providers=20on=20the=20transport?= =?UTF-8?q?=20seam?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- src/main/runtime/acp/sessionManager.ts | 16 ++++++------ src/main/runtime/ipc.ts | 35 +++++++++++++------------- 2 files changed, 26 insertions(+), 25 deletions(-) diff --git a/src/main/runtime/acp/sessionManager.ts b/src/main/runtime/acp/sessionManager.ts index 8c85dc5..4952b71 100644 --- a/src/main/runtime/acp/sessionManager.ts +++ b/src/main/runtime/acp/sessionManager.ts @@ -1,18 +1,18 @@ import type { AgentEvent } from '../../../shared/runtime' -import { AcpSession, type TransportSession, type PromptOpts } from './acpSession' +import { AcpSession, type TransportSession, type PromptOpts, OPENCODE_PROFILE } from './acpSession' import { CodexSession } from './codexSession' import { ClaudeSession } from './claudeSession' -// One live transport session per chat — copilot ACP or codex app-server. Sessions are disposed on -// provider switch (promptViaTransport detects this when the renderer sends no sessionId — see -// below), a dead child process being replaced on the next prompt, app quit, or idle timeout. +// One live transport session per chat — copilot ACP, codex app-server, claude SDK, or opencode ACP. +// Sessions are disposed on provider switch (promptViaTransport detects this when the renderer sends +// no sessionId — see below), a dead child process being replaced on the next prompt, app quit, or idle timeout. export const IDLE_MS = 15 * 60_000 interface Entry { session: TransportSession idleTimer: ReturnType | null - provider: 'copilot' | 'codex' | 'claude' + provider: 'copilot' | 'codex' | 'claude' | 'opencode' // Mutable indirection so a reused session's event sink always points at the CURRENT caller's // onEvent, not the closure captured when the session was first created (Important 4). ref: { onEvent: (e: AgentEvent) => void } @@ -43,7 +43,7 @@ function disposeChat(chatId: string, force = false): void { /** Try the interactive path. Resolves { ok: false } when the transport is unavailable — caller falls back. */ export async function promptViaTransport(opts: { - provider: 'copilot' | 'codex' | 'claude' + provider: 'copilot' | 'codex' | 'claude' | 'opencode' chatId: string runId: string prompt: string @@ -92,7 +92,9 @@ export async function promptViaTransport(opts: { ? new CodexSession(sink, opts.yolo === true) : opts.provider === 'claude' ? new ClaudeSession(sink, opts.yolo === true, { model: opts.model, effort: opts.effort }) - : new AcpSession(sink, opts.yolo === true) + : opts.provider === 'opencode' + ? new AcpSession(sink, opts.yolo === true, OPENCODE_PROFILE) + : new AcpSession(sink, opts.yolo === true) try { await session.connect(opts.cwd, opts.sessionId) } catch { diff --git a/src/main/runtime/ipc.ts b/src/main/runtime/ipc.ts index 08fe40b..4874e7d 100644 --- a/src/main/runtime/ipc.ts +++ b/src/main/runtime/ipc.ts @@ -73,7 +73,7 @@ export function registerRuntimeIpc(getWindow: () => BrowserWindow | null): void } if (event.type === 'run.completed' || event.type === 'run.errored') runs.delete(runId) } - if (req.provider === 'copilot' || req.provider === 'codex' || req.provider === 'claude') { + if (req.provider === 'copilot' || req.provider === 'codex' || req.provider === 'claude' || req.provider === 'opencode') { // Interactive-first: persistent transport session; on { ok: false } fall back to the one-shot path. void promptViaTransport({ provider: req.provider, chatId: req.chatId ?? runId, runId, prompt: req.prompt, cwd: req.cwd, yolo: req.yolo, sessionId: req.sessionId, model: req.model, effort: req.effort, onEvent: handler }).then(({ ok }) => { if (!ok) { @@ -85,28 +85,27 @@ export function registerRuntimeIpc(getWindow: () => BrowserWindow | null): void ? startCodexRun(runId, { prompt: req.prompt, cwd: req.cwd, yolo: req.yolo, sessionId: req.sessionId, effort: req.effort, model: req.model }, handler) : req.provider === 'claude' ? startClaudeRun(runId, { prompt: req.prompt, sessionId: req.sessionId, cwd: req.cwd, yolo: req.yolo, model: req.model, effort: req.effort, fast: req.fast }, handler) - : startCopilotRun(runId, { prompt: req.prompt, cwd: req.cwd, yolo: req.yolo, sessionId: req.sessionId, effort: req.effort, model: req.model }, handler) + : req.provider === 'opencode' + ? startOpenCodeRun(runId, { prompt: req.prompt, model: req.model, cwd: req.cwd, yolo: req.yolo, sessionId: req.sessionId, variant: req.effort }, handler) + : startCopilotRun(runId, { prompt: req.prompt, cwd: req.cwd, yolo: req.yolo, sessionId: req.sessionId, effort: req.effort, model: req.model }, handler) ) } }) return { runId } } - // Provider 'claude' is handled above (interactive-first, with one-shot fallback); opencode - // dispatches its own adapter; the NDJSON stub covers the rest (until those adapters land). - const run = - req.provider === 'opencode' - ? startOpenCodeRun(runId, { prompt: req.prompt, model: req.model, cwd: req.cwd, yolo: req.yolo, sessionId: req.sessionId, variant: req.effort }, handler) - : startHarnessRun( - runId, - { - prompt: req.prompt, - command: process.execPath, - args: [stubHarnessPath(), req.prompt], - env: { ELECTRON_RUN_AS_NODE: '1' }, // run the .mjs with Electron's bundled Node - cwd: req.cwd - }, - handler - ) + // Providers copilot, codex, claude, and opencode are handled above (interactive-first, with one-shot + // fallback); the NDJSON stub covers the rest (until those adapters land). + const run = startHarnessRun( + runId, + { + prompt: req.prompt, + command: process.execPath, + args: [stubHarnessPath(), req.prompt], + env: { ELECTRON_RUN_AS_NODE: '1' }, // run the .mjs with Electron's bundled Node + cwd: req.cwd + }, + handler + ) runs.set(runId, run) return { runId } }) From 81236b2375d737b16fc350b8956a88c4a73b3abb Mon Sep 17 00:00:00 2001 From: Nathan Font-Fife Date: Thu, 9 Jul 2026 15:29:36 -0600 Subject: [PATCH 8/9] =?UTF-8?q?docs:=20interactive=20transport=20pillar=20?= =?UTF-8?q?4=20done=20=E2=80=94=20opencode=20ACP=20verified=20live;=20mile?= =?UTF-8?q?stone=20complete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- docs/DECISIONS.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index cb6a937..b88f62c 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -4,6 +4,8 @@ ## Current phase +**✅ Interactive run transport — pillar 4, opencode ACP** (`2e7fe3a` + live fixes): opencode runs are INTERACTIVE — the LAST pillar; every provider now runs on the TransportSession seam. opencode speaks standard ACP (`opencode acp`), so pillar 1's AcpSession gained a provider PROFILE instead of a fourth class: shared streaming/tool-rows/permissions/cancel/revival, plus opencode-gated extras — usage_update drives the live context bar with the REAL window size (200K/262K observed live) and real cost, agent_thought_chunk renders the thinking row. HEADLINE: per-session model switching over ACP via session/set_config_option (configId 'model'), hosted ↔ LM Studio LOCAL models, live, no respawn — the SUPER-HARD local-model requirement's interactive leg, verified end-to-end (hosted turn → switch → local qwen3.6-27b recalled the hosted turn's codeword, through a killed-child recovery + session/load). Cancel intent is remembered session-side (opencode reports end_turn after session/cancel) — verified live rescuing a turn hung on a dead LM Studio backend, and the interrupted gate correctly suppressed the empty-turn notice. Fail-honest: a completed turn with zero text and zero output tokens (unloaded local model) renders a notice row instead of a fake success (probe-fixture + unit-tested; live re-trigger impossible while ANY model is loaded — LM Studio serves the loaded model for any requested id, recorded). set_config_option fail-open verified live (-32602 on uncataloged id → turn proceeds on the harness's current model). Permission cards surface for outside-cwd writes (external_directory, opencode's own options); cwd writes auto-allow per opencode's defaults. Riders landed: JsonRpcClient dead-stdin guards, claude array-form tool_result detail, FRESH_VERIFY_MS→1000ms. Copilot regression smoke green (shared class). Env quirk worth a follow-up: opencode's built-in lmstudio catalog is static registry defaults (no live localhost scan) — a local `lmstudio-local` provider entry in opencode.json (added with owner approval, backup kept) exposes actually-loaded models. Spec: `docs/superpowers/specs/2026-07-09-interactive-transport-pillar4-opencode-design.md`. **The interactive-transport roadmap item is COMPLETE.** + **✅ Interactive run transport — pillar 3, claude stream-json** (`949cddb`): claude runs are INTERACTIVE — token streaming via stream_event deltas, permission cards built from claude's own can_use_tool requests INCLUDING its permission_suggestions ("Allow edits for session" = setMode acceptEdits echoed back verbatim via updatedPermissions — live-verified to suppress the next same-mode prompt), tool rows from tool_use/tool_result blocks, Stop = control_request interrupt (error_during_execution signature → canceled), YOLO = live set_permission_mode with the spawn carrying --allow-dangerously-skip-permissions (verified both directions on ONE child PID — no respawn). Long-lived `claude --print --input/output-format stream-json --permission-prompt-tool stdio` child per chat behind the same TransportSession seam via a new StreamJsonClient (typed frames — claude is NOT JSON-RPC). Revival: --resume fast-fails (~1.3s) on bogus ids → connect races a 2s window and THROWS into the one-shot fallback (context doctrine); restart revival verified (no history replay). Model/effort are spawn-bound: mid-chat model switches respawn-with-resume transparently (verified Sonnet→Opus: new PID with --model opus --resume , context intact). NEW surfaces: REAL cost (result.total_cost_usd → Inspector cost row accumulated real dollars) and live context metering (message_start/result usage → usage.updated). Riders landed: fallback turns demote contextLive (the `~` honesty marker returns — verified visible), and BOTH claude + codex watchdogs now measure inactivity, not turn duration. Verified live (computer-use matrix, all 10 items): approve / suggestion-escalation / deny (incl. claude's own acceptEdits + sandbox-safe semantics — no card when claude itself doesn't require one) / YOLO toggle / interrupt mid-stream / continuity / restart revival / model-switch respawn / fallback + ~ return + recovery / real cost + live context + thinking row (appears, collapses). Live verification caught one real bug the 165-test suite could not: claude's tool_result completions carry no title and the upsertTool spread erased the running row's title (fixed 949cddb). Spec: `docs/superpowers/specs/2026-07-09-interactive-transport-pillar3-claude-design.md`. **✅ Interactive run transport — pillar 2, codex app-server** (`636ebe7`): codex runs are INTERACTIVE with TOKEN STREAMING (item/agentMessage/delta — no more single-blob replies). Per-chat `codex app-server` session behind the same TransportSession seam (thread/start|resume → turn/start per send; run resolves on the turn/completed notification with a 30-min watchdog). Approvals render as permission cards built from the server's own availableDecisions (accept / acceptForSession / execpolicy-amendment / cancel — echoed verbatim, NAC invents nothing); fileChange rows carry per-change diff text (live-probed: the diff lives on `changes[]`, not the item); Stop = turn/interrupt. Per-turn model+effort ARE honored (unlike copilot's pillar-1 limitation) so codex ledger verdicts stay live. NEW: real token metering — thread/tokenUsage/updated drives a genuinely live Inspector context bar for codex chats (contextK/windowK stop being estimates; the `~` drops). Fallback floor: one-shot codexArgs path incl. `codex exec resume` for revival failures, with a render-only notice row; the chat recovers to interactive on the next send. Verified live (computer-use matrix): approval options / deny / streaming / interrupt / continuity / restart revival (no history double-append — the `replaying` guard holds) / fallback + recovery / diff-on-expand / real context numbers (claude chats keep the `~`). Live verification caught three real bugs the suite could not: declined items report `status:'declined'` (rendered ✓ as if run), Stop left the interrupted command's row spinning forever (endTurn now sweeps open rows to failed), and the fileChange diff field didn't exist where the mapper looked. Spec: `docs/superpowers/specs/2026-07-09-interactive-transport-pillar2-codex-design.md`. @@ -51,7 +53,7 @@ - ✅ **Pillar 1 — copilot ACP** (PR #3/#4, merged): permission cards, tool rows, real cancel, native + restart continuity, replay-clean. Live-verified. - ✅ **Pillar 2 — codex app-server**: token streaming, server-defined approval cards, diff-carrying edit rows, turn/interrupt, real token metering. Live-verified. - ✅ **Pillar 3 — claude stream-json**: streaming, suggestion-derived permission cards, live YOLO mode-switching, respawn-with-resume model switches, real cost + live context. Live-verified. - - ▶ **Pillar 4 — opencode acp** (next; last pillar). + - ✅ **Pillar 4 — opencode acp**: provider-profiled AcpSession, live model switching incl. LM Studio locals, real window+cost, thinking rows. Live-verified. **Milestone complete — all four providers interactive.** Next roadmap item: #2 no-fake-pixels sweep. 2. **No-fake-pixels sweep** — remove seed demo chats on fresh state; real Inspector context-window/cost rows (contextK + $0.42 are fake); agent picker wiring (`--agent`); closes M0-5 error/empty states. 3. **Context library polish** — edit notes; mid-conversation re-seed on attachment change; per-harness-native injection. 4. **Packaging** (electron-builder) — after #1 makes daily-driving real. Known constraint: registry/adapters assume PATH access, which Finder-launched packaged apps don't inherit. From 39f19498fe9f1d1a00d5a9a8530353977de7d85c Mon Sep 17 00:00:00 2001 From: Nathan Font-Fife Date: Thu, 9 Jul 2026 15:37:51 -0600 Subject: [PATCH 9/9] fix(ledger): zero-output completions are not works-evidence; FRESH_VERIFY_MS rationale; attribution-gap note Co-Authored-By: Claude Fable 5 --- docs/DECISIONS.md | 2 +- src/main/runtime/acp/claudeSession.ts | 2 ++ src/main/runtime/capabilities/ledger.test.ts | 11 ++++++++++- src/main/runtime/capabilities/ledger.ts | 8 ++++++++ src/main/runtime/ipc.ts | 4 ++-- 5 files changed, 23 insertions(+), 4 deletions(-) diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index b88f62c..4bc0bb4 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -4,7 +4,7 @@ ## Current phase -**✅ Interactive run transport — pillar 4, opencode ACP** (`2e7fe3a` + live fixes): opencode runs are INTERACTIVE — the LAST pillar; every provider now runs on the TransportSession seam. opencode speaks standard ACP (`opencode acp`), so pillar 1's AcpSession gained a provider PROFILE instead of a fourth class: shared streaming/tool-rows/permissions/cancel/revival, plus opencode-gated extras — usage_update drives the live context bar with the REAL window size (200K/262K observed live) and real cost, agent_thought_chunk renders the thinking row. HEADLINE: per-session model switching over ACP via session/set_config_option (configId 'model'), hosted ↔ LM Studio LOCAL models, live, no respawn — the SUPER-HARD local-model requirement's interactive leg, verified end-to-end (hosted turn → switch → local qwen3.6-27b recalled the hosted turn's codeword, through a killed-child recovery + session/load). Cancel intent is remembered session-side (opencode reports end_turn after session/cancel) — verified live rescuing a turn hung on a dead LM Studio backend, and the interrupted gate correctly suppressed the empty-turn notice. Fail-honest: a completed turn with zero text and zero output tokens (unloaded local model) renders a notice row instead of a fake success (probe-fixture + unit-tested; live re-trigger impossible while ANY model is loaded — LM Studio serves the loaded model for any requested id, recorded). set_config_option fail-open verified live (-32602 on uncataloged id → turn proceeds on the harness's current model). Permission cards surface for outside-cwd writes (external_directory, opencode's own options); cwd writes auto-allow per opencode's defaults. Riders landed: JsonRpcClient dead-stdin guards, claude array-form tool_result detail, FRESH_VERIFY_MS→1000ms. Copilot regression smoke green (shared class). Env quirk worth a follow-up: opencode's built-in lmstudio catalog is static registry defaults (no live localhost scan) — a local `lmstudio-local` provider entry in opencode.json (added with owner approval, backup kept) exposes actually-loaded models. Spec: `docs/superpowers/specs/2026-07-09-interactive-transport-pillar4-opencode-design.md`. **The interactive-transport roadmap item is COMPLETE.** +**✅ Interactive run transport — pillar 4, opencode ACP** (`2e7fe3a` + live fixes): opencode runs are INTERACTIVE — the LAST pillar; every provider now runs on the TransportSession seam. opencode speaks standard ACP (`opencode acp`), so pillar 1's AcpSession gained a provider PROFILE instead of a fourth class: shared streaming/tool-rows/permissions/cancel/revival, plus opencode-gated extras — usage_update drives the live context bar with the REAL window size (200K/262K observed live) and real cost, agent_thought_chunk renders the thinking row. HEADLINE: per-session model switching over ACP via session/set_config_option (configId 'model'), hosted ↔ LM Studio LOCAL models, live, no respawn — the SUPER-HARD local-model requirement's interactive leg, verified end-to-end (hosted turn → switch → local qwen3.6-27b recalled the hosted turn's codeword, through a killed-child recovery + session/load). Cancel intent is remembered session-side (opencode reports end_turn after session/cancel) — verified live rescuing a turn hung on a dead LM Studio backend, and the interrupted gate correctly suppressed the empty-turn notice. Fail-honest: a completed turn with zero text and zero output tokens (unloaded local model) renders a notice row instead of a fake success (probe-fixture + unit-tested; live re-trigger impossible while ANY model is loaded — LM Studio serves the loaded model for any requested id, recorded). set_config_option fail-open verified live (-32602 on uncataloged id → turn proceeds on the harness's current model). Known attribution gap (deferred): a fail-open model switch runs on the harness's CURRENT model but the ledger attributes the outcome to the REQUESTED model — fix rides with the no-fake-pixels sweep (needs the session to surface applied-model truth). Zero-output completions no longer record 'works' (isWorksEvidence). Permission cards surface for outside-cwd writes (external_directory, opencode's own options); cwd writes auto-allow per opencode's defaults. Riders landed: JsonRpcClient dead-stdin guards, claude array-form tool_result detail, FRESH_VERIFY_MS→1000ms. Copilot regression smoke green (shared class). Env quirk worth a follow-up: opencode's built-in lmstudio catalog is static registry defaults (no live localhost scan) — a local `lmstudio-local` provider entry in opencode.json (added with owner approval, backup kept) exposes actually-loaded models. Spec: `docs/superpowers/specs/2026-07-09-interactive-transport-pillar4-opencode-design.md`. **The interactive-transport roadmap item is COMPLETE.** **✅ Interactive run transport — pillar 3, claude stream-json** (`949cddb`): claude runs are INTERACTIVE — token streaming via stream_event deltas, permission cards built from claude's own can_use_tool requests INCLUDING its permission_suggestions ("Allow edits for session" = setMode acceptEdits echoed back verbatim via updatedPermissions — live-verified to suppress the next same-mode prompt), tool rows from tool_use/tool_result blocks, Stop = control_request interrupt (error_during_execution signature → canceled), YOLO = live set_permission_mode with the spawn carrying --allow-dangerously-skip-permissions (verified both directions on ONE child PID — no respawn). Long-lived `claude --print --input/output-format stream-json --permission-prompt-tool stdio` child per chat behind the same TransportSession seam via a new StreamJsonClient (typed frames — claude is NOT JSON-RPC). Revival: --resume fast-fails (~1.3s) on bogus ids → connect races a 2s window and THROWS into the one-shot fallback (context doctrine); restart revival verified (no history replay). Model/effort are spawn-bound: mid-chat model switches respawn-with-resume transparently (verified Sonnet→Opus: new PID with --model opus --resume , context intact). NEW surfaces: REAL cost (result.total_cost_usd → Inspector cost row accumulated real dollars) and live context metering (message_start/result usage → usage.updated). Riders landed: fallback turns demote contextLive (the `~` honesty marker returns — verified visible), and BOTH claude + codex watchdogs now measure inactivity, not turn duration. Verified live (computer-use matrix, all 10 items): approve / suggestion-escalation / deny (incl. claude's own acceptEdits + sandbox-safe semantics — no card when claude itself doesn't require one) / YOLO toggle / interrupt mid-stream / continuity / restart revival / model-switch respawn / fallback + ~ return + recovery / real cost + live context + thinking row (appears, collapses). Live verification caught one real bug the 165-test suite could not: claude's tool_result completions carry no title and the upsertTool spread erased the running row's title (fixed 949cddb). Spec: `docs/superpowers/specs/2026-07-09-interactive-transport-pillar3-claude-design.md`. diff --git a/src/main/runtime/acp/claudeSession.ts b/src/main/runtime/acp/claudeSession.ts index 7f638f1..d80b092 100644 --- a/src/main/runtime/acp/claudeSession.ts +++ b/src/main/runtime/acp/claudeSession.ts @@ -24,6 +24,8 @@ import { // without losing the process. export const RESUME_VERIFY_MS = 2000 +// 1000ms: cold-start headroom for a flag-rejecting older CLI (bogus --resume exits ~1.3s ⇒ resume gets 2000); +// cost is one-time at fresh-chat connect. export const FRESH_VERIFY_MS = 1000 /** Pure + exported for testing: only respawn when a session actually exists to `--resume` into AND diff --git a/src/main/runtime/capabilities/ledger.test.ts b/src/main/runtime/capabilities/ledger.test.ts index e538db7..21c2469 100644 --- a/src/main/runtime/capabilities/ledger.test.ts +++ b/src/main/runtime/capabilities/ledger.test.ts @@ -1,7 +1,16 @@ import { describe, it, expect } from 'vitest' -import { classifyModelRejection, CLAUDE_MODEL_REJECTION, mergeLedger, type Ledger } from './ledger' +import { classifyModelRejection, CLAUDE_MODEL_REJECTION, isWorksEvidence, mergeLedger, type Ledger } from './ledger' import { STATIC_CAPABILITIES } from '../../../shared/capabilities' +describe('isWorksEvidence', () => { + it('zero-output completions are not evidence a model works', () => { + expect(isWorksEvidence('end_turn', { outputTokens: 0 })).toBe(false) + expect(isWorksEvidence('end_turn', { outputTokens: 15 })).toBe(true) + expect(isWorksEvidence('end_turn', undefined)).toBe(true) + expect(isWorksEvidence('canceled', { outputTokens: 15 })).toBe(false) + }) +}) + describe('classifyModelRejection', () => { it('recognizes the three verified rejection shapes', () => { expect(classifyModelRejection(`{"type":"error","status":400,"error":{"type":"invalid_request_error","message":"The 'gpt-5-codex' model is not supported when using Codex with a ChatGPT account."}}`)).toBe(true) diff --git a/src/main/runtime/capabilities/ledger.ts b/src/main/runtime/capabilities/ledger.ts index e17b730..0ca1350 100644 --- a/src/main/runtime/capabilities/ledger.ts +++ b/src/main/runtime/capabilities/ledger.ts @@ -19,6 +19,14 @@ export function classifyModelRejection(message: string): boolean { return REJECTION_PATTERNS.some((p) => p.test(message)) } +/** Pure + exported for testing: a completion is only "works" evidence when it actually produced + * output. An opencode EMPTY TURN (unloaded local model) reports stopReason 'end_turn' with + * usage.outputTokens === 0 — that is not proof the model works. Completions without a usage object + * (claude one-shot, copilot, stub) carry no such signal, so they still count as before. */ +export function isWorksEvidence(stopReason: string, usage?: { outputTokens?: number }): boolean { + return stopReason === 'end_turn' && !(usage && usage.outputTokens === 0) +} + /** Pure + exported for testing: stamp `gated` onto caps models (and their variants) from ledger entries. * Model-level gating stays keyed on the model's own id; a gated variant id only stamps that variant * entry (the parent model's own gated flag is untouched unless the model's own id is also gated). */ diff --git a/src/main/runtime/ipc.ts b/src/main/runtime/ipc.ts index 4874e7d..36b1c6a 100644 --- a/src/main/runtime/ipc.ts +++ b/src/main/runtime/ipc.ts @@ -10,7 +10,7 @@ import { startCopilotRun } from './copilotAdapter' import { startOpenCodeRun } from './openCodeAdapter' import { probeProviders } from './registry' import { getCapabilities, invalidateCapabilities } from './capabilities' -import { classifyModelRejection } from './capabilities/ledger' +import { classifyModelRejection, isWorksEvidence } from './capabilities/ledger' import { recordOutcome } from './capabilities/ledgerStore' import { promptViaTransport, respondPermission as acpRespondPermission, cancelRun as acpCancelRun, disposeAll as acpDisposeAll } from './acp/sessionManager' @@ -69,7 +69,7 @@ export function registerRuntimeIpc(getWindow: () => BrowserWindow | null): void if (event.type === 'run.errored' && classifyModelRejection(event.message)) { recordOutcome(req.provider, ledgerModel, 'gated', event.message) invalidateCapabilities(req.provider) // next loadCaps (picker mount) re-fetches + re-merges the ledger - } else if (event.type === 'run.completed' && event.stopReason === 'end_turn') recordOutcome(req.provider, ledgerModel, 'works') + } else if (event.type === 'run.completed' && isWorksEvidence(event.stopReason, event.usage)) recordOutcome(req.provider, ledgerModel, 'works') } if (event.type === 'run.completed' || event.type === 'run.errored') runs.delete(runId) }