diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md
index 56d8866..d7ece30 100644
--- a/docs/DECISIONS.md
+++ b/docs/DECISIONS.md
@@ -4,6 +4,8 @@
## Current phase
+**✅ Interactive run transport — pillar 1, copilot ACP** (`77747cf`): copilot runs are INTERACTIVE. A persistent `copilot --acp` session per chat (spawn → initialize → session/new, revived via session/load) sits behind a provider-generic `TransportSession` seam; the M4 JSON-RPC client learned to answer server→client requests (that's how permission responses flow). The harness's permission requests render as inline transcript cards (Allow once / Always allow / Deny — the harness's own options), tool calls render as live expandable rows (status glyph + output on expand), and Stop is a real protocol-level `session/cancel`. YOLO auto-approves (no cards). Tool/permission history is render-only — the replay invariant holds (`buildReplayPrompt` reads only turn text), so cross-provider switches stay bounded and clean. Every ACP failure falls back to the one-shot headless path (floor = previous release). **Verified live (computer-use matrix):** allow → card resolves + tool ✓ + reply; deny → red ✗; cancel mid-`sleep` → "cancelled by user"; YOLO → no card; two-turn native continuity (no replay block on turn 2); copilot→Claude switch recalled all five prior shell commands (context preserved, no tool chatter in replay). **Live verification caught a real bug the 111-test suite could not:** AcpSession passed the stored `~/…` workspace path straight to session/new, which returns `-32603 "Directory path must be absolute"` — every interactive run silently fell back to headless; fixed by routing through `resolveCwd` like the one-shot adapters. Pillars 2-4 (codex app-server, claude, opencode acp) reuse this seam. Spec: `docs/superpowers/specs/2026-07-09-interactive-run-transport-design.md`. **Known pillar-1 limitation:** the ACP session runs copilot's account-default model — the picker's model choice isn't yet forwarded over ACP (a pillar-1 follow-up); ledger verdicts are not recorded for copilot to avoid mis-attribution.
+
**✅ Per-account capability discovery — M4 pillar one** (`1f48c8a`): the model picker now shows what each harness ACTUALLY provides for the owner's account — no hardcoded model lists. Codex: app-server v2 `model/list` over stdio JSON-RPC (per-model reasoning efforts + defaults; verified in-app: the account's real 5 models rendered, GPT-5.5 picked → run completed with `-m gpt-5.5`); Copilot: ACP `session/new` `availableModels` (11 models with usage multipliers + current default; verified in-app: Claude Sonnet 4.6 picked → run completed with `--model claude-sonnet-4.6`) — codex/copilot model selection is now WIRED, superseding the 2026-06-29 "needs M4" lock. Claude stays static-base (no headless list exists; raw API `/v1/models` rejected — wrapper invariant) merged with a persisted **gating ledger** (`userData/nac-capability-ledger.json`) that learns per-account verdicts from real run outcomes — verified: both test runs recorded `works` with correct attribution, and a gated entry renders the model chip warning-tinted after refresh (still clickable — fail-honest stands). Effort is provider-real: per-model scales (codex incl. xhigh), claude's 6 documented levels (+session-only note), copilot's 7, opencode variants; `null` = harness default; **verified in-app: switching codex→copilot reset effort to default**. Legacy `thinking` migrated (pre-feature cosmetic values → null). Degradation ladder protocol → static+learned → static keeps the app at the previous floor when discovery fails. Spec: `docs/superpowers/specs/2026-07-08-per-account-capability-discovery-design.md`.
**✅ Provider-first model picker + real options** (`dcbf0ad`): the model modal is two-page (detected providers → provider page); availability = live CLI probe (CliRegistry v0 — starts M4; Cursor dropped until it has an adapter). Thinking/effort is REAL on all four harnesses (claude `--effort`, codex `model_reasoning_effort`, copilot `--reasoning-effort`, opencode `--variant`; universal none/low/medium/high, 'none' = harness default) — closes the "thinking-level wiring" next-option. Claude extras: fast mode via per-run `--settings '{"fastMode":true}'` (no --fast flag exists) and a Sonnet 1M-context variant (`sonnet[1m]`), **both verified end-to-end in the running app** (2026-07-08 GUI check: picked `Sonnet 4.6 · 1M` in the new picker → run completed and `claude-sonnet-4-6[1m]` appeared in the harness's model usage; toggled Fast mode on → resumed run with the injected settings completed. Fast mode = the flag is accepted and sent per-run; the speed gain itself is server-side and not asserted). Gated options fail honestly (harness stderr → transcript). Live-verified `--effort low` on all four binaries: claude/codex/copilot completed cleanly first try; opencode's account-default model hung past 120s (unrelated to `--variant` itself — the flag worked once a responsive model was targeted), confirmed by retrying with `-m opencode/big-pickle`, which completed in ~1s — no `OptionDef` change needed, the existing "model-dependent" note already covers this. Spec: `docs/superpowers/specs/2026-07-08-provider-first-model-picker-design.md`. Review fixes: codex/copilot model chips are display-only ("account default · needs M4 discovery" — honest UI per the locked M4 decision), and effort defaults migrated to 'none' (pre-feature persisted 'medium' was cosmetic; runs gain flags only when the user picks a level).
diff --git a/src/main/runtime/acp/acpSession.test.ts b/src/main/runtime/acp/acpSession.test.ts
new file mode 100644
index 0000000..ac02943
--- /dev/null
+++ b/src/main/runtime/acp/acpSession.test.ts
@@ -0,0 +1,28 @@
+import { describe, it, expect } from 'vitest'
+import { homedir } from 'os'
+import { pickAutoApprove, acpCwd } from './acpSession'
+
+describe('pickAutoApprove', () => {
+ it('picks the first allow-kind option', () => {
+ expect(pickAutoApprove([
+ { id: 'reject_once', label: 'Deny', kind: 'deny' },
+ { id: 'allow_once', label: 'Allow once', kind: 'allow' },
+ { id: 'allow_always', label: 'Always', kind: 'allow_always' }
+ ])?.id).toBe('allow_once')
+ })
+ it('returns undefined when no allow option exists', () => {
+ expect(pickAutoApprove([{ id: 'reject_once', label: 'Deny', kind: 'deny' }])).toBeUndefined()
+ })
+})
+
+describe('acpCwd', () => {
+ it('expands a stored ~ workspace path to absolute (copilot session/new rejects non-absolute)', () => {
+ expect(acpCwd('~/Code/nac-code')).toBe(`${homedir()}/Code/nac-code`)
+ expect(acpCwd('~')).toBe(homedir())
+ })
+ it('passes an absolute path through and falls back to process cwd when unset', () => {
+ expect(acpCwd('/abs/path')).toBe('/abs/path')
+ expect(acpCwd(undefined)).toBe(process.cwd())
+ expect(acpCwd('')).toBe(process.cwd())
+ })
+})
diff --git a/src/main/runtime/acp/acpSession.ts b/src/main/runtime/acp/acpSession.ts
new file mode 100644
index 0000000..d783883
--- /dev/null
+++ b/src/main/runtime/acp/acpSession.ts
@@ -0,0 +1,168 @@
+import { JsonRpcClient } from '../capabilities/jsonRpc'
+import type { AgentEvent, PermissionOption } from '../../../shared/runtime'
+import { mapAcpUpdate, mapPermissionRequest } from './mapAcp'
+import { resolveCwd } from '../paths'
+
+export const PROMPT_TIMEOUT_MS = 1_800_000 // 30 min — cancellation, not timeout, is the stop lever
+const HANDSHAKE_TIMEOUT_MS = 10_000
+
+export interface TransportSession {
+ prompt(runId: string, text: string): void
+ respondPermission(requestId: string, optionId: string): void
+ cancel(): void
+ dispose(): void
+}
+
+/** Pure + exported for testing: YOLO auto-approval picks the first allow-ish option. */
+export function pickAutoApprove(options: PermissionOption[]): PermissionOption | undefined {
+ return options.find((o) => o.kind === 'allow' || o.kind === 'allow_always')
+}
+
+/** Pure + exported for testing: ACP session cwd. copilot's session/new rejects a non-absolute path
+ * (`-32603 "Directory path must be absolute"`), so a stored `~/…` workspace path MUST be expanded —
+ * the same resolveCwd every one-shot adapter uses. Falls back to process cwd when unset. */
+export function acpCwd(cwd: string | undefined): string {
+ return resolveCwd(cwd) ?? process.cwd()
+}
+
+interface PendingPermission {
+ resolve: (optionId: string) => void
+ denyId: string
+}
+
+export class AcpSession implements TransportSession {
+ private client: JsonRpcClient
+ private sessionId: string | null = null
+ private currentRunId: string | null = null
+ private replaying = false // suppress session/load history replay
+ private permissionSeq = 0
+ private pendingPermissions = new Map()
+ private onEvent: (e: AgentEvent) => void
+ private yolo: boolean
+
+ constructor(onEvent: (e: AgentEvent) => void, yolo: boolean) {
+ this.onEvent = onEvent
+ this.yolo = yolo
+ this.client = new JsonRpcClient('copilot', ['--acp'])
+ 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)
+ })
+ this.client.onRequest('session/request_permission', (params) => this.handlePermission(params))
+ }
+
+ setYolo(y: boolean): void {
+ this.yolo = y
+ }
+
+ /** Resolves the ACP handshake; throws on failure so the caller can fall back. */
+ async connect(cwd: string | undefined, existingSessionId: string | undefined): Promise {
+ await this.client.request('initialize', {
+ protocolVersion: 1,
+ clientCapabilities: { fs: { readTextFile: false, writeTextFile: false } }
+ }, HANDSHAKE_TIMEOUT_MS)
+ 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)
+ this.sessionId = existingSessionId
+ return existingSessionId
+ } catch (e) {
+ // Re-throw: the caller sent a BARE message (renderer chose native continuity, no replay
+ // text seeded). Falling through to session/new here would silently start an empty
+ // session and drop the conversation — a hard context-preservation violation. Rejecting
+ // connect() instead makes promptViaAcp resolve { ok: false }, so ipc.ts falls back to the
+ // one-shot startCopilotRun(sessionId) path, which uses --resume to preserve context.
+ throw e instanceof Error ? e : new Error(String(e))
+ } finally {
+ this.replaying = false
+ }
+ }
+ const res = (await this.client.request('session/new', { cwd: acpCwd(cwd), mcpServers: [] }, HANDSHAKE_TIMEOUT_MS)) as { sessionId?: string }
+ if (!res?.sessionId) throw new Error('acp: session/new returned no sessionId')
+ this.sessionId = res.sessionId
+ return res.sessionId
+ }
+
+ get loadedSessionId(): string | null {
+ return this.sessionId
+ }
+
+ private handlePermission(params: unknown): Promise {
+ const runId = this.currentRunId ?? 'unknown'
+ const requestId = `perm_${++this.permissionSeq}`
+ const event = mapPermissionRequest(runId, requestId, params)
+ if (!event) return Promise.resolve({ outcome: { outcome: 'cancelled' } }) // zero options: never hang
+ if (this.yolo) {
+ const auto = pickAutoApprove(event.options)
+ if (auto) return Promise.resolve({ outcome: { outcome: 'selected', optionId: auto.id } })
+ }
+ this.onEvent(event)
+ const denyId = event.options.find((o) => o.kind === 'deny')?.id ?? event.options[event.options.length - 1].id
+ return new Promise((resolve) => {
+ this.pendingPermissions.set(requestId, {
+ denyId,
+ resolve: (optionId) => {
+ this.onEvent({ type: 'permission.resolved', runId, requestId, optionId })
+ resolve({ outcome: { outcome: 'selected', optionId } })
+ }
+ })
+ })
+ }
+
+ /** True while a turn is in flight — the idle reaper must never dispose a busy session. */
+ get busy(): boolean {
+ return this.currentRunId !== null
+ }
+
+ prompt(runId: string, text: string): void {
+ if (!this.sessionId) throw new Error('acp: no session')
+ this.currentRunId = runId
+ 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
+ })
+ }
+
+ respondPermission(requestId: string, optionId: string): void {
+ const p = this.pendingPermissions.get(requestId)
+ if (!p) return
+ this.pendingPermissions.delete(requestId)
+ p.resolve(optionId)
+ }
+
+ private expirePermissions(): void {
+ // A turn ended with cards still open (error/cancel): answer the protocol with a deny-equivalent
+ // — the actual deny option id offered for that request, not a hardcoded guess.
+ for (const [requestId, p] of this.pendingPermissions) {
+ this.pendingPermissions.delete(requestId)
+ p.resolve(p.denyId)
+ }
+ }
+
+ /** True once the underlying ACP child process has exited — the session can no longer be used. */
+ get dead(): boolean {
+ return this.client.isClosed
+ }
+
+ cancel(): void {
+ if (this.sessionId) this.client.notify('session/cancel', { sessionId: this.sessionId })
+ }
+
+ dispose(): void {
+ this.expirePermissions()
+ this.client.close()
+ }
+}
diff --git a/src/main/runtime/acp/mapAcp.test.ts b/src/main/runtime/acp/mapAcp.test.ts
new file mode 100644
index 0000000..39f3307
--- /dev/null
+++ b/src/main/runtime/acp/mapAcp.test.ts
@@ -0,0 +1,51 @@
+import { describe, it, expect } from 'vitest'
+import { mapAcpUpdate, mapPermissionRequest } 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' } }
+const CHUNK = { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'It printed ' } }
+const PERM = { sessionId: 's1', toolCall: { toolCallId: 'call_MHx', title: 'Run echo nac-probe-ok', kind: 'execute', status: 'pending', rawInput: { command: 'echo nac-probe-ok' } }, options: [{ optionId: 'allow_once', kind: 'allow_once', name: 'Allow once' }, { optionId: 'allow_always', kind: 'allow_always', name: 'Always allow' }, { optionId: 'reject_once', kind: 'reject_once', name: 'Deny' }] }
+
+describe('mapAcpUpdate', () => {
+ it('maps tool_call to a pending tool.updated carrying the command as detail', () => {
+ expect(mapAcpUpdate('r', TOOL_CALL)).toEqual([{ type: 'tool.updated', runId: 'r', toolCallId: 'call_MHx', title: 'Run echo nac-probe-ok', kind: 'execute', status: 'pending', detail: 'echo nac-probe-ok' }])
+ })
+ it('maps a completed tool_call_update carrying output text as detail', () => {
+ const [e] = mapAcpUpdate('r', TOOL_DONE)
+ expect(e).toMatchObject({ type: 'tool.updated', toolCallId: 'call_MHx', status: 'completed' })
+ expect((e as { detail?: string }).detail).toContain('nac-probe-ok')
+ })
+ it('maps agent_message_chunk to content.delta and ignores unknown/junk updates', () => {
+ expect(mapAcpUpdate('r', CHUNK)).toEqual([{ type: 'content.delta', runId: 'r', streamKind: 'assistant_text', text: 'It printed ' }])
+ expect(mapAcpUpdate('r', { sessionUpdate: 'plan' })).toEqual([])
+ expect(mapAcpUpdate('r', null)).toEqual([])
+ })
+ it('preserves a tool_call_update without status as a running upsert', () => {
+ const [e] = mapAcpUpdate('r', { sessionUpdate: 'tool_call_update', toolCallId: 'call_MHx', content: [{ type: 'content', content: { type: 'text', text: 'partial' } }] })
+ expect(e).toMatchObject({ type: 'tool.updated', status: 'running', detail: 'partial' })
+ })
+ it('never surfaces a non-string rawOutput.content as detail (Minor 6: React would crash on an object child)', () => {
+ const [e] = mapAcpUpdate('r', { sessionUpdate: 'tool_call_update', toolCallId: 'call_MHx', status: 'completed', rawOutput: { content: { type: 'image', data: 'base64...' } } })
+ expect(e).toMatchObject({ type: 'tool.updated', toolCallId: 'call_MHx', status: 'completed' })
+ expect((e as { detail?: unknown }).detail).toBeUndefined()
+ })
+})
+
+describe('mapPermissionRequest', () => {
+ it('maps the captured request with normalized option kinds', () => {
+ const e = mapPermissionRequest('r', 'req1', PERM)
+ expect(e).toEqual({
+ type: 'permission.requested', runId: 'r', requestId: 'req1', title: 'Run echo nac-probe-ok',
+ detail: 'echo nac-probe-ok',
+ options: [
+ { id: 'allow_once', label: 'Allow once', kind: 'allow' },
+ { id: 'allow_always', label: 'Always allow', kind: 'allow_always' },
+ { id: 'reject_once', label: 'Deny', kind: 'deny' }
+ ]
+ })
+ })
+ it('returns null for junk', () => {
+ expect(mapPermissionRequest('r', 'x', null)).toBeNull()
+ expect(mapPermissionRequest('r', 'x', { options: [] })).toBeNull()
+ })
+})
diff --git a/src/main/runtime/acp/mapAcp.ts b/src/main/runtime/acp/mapAcp.ts
new file mode 100644
index 0000000..39bafd2
--- /dev/null
+++ b/src/main/runtime/acp/mapAcp.ts
@@ -0,0 +1,81 @@
+import type { AgentEvent, PermissionOption } from '../../../shared/runtime'
+
+// Pure mappers from copilot ACP frames (live-captured 2026-07-09, docs/research/
+// acp-prompt-frames-copilot-1.0.69.txt) to canonical AgentEvents.
+
+interface AcpContentEntry {
+ content?: { text?: string }
+}
+interface AcpUpdate {
+ sessionUpdate?: string
+ toolCallId?: string
+ title?: string
+ kind?: string
+ status?: string
+ rawInput?: { command?: string }
+ rawOutput?: { content?: unknown }
+ content?: AcpContentEntry[] | { text?: string }
+}
+
+const TOOL_STATUSES = new Set(['pending', 'running', 'completed', 'failed'])
+
+/** rawOutput.content / rawInput.command can be structured (non-string) — only strings are safe to
+ * hand to React as event detail, so anything else is dropped rather than crashing the renderer. */
+function asStringDetail(x: unknown): string | undefined {
+ return typeof x === 'string' ? x : undefined
+}
+
+function contentText(u: AcpUpdate): string | undefined {
+ if (Array.isArray(u.content)) {
+ const texts = u.content.map((c) => c?.content?.text).filter((t): t is string => Boolean(t))
+ return texts.length ? texts.join('') : undefined
+ }
+ return undefined
+}
+
+/** One session/update frame → 0..n AgentEvents. Unknown update kinds are ignored. */
+export function mapAcpUpdate(runId: string, update: unknown): AgentEvent[] {
+ const u = update as AcpUpdate | null
+ if (!u || typeof u !== 'object') return []
+ switch (u.sessionUpdate) {
+ case 'agent_message_chunk': {
+ const text = (u.content as { text?: string } | undefined)?.text
+ return text ? [{ type: 'content.delta', runId, streamKind: 'assistant_text', text }] : []
+ }
+ case 'tool_call':
+ case 'tool_call_update': {
+ if (!u.toolCallId) return []
+ const status = (u.status && TOOL_STATUSES.has(u.status) ? u.status : u.sessionUpdate === 'tool_call' ? 'pending' : 'running') as 'pending' | 'running' | 'completed' | 'failed'
+ 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 } : {}) }]
+ }
+ default:
+ return []
+ }
+}
+
+const OPTION_KINDS: Record = {
+ allow_once: 'allow',
+ allow_always: 'allow_always',
+ reject_once: 'deny',
+ reject_always: 'deny'
+}
+
+/** session/request_permission params → a permission.requested event (null for junk/no options). */
+export function mapPermissionRequest(runId: string, requestId: string, params: unknown): Extract | null {
+ const p = params as { toolCall?: { title?: string; rawInput?: { command?: string } }; options?: { optionId?: string; kind?: string; name?: string }[] } | null
+ if (!p || typeof p !== 'object' || !Array.isArray(p.options) || p.options.length === 0) return null
+ const options: PermissionOption[] = []
+ for (const o of p.options) {
+ if (!o?.optionId) continue
+ options.push({ id: o.optionId, label: o.name ?? o.optionId, kind: OPTION_KINDS[o.kind ?? ''] ?? 'deny' })
+ }
+ if (options.length === 0) return null
+ const detail = asStringDetail(p.toolCall?.rawInput?.command)
+ return {
+ type: 'permission.requested', runId, requestId,
+ title: p.toolCall?.title ?? 'Permission request',
+ ...(detail ? { detail } : {}),
+ options
+ }
+}
diff --git a/src/main/runtime/acp/sessionManager.ts b/src/main/runtime/acp/sessionManager.ts
new file mode 100644
index 0000000..45607d0
--- /dev/null
+++ b/src/main/runtime/acp/sessionManager.ts
@@ -0,0 +1,113 @@
+import type { AgentEvent } from '../../../shared/runtime'
+import { AcpSession } from './acpSession'
+
+// One live ACP session per chat. Sessions are disposed on provider switch (promptViaAcp 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: AcpSession
+ idleTimer: ReturnType | null
+ // 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 }
+}
+
+const byChat = new Map()
+const runToChat = new Map()
+
+function touch(chatId: string): void {
+ const e = byChat.get(chatId)
+ if (!e) return
+ if (e.idleTimer) clearTimeout(e.idleTimer)
+ e.idleTimer = setTimeout(() => disposeChat(chatId), IDLE_MS)
+}
+
+function disposeChat(chatId: string, force = false): void {
+ const e = byChat.get(chatId)
+ if (!e) return
+ if (!force && e.session.busy) {
+ // Idle reaper path: a turn can run up to 30 min — re-arm instead of killing mid-turn.
+ touch(chatId)
+ return
+ }
+ byChat.delete(chatId)
+ if (e.idleTimer) clearTimeout(e.idleTimer)
+ e.session.dispose()
+}
+
+/** Try the interactive path. Resolves { ok: false } when ACP is unavailable — caller falls back. */
+export async function promptViaAcp(opts: {
+ chatId: string
+ runId: string
+ prompt: string
+ cwd?: string
+ yolo?: boolean
+ sessionId?: string
+ onEvent: (e: AgentEvent) => void
+}): Promise<{ ok: boolean }> {
+ let entry = byChat.get(opts.chatId)
+
+ // Important 2: a dead child (process exited mid-lifetime) must never be reused — its stdin is
+ // gone, so a session/prompt against it would hang until the 30-min timeout with Stop a no-op.
+ if (entry && entry.session.dead) {
+ if (entry.idleTimer) clearTimeout(entry.idleTimer)
+ entry.session.dispose()
+ byChat.delete(opts.chatId)
+ entry = undefined
+ }
+
+ // Important 5: no sessionId means the renderer built a replay prompt — it believes there's no
+ // native session (provider changed, or the session was otherwise dropped client-side). Any ACP
+ // session we're still holding for this chat is stale and must be disposed, per spec.
+ if (entry && opts.sessionId === undefined) {
+ disposeChat(opts.chatId)
+ entry = undefined
+ }
+
+ if (!entry) {
+ const ref = { onEvent: opts.onEvent }
+ const session = new AcpSession((e) => {
+ if (e.type === 'run.completed' || e.type === 'run.errored') runToChat.delete(e.runId)
+ ref.onEvent(e)
+ }, opts.yolo === true)
+ try {
+ await session.connect(opts.cwd, opts.sessionId)
+ } catch {
+ session.dispose()
+ return { ok: false }
+ }
+ entry = { session, idleTimer: null, ref }
+ byChat.set(opts.chatId, entry)
+ } else {
+ entry.ref.onEvent = opts.onEvent
+ }
+ entry.session.setYolo(opts.yolo === true)
+ runToChat.set(opts.runId, opts.chatId)
+ entry.session.prompt(opts.runId, opts.prompt)
+ touch(opts.chatId)
+ return { ok: true }
+}
+
+export function respondPermission(runId: string, requestId: string, optionId: string): void {
+ const chatId = runToChat.get(runId)
+ if (!chatId) return
+ byChat.get(chatId)?.session.respondPermission(requestId, optionId)
+ touch(chatId)
+}
+
+export function cancelRun(runId: string): boolean {
+ const chatId = runToChat.get(runId)
+ if (!chatId) return false
+ const e = byChat.get(chatId)
+ if (!e) return false
+ e.session.cancel()
+ return true
+}
+
+export function disposeAll(): void {
+ // App quit: force — a busy session must still be torn down (the process is exiting).
+ for (const chatId of [...byChat.keys()]) disposeChat(chatId, true)
+}
diff --git a/src/main/runtime/capabilities/jsonRpc.test.ts b/src/main/runtime/capabilities/jsonRpc.test.ts
index f67897e..4ba0773 100644
--- a/src/main/runtime/capabilities/jsonRpc.test.ts
+++ b/src/main/runtime/capabilities/jsonRpc.test.ts
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
-import { parseRpcLine, LineDecoder } from './jsonRpc'
+import { parseRpcLine, LineDecoder, classifyRpcMessage } from './jsonRpc'
describe('parseRpcLine', () => {
it('parses codex-style responses that omit the jsonrpc field', () => {
@@ -30,3 +30,13 @@ describe('LineDecoder', () => {
expect(d.push(Buffer.from('1}\n{"id":2}\n', 'utf8'))).toEqual(['{"id":1}', '{"id":2}'])
})
})
+
+describe('classifyRpcMessage', () => {
+ it('separates responses, server requests, and notifications', () => {
+ expect(classifyRpcMessage({ id: 3, result: { stopReason: 'end_turn' } })).toBe('response')
+ // Real captured frame: copilot's permission request arrived with id 0 — a server REQUEST has method+id.
+ expect(classifyRpcMessage({ id: 0, method: 'session/request_permission' })).toBe('server-request')
+ expect(classifyRpcMessage({ method: 'session/update' })).toBe('notification')
+ expect(classifyRpcMessage({ id: 1, error: { code: -32601 } })).toBe('response')
+ })
+})
diff --git a/src/main/runtime/capabilities/jsonRpc.ts b/src/main/runtime/capabilities/jsonRpc.ts
index bcf99a8..efff5b5 100644
--- a/src/main/runtime/capabilities/jsonRpc.ts
+++ b/src/main/runtime/capabilities/jsonRpc.ts
@@ -9,6 +9,7 @@ export interface RpcMessage {
result?: unknown
error?: { code?: number; message?: string }
method?: string
+ params?: unknown
}
/** Pure + exported for testing: chunk-boundary-safe newline splitter (multi-byte UTF-8 can split across chunks). */
@@ -42,28 +43,51 @@ export function parseRpcLine(line: string): RpcMessage | null {
return m
}
+/** Pure + exported for testing: incoming message kind. A server-initiated message carries `method`;
+ * with an id it's a request we must answer, without one a notification. Anything else is a response. */
+export function classifyRpcMessage(m: RpcMessage): 'response' | 'server-request' | 'notification' {
+ if (m.method !== undefined) return m.id !== undefined ? 'server-request' : 'notification'
+ return 'response'
+}
+
export class JsonRpcClient {
private child: ChildProcess
private lines = new LineDecoder()
private nextId = 1
private pending = new Map void; reject: (e: Error) => void }>()
+ private notificationHandlers = new Map void>()
+ private requestHandlers = new Map Promise | unknown>()
+ private closed = false
constructor(command: string, args: string[]) {
this.child = spawn(command, args, { stdio: ['pipe', 'pipe', 'ignore'] })
this.child.stdout?.on('data', (chunk: Buffer) => {
for (const line of this.lines.push(chunk)) {
const msg = parseRpcLine(line)
- if (msg?.id !== undefined && this.pending.has(msg.id)) {
+ if (!msg) continue
+ const kind = classifyRpcMessage(msg)
+ if (kind === 'response' && msg.id !== undefined && this.pending.has(msg.id)) {
const p = this.pending.get(msg.id)!
this.pending.delete(msg.id)
if (msg.error) p.reject(new Error(msg.error.message ?? `rpc error ${msg.error.code}`))
else p.resolve(msg.result)
+ } else if (kind === 'server-request') {
+ this.answer(msg)
+ } else if (kind === 'notification') {
+ this.notificationHandlers.get(msg.method!)?.(msg.params)
}
- // notifications (method, no id) are ignored — discovery only awaits responses
}
})
this.child.on('error', (err) => this.failAll(err))
- this.child.on('close', () => this.failAll(new Error('rpc server closed')))
+ this.child.on('close', () => {
+ this.closed = true
+ this.failAll(new Error('rpc server closed'))
+ })
+ }
+
+ /** True once the child process has exited — further requests would hang forever on a dead pipe. */
+ get isClosed(): boolean {
+ return this.closed
}
private failAll(err: Error): void {
@@ -71,7 +95,35 @@ export class JsonRpcClient {
this.pending.clear()
}
+ 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')
+ }
+ if (!handler) {
+ write({ error: { code: -32601, message: `unhandled: ${msg.method}` } })
+ return
+ }
+ Promise.resolve()
+ .then(() => handler(msg.params))
+ .then((result) => write({ result }))
+ .catch((e: Error) => write({ error: { code: -32000, message: e.message ?? 'handler error' } }))
+ }
+
+ onNotification(method: string, handler: (params: unknown) => void): void {
+ this.notificationHandlers.set(method, handler)
+ }
+
+ onRequest(method: string, handler: (params: unknown) => Promise | unknown): void {
+ this.requestHandlers.set(method, handler)
+ }
+
+ notify(method: string, params?: unknown): void {
+ this.child.stdin?.write(JSON.stringify({ jsonrpc: '2.0', method, params: params ?? {} }) + '\n')
+ }
+
request(method: string, params?: unknown, timeoutMs = 5000): Promise {
+ if (this.closed) return Promise.reject(new Error('rpc: server closed'))
const id = this.nextId++
const payload = JSON.stringify({ jsonrpc: '2.0', id, method, params: params ?? {} })
return new Promise((resolve, reject) => {
diff --git a/src/main/runtime/ipc.ts b/src/main/runtime/ipc.ts
index 4fbc360..3f40e24 100644
--- a/src/main/runtime/ipc.ts
+++ b/src/main/runtime/ipc.ts
@@ -12,6 +12,7 @@ import { probeProviders } from './registry'
import { getCapabilities, invalidateCapabilities } from './capabilities'
import { classifyModelRejection } from './capabilities/ledger'
import { recordOutcome } from './capabilities/ledgerStore'
+import { promptViaAcp, respondPermission as acpRespondPermission, cancelRun as acpCancelRun, disposeAll as acpDisposeAll } from './acp/sessionManager'
const runs = new Map()
let counter = 0
@@ -52,31 +53,45 @@ function stubHarnessPath(): string {
* (ACP / app-server / SDK) slots in behind the same AgentEvent stream later.
*/
export function registerRuntimeIpc(getWindow: () => BrowserWindow | null): void {
+ app.on('will-quit', () => acpDisposeAll())
+
ipcMain.handle(RUN_CHANNELS.start, (_e, req: RunRequest): { runId: string } => {
const runId = `run_${++counter}`
const send = (event: AgentEvent): void => getWindow()?.webContents.send(RUN_CHANNELS.event, event)
+ // ACP runs the account-default model; don't attribute ledger verdicts to the picked model
+ // (pillar-1 limitation) — copilot never forwards the picker's model choice over ACP, and its
+ // headless fallback is also default-model in practice, so gate the ledger off for copilot entirely.
+ const ledgerModel = req.provider === 'copilot' ? undefined : req.model
const handler = (event: AgentEvent): void => {
send(event)
// Gating ledger: learn per-account model verdicts from real outcomes (explicit model only).
- if (req.model && req.provider) {
+ if (ledgerModel && req.provider) {
if (event.type === 'run.errored' && classifyModelRejection(event.message)) {
- recordOutcome(req.provider, req.model, 'gated', 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, req.model, 'works')
+ } else if (event.type === 'run.completed' && event.stopReason === 'end_turn') recordOutcome(req.provider, ledgerModel, 'works')
}
if (event.type === 'run.completed' || event.type === 'run.errored') runs.delete(runId)
}
+ if (req.provider === 'copilot') {
+ // Interactive-first: persistent ACP session; on { ok: false } fall back to the one-shot path.
+ void promptViaAcp({ chatId: req.chatId ?? runId, runId, prompt: req.prompt, cwd: req.cwd, yolo: req.yolo, sessionId: req.sessionId, onEvent: handler }).then(({ ok }) => {
+ if (!ok) {
+ handler({ type: 'content.delta', runId, streamKind: 'assistant_text', text: '\n[interactive session unavailable — ran headless]\n' })
+ runs.set(runId, startCopilotRun(runId, { prompt: req.prompt, cwd: req.cwd, yolo: req.yolo, sessionId: req.sessionId, effort: req.effort, model: req.model }, handler))
+ }
+ })
+ return { runId }
+ }
// Real Claude adapter for provider 'claude'; the NDJSON stub for the rest (until those adapters land).
const run =
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)
: req.provider === 'codex'
? startCodexRun(runId, { prompt: req.prompt, cwd: req.cwd, yolo: req.yolo, sessionId: req.sessionId, effort: req.effort, model: req.model }, handler)
- : req.provider === 'copilot'
- ? 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)
- : startHarnessRun(
+ : 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,
@@ -92,10 +107,13 @@ export function registerRuntimeIpc(getWindow: () => BrowserWindow | null): void
})
ipcMain.handle(RUN_CHANNELS.cancel, (_e, runId: string): void => {
+ if (acpCancelRun(runId)) return // live interactive session: protocol-level stop
runs.get(runId)?.cancel()
runs.delete(runId)
})
+ ipcMain.handle(RUN_CHANNELS.respondPermission, (_e, runId: string, requestId: string, optionId: string) => acpRespondPermission(runId, requestId, optionId))
+
ipcMain.handle(RUN_CHANNELS.summarize, async (_e, req: SummarizeRequest): Promise<{ summary: string }> => {
const summary = await runOnce(req.provider, `${SUMMARIZE_INSTRUCTION}\n\n${req.text}`, req.model)
return { summary }
diff --git a/src/preload/index.ts b/src/preload/index.ts
index 46dc678..63aa0dc 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -10,6 +10,8 @@ const api = {
start: (req: RunRequest): Promise<{ runId: string }> => ipcRenderer.invoke(RUN_CHANNELS.start, req),
cancel: (runId: string): Promise => ipcRenderer.invoke(RUN_CHANNELS.cancel, runId),
summarize: (req: SummarizeRequest): Promise<{ summary: string }> => ipcRenderer.invoke(RUN_CHANNELS.summarize, req),
+ respondPermission: (runId: string, requestId: string, optionId: string): Promise =>
+ ipcRenderer.invoke(RUN_CHANNELS.respondPermission, runId, requestId, optionId),
// Subscribe to streamed AgentEvents; returns an unsubscribe function.
onEvent: (cb: (event: AgentEvent) => void): (() => void) => {
const listener = (_e: unknown, event: AgentEvent): void => cb(event)
diff --git a/src/renderer/src/components/ChatView.tsx b/src/renderer/src/components/ChatView.tsx
index 92b63eb..d56adc6 100644
--- a/src/renderer/src/components/ChatView.tsx
+++ b/src/renderer/src/components/ChatView.tsx
@@ -1,8 +1,10 @@
import { useEffect, useRef, useState, type CSSProperties } from 'react'
-import { useApp, selectActiveChat, contextPending } from '../store/store'
-import { sendMessage, isStreaming } from '../store/runtime'
+import { useApp, selectActiveChat, contextPending, type Turn } from '../store/store'
+import { sendMessage, isStreaming, runIdForChat } from '../store/runtime'
import { CONFIGURATIONS, CONFIGS_BY_ID, configTokens } from '../data/configs'
import { effortScaleFor } from '../../../shared/capabilities'
+import ToolRow from './ToolRow'
+import PermissionCard from './PermissionCard'
// Center pane: chat header · thread · composer. Send drives a real run (Claude adapter) or the stub;
// streamed AgentEvents land in the chat's transcript via the run controller.
@@ -25,6 +27,7 @@ export default function ChatView() {
const messages = active.messages ?? [] // defensive: tolerate stale data missing the field
const pending = contextPending(active)
const bottomRef = useRef(null)
+ const runId = runIdForChat(active.id) // cards only appear on the streaming turn, so this is unambiguous
const cwd = useApp((s) => s.workspaces.find((w) => w.id === active.workspaceId)?.path) ?? ''
const [changed, setChanged] = useState(0)
useEffect(() => {
@@ -106,7 +109,7 @@ export default function ChatView() {
)}
{messages.map((m) => (
-
+
))}
@@ -163,11 +166,22 @@ export default function ChatView() {
toggleYolo()} style={{ ...toolbarItem, color: active.yolo ? 'var(--warning)' : 'var(--muted)', fontWeight: active.yolo ? 600 : 400 }}>
YOLO{active.yolo ? ' ●' : ''}
+ {streaming && (
+
+ )}