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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
28 changes: 28 additions & 0 deletions src/main/runtime/acp/acpSession.test.ts
Original file line number Diff line number Diff line change
@@ -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())
})
})
168 changes: 168 additions & 0 deletions src/main/runtime/acp/acpSession.ts
Original file line number Diff line number Diff line change
@@ -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<string, PendingPermission>()
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<string> {
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<unknown> {
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 } })
}
Comment on lines +92 to +100

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in PR #4 — handlePermission now auto-cancels (resolves the JSON-RPC request with a cancelled outcome) when !currentRunId or replaying, via a pure tested shouldAutoCancelPermission guard that mirrors the existing session/update notification guard. No more unresolvable pending permission / deadlock, and replayed prompts during session/load no longer surface.

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()
}
}
51 changes: 51 additions & 0 deletions src/main/runtime/acp/mapAcp.test.ts
Original file line number Diff line number Diff line change
@@ -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<shellId: 0 completed with exit code 0>' } }], rawOutput: { content: 'nac-probe-ok\n<shellId: 0 completed with exit code 0>' } }
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()
})
})
Loading