From 1281fe4ca7bea4065ace2af7d1e3ec0f9303cb51 Mon Sep 17 00:00:00 2001
From: Nathan Font-Fife
Date: Thu, 9 Jul 2026 05:03:53 -0600
Subject: [PATCH 01/11] feat(rpc): server-request answering, notification
subscription, outbound notify
---
src/main/runtime/capabilities/jsonRpc.test.ts | 12 ++++-
src/main/runtime/capabilities/jsonRpc.ts | 46 ++++++++++++++++++-
2 files changed, 55 insertions(+), 3 deletions(-)
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..15d98a6 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,24 +43,38 @@ 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>()
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))
@@ -71,6 +86,33 @@ 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 {
const id = this.nextId++
const payload = JSON.stringify({ jsonrpc: '2.0', id, method, params: params ?? {} })
From 5988c3f442d14bd62fe2003f0a059254311ee9f7 Mon Sep 17 00:00:00 2001
From: Nathan Font-Fife
Date: Thu, 9 Jul 2026 05:07:36 -0600
Subject: [PATCH 02/11] feat(events): tool/permission canonical events + pure
ACP mappers from captured frames
Co-Authored-By: Claude Fable 5
---
src/main/runtime/acp/mapAcp.test.ts | 46 ++++++++++++++++++
src/main/runtime/acp/mapAcp.ts | 74 +++++++++++++++++++++++++++++
src/shared/runtime.ts | 12 ++++-
3 files changed, 131 insertions(+), 1 deletion(-)
create mode 100644 src/main/runtime/acp/mapAcp.test.ts
create mode 100644 src/main/runtime/acp/mapAcp.ts
diff --git a/src/main/runtime/acp/mapAcp.test.ts b/src/main/runtime/acp/mapAcp.test.ts
new file mode 100644
index 0000000..33023bc
--- /dev/null
+++ b/src/main/runtime/acp/mapAcp.test.ts
@@ -0,0 +1,46 @@
+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' })
+ })
+})
+
+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..690d5c8
--- /dev/null
+++ b/src/main/runtime/acp/mapAcp.ts
@@ -0,0 +1,74 @@
+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?: string }
+ content?: AcpContentEntry[] | { text?: string }
+}
+
+const TOOL_STATUSES = new Set(['pending', 'running', 'completed', 'failed'])
+
+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 = 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
+ return {
+ type: 'permission.requested', runId, requestId,
+ title: p.toolCall?.title ?? 'Permission request',
+ ...(p.toolCall?.rawInput?.command ? { detail: p.toolCall.rawInput.command } : {}),
+ options
+ }
+}
diff --git a/src/shared/runtime.ts b/src/shared/runtime.ts
index ff7a5b8..94841da 100644
--- a/src/shared/runtime.ts
+++ b/src/shared/runtime.ts
@@ -27,18 +27,28 @@ export interface TurnUsage {
costUsd?: number
}
+export interface PermissionOption {
+ id: string
+ label: string
+ kind: 'allow' | 'allow_always' | 'deny'
+}
+
export type AgentEvent =
| { type: 'run.started'; runId: string; sessionId?: string }
| { type: 'content.delta'; runId: string; streamKind: 'assistant_text' | 'reasoning'; text: string }
| { type: 'run.completed'; runId: string; stopReason: 'end_turn' | 'error' | 'canceled'; usage?: TurnUsage }
| { type: 'run.errored'; runId: string; message: string }
+ | { type: 'tool.updated'; runId: string; toolCallId: string; title: string; kind?: string; status: 'pending' | 'running' | 'completed' | 'failed'; detail?: string }
+ | { type: 'permission.requested'; runId: string; requestId: string; title: string; detail?: string; options: PermissionOption[] }
+ | { type: 'permission.resolved'; runId: string; requestId: string; optionId: string }
// IPC channel names shared by main and preload.
export const RUN_CHANNELS = {
start: 'run:start',
cancel: 'run:cancel',
event: 'run:event',
- summarize: 'run:summarize'
+ summarize: 'run:summarize',
+ respondPermission: 'run:respondPermission'
} as const
export const STATE_CHANNELS = {
From 567a7bac9b312bd56b3fca7b03dbbb279b394507 Mon Sep 17 00:00:00 2001
From: Nathan Font-Fife
Date: Thu, 9 Jul 2026 05:11:21 -0600
Subject: [PATCH 03/11] feat(acp): persistent AcpSession + SessionManager
behind the TransportSession seam
---
src/main/runtime/acp/acpSession.test.ts | 15 +++
src/main/runtime/acp/acpSession.ts | 143 ++++++++++++++++++++++++
src/main/runtime/acp/sessionManager.ts | 84 ++++++++++++++
3 files changed, 242 insertions(+)
create mode 100644 src/main/runtime/acp/acpSession.test.ts
create mode 100644 src/main/runtime/acp/acpSession.ts
create mode 100644 src/main/runtime/acp/sessionManager.ts
diff --git a/src/main/runtime/acp/acpSession.test.ts b/src/main/runtime/acp/acpSession.test.ts
new file mode 100644
index 0000000..d2b30e7
--- /dev/null
+++ b/src/main/runtime/acp/acpSession.test.ts
@@ -0,0 +1,15 @@
+import { describe, it, expect } from 'vitest'
+import { pickAutoApprove } 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()
+ })
+})
diff --git a/src/main/runtime/acp/acpSession.ts b/src/main/runtime/acp/acpSession.ts
new file mode 100644
index 0000000..ab30c3a
--- /dev/null
+++ b/src/main/runtime/acp/acpSession.ts
@@ -0,0 +1,143 @@
+import { JsonRpcClient } from '../capabilities/jsonRpc'
+import type { AgentEvent, PermissionOption } from '../../../shared/runtime'
+import { mapAcpUpdate, mapPermissionRequest } from './mapAcp'
+
+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')
+}
+
+interface PendingPermission {
+ resolve: (optionId: string) => void
+}
+
+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: cwd ?? process.cwd(), mcpServers: [] }, HANDSHAKE_TIMEOUT_MS)
+ this.sessionId = existingSessionId
+ return existingSessionId
+ } catch {
+ // fall through to a fresh session (caller seeds it with the replay prompt on the next send)
+ } finally {
+ this.replaying = false
+ }
+ }
+ const res = (await this.client.request('session/new', { cwd: cwd ?? process.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)
+ return new Promise((resolve) => {
+ this.pendingPermissions.set(requestId, {
+ 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.onEvent({ type: 'run.completed', runId, stopReason: stop === 'cancelled' ? 'canceled' : 'end_turn' })
+ })
+ .catch((e: Error) => this.onEvent({ type: 'run.errored', runId, message: e.message }))
+ .finally(() => {
+ this.currentRunId = null
+ this.expirePermissions()
+ })
+ }
+
+ 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.
+ for (const [requestId, p] of this.pendingPermissions) {
+ this.pendingPermissions.delete(requestId)
+ p.resolve('reject_once')
+ }
+ }
+
+ 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/sessionManager.ts b/src/main/runtime/acp/sessionManager.ts
new file mode 100644
index 0000000..7c48bc1
--- /dev/null
+++ b/src/main/runtime/acp/sessionManager.ts
@@ -0,0 +1,84 @@
+import type { AgentEvent } from '../../../shared/runtime'
+import { AcpSession } from './acpSession'
+
+// One live ACP session per chat. Sessions die on provider switch (a new prompt for the same chat
+// with a different transport never reaches here), app quit, or idle timeout.
+
+export const IDLE_MS = 15 * 60_000
+
+interface Entry {
+ session: AcpSession
+ idleTimer: ReturnType | null
+}
+
+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): void {
+ const e = byChat.get(chatId)
+ if (!e) return
+ if (e.session.busy) {
+ // A turn can run up to 30 min (PROMPT_TIMEOUT_MS) — 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)
+ if (!entry) {
+ const session = new AcpSession(opts.onEvent, opts.yolo === true)
+ try {
+ await session.connect(opts.cwd, opts.sessionId)
+ } catch {
+ session.dispose()
+ return { ok: false }
+ }
+ entry = { session, idleTimer: null }
+ byChat.set(opts.chatId, entry)
+ }
+ 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 {
+ for (const chatId of [...byChat.keys()]) disposeChat(chatId)
+}
From 26d151d43cca96a0c02eb96eca07992a67bde18f Mon Sep 17 00:00:00 2001
From: Nathan Font-Fife
Date: Thu, 9 Jul 2026 05:15:48 -0600
Subject: [PATCH 04/11] fix(acp): bound runToChat lifetime + force disposal on
app quit
- Delete runToChat entries when terminal run events occur (run.completed/run.errored)
- Add force flag to disposeChat to allow killing busy sessions on app quit
- disposeAll now force-disposes all sessions (process is exiting)
- Idle reaper path unchanged (re-arms instead of killing mid-turn)
Co-Authored-By: Claude Fable 5
---
src/main/runtime/acp/sessionManager.ts | 14 +++++++++-----
1 file changed, 9 insertions(+), 5 deletions(-)
diff --git a/src/main/runtime/acp/sessionManager.ts b/src/main/runtime/acp/sessionManager.ts
index 7c48bc1..74d952f 100644
--- a/src/main/runtime/acp/sessionManager.ts
+++ b/src/main/runtime/acp/sessionManager.ts
@@ -21,11 +21,11 @@ function touch(chatId: string): void {
e.idleTimer = setTimeout(() => disposeChat(chatId), IDLE_MS)
}
-function disposeChat(chatId: string): void {
+function disposeChat(chatId: string, force = false): void {
const e = byChat.get(chatId)
if (!e) return
- if (e.session.busy) {
- // A turn can run up to 30 min (PROMPT_TIMEOUT_MS) — re-arm instead of killing mid-turn.
+ 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
}
@@ -46,7 +46,10 @@ export async function promptViaAcp(opts: {
}): Promise<{ ok: boolean }> {
let entry = byChat.get(opts.chatId)
if (!entry) {
- const session = new AcpSession(opts.onEvent, opts.yolo === true)
+ const session = new AcpSession((e) => {
+ if (e.type === 'run.completed' || e.type === 'run.errored') runToChat.delete(e.runId)
+ opts.onEvent(e)
+ }, opts.yolo === true)
try {
await session.connect(opts.cwd, opts.sessionId)
} catch {
@@ -80,5 +83,6 @@ export function cancelRun(runId: string): boolean {
}
export function disposeAll(): void {
- for (const chatId of [...byChat.keys()]) disposeChat(chatId)
+ // App quit: force — a busy session must still be torn down (the process is exiting).
+ for (const chatId of [...byChat.keys()]) disposeChat(chatId, true)
}
From abf56f3a4fe36a73ddabf240a5e9723042853605 Mon Sep 17 00:00:00 2001
From: Nathan Font-Fife
Date: Thu, 9 Jul 2026 05:20:34 -0600
Subject: [PATCH 05/11] feat(runtime): interactive copilot routing, permission
IPC, tool/permission turn state
---
src/main/runtime/ipc.ts | 24 ++++++++---
src/preload/index.ts | 2 +
src/renderer/src/store/persist.test.ts | 9 ++++
src/renderer/src/store/persist.ts | 9 +++-
src/renderer/src/store/runtime.ts | 10 +++++
src/renderer/src/store/store.test.ts | 20 +++++++++
src/renderer/src/store/store.ts | 58 +++++++++++++++++++++++++-
src/shared/runtime.ts | 1 +
8 files changed, 126 insertions(+), 7 deletions(-)
diff --git a/src/main/runtime/ipc.ts b/src/main/runtime/ipc.ts
index 4fbc360..5d594d6 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,6 +53,8 @@ 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)
@@ -66,17 +69,25 @@ export function registerRuntimeIpc(getWindow: () => BrowserWindow | null): void
}
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 +103,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/store/persist.test.ts b/src/renderer/src/store/persist.test.ts
index 0cc6e7e..cb9ccdb 100644
--- a/src/renderer/src/store/persist.test.ts
+++ b/src/renderer/src/store/persist.test.ts
@@ -21,3 +21,12 @@ describe('normalizeChat — thinking → effort migration', () => {
expect(normalizeChat({} as never, 'c_none').fast).toBe(false)
})
})
+
+describe('normalizeChat — never restore live-looking tool/permission state', () => {
+ it('hydration never restores live-looking tool/permission state', () => {
+ const raw = { fast: false, messages: [{ id: 'a', role: 'assistant', text: 'x', tools: [{ toolCallId: 't', title: 'T', status: 'running' }], permissions: [{ requestId: 'p', title: 'P', options: [] }] }] } as never
+ const c = normalizeChat(raw, 'c_live')
+ expect(c.messages[0].tools?.[0].status).toBe('failed')
+ expect(c.messages[0].permissions?.[0].resolvedOptionId).toBe('stale')
+ })
+})
diff --git a/src/renderer/src/store/persist.ts b/src/renderer/src/store/persist.ts
index dd75786..3a2038e 100644
--- a/src/renderer/src/store/persist.ts
+++ b/src/renderer/src/store/persist.ts
@@ -38,7 +38,14 @@ export function normalizeChat(c: Partial & { claudeSessionId?: string | nu
contextK: c.contextK ?? 0,
windowK: c.windowK ?? 200,
branchedFrom: c.branchedFrom ?? null,
- messages: Array.isArray(c.messages) ? c.messages : [],
+ messages: Array.isArray(c.messages)
+ ? c.messages.map((m) => ({
+ ...m,
+ // never restore live-looking state (the `compacting` doctrine)
+ tools: m.tools?.map((t) => (t.status === 'pending' || t.status === 'running' ? { ...t, status: 'failed' as const } : t)),
+ permissions: m.permissions?.map((p) => (p.resolvedOptionId ? p : { ...p, resolvedOptionId: 'stale' }))
+ }))
+ : [],
sessionId: c.sessionId ?? c.claudeSessionId ?? null, // migrate legacy claudeSessionId
sessionProvider: c.sessionProvider ?? (c.claudeSessionId ? 'claude' : null),
summary: c.summary ?? null,
diff --git a/src/renderer/src/store/runtime.ts b/src/renderer/src/store/runtime.ts
index cfcf262..e0270b5 100644
--- a/src/renderer/src/store/runtime.ts
+++ b/src/renderer/src/store/runtime.ts
@@ -31,6 +31,15 @@ export function initRuntime(): void {
s.endTurn(chatId, event.message)
delete runToChat[event.runId]
break
+ case 'tool.updated':
+ s.upsertTool(chatId, { toolCallId: event.toolCallId, title: event.title, kind: event.kind, status: event.status, detail: event.detail })
+ break
+ case 'permission.requested':
+ s.upsertPermission(chatId, { requestId: event.requestId, title: event.title, detail: event.detail, options: event.options })
+ break
+ case 'permission.resolved':
+ s.resolvePermission(chatId, event.requestId, event.optionId)
+ break
}
})
}
@@ -95,6 +104,7 @@ export async function sendMessage(text: string): Promise {
const { runId } = await window.nac.runs.start({
prompt: useNative ? message : contextBlock + buildReplayPrompt(chat.summary, tail, message),
provider: chat.provider,
+ chatId,
sessionId: useNative ? chat.sessionId ?? undefined : undefined,
cwd,
yolo: chat.yolo,
diff --git a/src/renderer/src/store/store.test.ts b/src/renderer/src/store/store.test.ts
index 55c9df3..7636770 100644
--- a/src/renderer/src/store/store.test.ts
+++ b/src/renderer/src/store/store.test.ts
@@ -185,4 +185,24 @@ describe('app store — per-chat spine', () => {
s.setModel(chat.provider, chat.model)
expect(useApp.getState().chats[s.activeChatId].effort).toBe('low') // still valid → kept
})
+
+ it('upsertTool merges by toolCallId on the streaming turn', () => {
+ const s = useApp.getState()
+ const id = s.activeChatId
+ s.pushTurn(id, { id: 'a1', role: 'assistant', text: '', streaming: true })
+ s.upsertTool(id, { toolCallId: 't1', title: 'Run x', status: 'pending', detail: 'x' })
+ s.upsertTool(id, { toolCallId: 't1', title: 'Run x', status: 'completed', detail: 'done' })
+ const turn = useApp.getState().chats[id].messages.at(-1)!
+ expect(turn.tools).toEqual([{ toolCallId: 't1', title: 'Run x', status: 'completed', detail: 'done' }])
+ })
+
+ it('permission cards resolve in place', () => {
+ const s = useApp.getState()
+ const id = s.activeChatId
+ s.pushTurn(id, { id: 'a2', role: 'assistant', text: '', streaming: true })
+ s.upsertPermission(id, { requestId: 'p1', title: 'Run x', options: [{ id: 'allow_once', label: 'Allow once', kind: 'allow' }] })
+ s.resolvePermission(id, 'p1', 'allow_once')
+ const turn = useApp.getState().chats[id].messages.at(-1)!
+ expect(turn.permissions?.[0].resolvedOptionId).toBe('allow_once')
+ })
})
diff --git a/src/renderer/src/store/store.ts b/src/renderer/src/store/store.ts
index 975a4d3..72e682a 100644
--- a/src/renderer/src/store/store.ts
+++ b/src/renderer/src/store/store.ts
@@ -2,7 +2,7 @@ import { create } from 'zustand'
import { CONFIGS_BY_ID } from '../data/configs'
import { STATIC_CAPABILITIES, effortScaleFor, modelIdFor } from '../../../shared/capabilities'
import type { ContextItem } from '../data/context'
-import type { TurnUsage, ProviderCapabilities } from '../../../shared/runtime'
+import type { TurnUsage, ProviderCapabilities, PermissionOption } from '../../../shared/runtime'
// The per-chat state spine (FR-4.1): every chat owns its own provider/model/agent/attached/config/transcript.
// Mutations target a specific chat — nothing is global. Switching chats is lossless (FR-4.2).
@@ -20,6 +20,21 @@ export interface Workspace {
defaults?: WorkspaceDefaults // new chats here inherit these (else fall back to active-chat inheritance — M0-4)
}
+export interface ToolRow {
+ toolCallId: string
+ title: string
+ kind?: string
+ status: 'pending' | 'running' | 'completed' | 'failed'
+ detail?: string
+}
+export interface PermissionCard {
+ requestId: string
+ title: string
+ detail?: string
+ options: PermissionOption[]
+ resolvedOptionId?: string
+}
+
// A turn in the provider-neutral transcript (M0-8 source of truth — renders the UI and, later, powers replay).
export interface Turn {
id: string
@@ -27,6 +42,8 @@ export interface Turn {
text: string
streaming?: boolean
error?: boolean
+ tools?: ToolRow[] // render-only history — NEVER read by buildReplayPrompt
+ permissions?: PermissionCard[]
}
// Accumulated metering for a chat, keyed by provider (each provider reports in its own units).
@@ -111,6 +128,9 @@ interface AppState {
endTurn: (chatId: string, error?: string) => void
setSession: (chatId: string, sessionId: string, provider: string) => void
recordUsage: (chatId: string, provider: string, usage: TurnUsage) => void
+ upsertTool: (chatId: string, row: ToolRow) => void
+ upsertPermission: (chatId: string, card: PermissionCard) => void
+ resolvePermission: (chatId: string, requestId: string, optionId: string) => void
// user-authored context library items (notes + files), persisted
userItems: ContextItem[]
addNote: (name: string, content: string) => void
@@ -338,6 +358,42 @@ export const useApp = create()((set, get) => ({
}
return { chats: { ...s.chats, [chatId]: { ...c, usage: { ...c.usage, [provider]: next } } } }
}),
+ upsertTool: (chatId, row) =>
+ set((s) => {
+ const c = s.chats[chatId]
+ if (!c) return {}
+ const messages = updateLast(c.messages, (t) => {
+ const tools = t.tools ? [...t.tools] : []
+ const i = tools.findIndex((x) => x.toolCallId === row.toolCallId)
+ if (i >= 0) tools[i] = { ...tools[i], ...row }
+ else tools.push(row)
+ return { ...t, tools }
+ })
+ return { chats: { ...s.chats, [chatId]: { ...c, messages } } }
+ }),
+ upsertPermission: (chatId, card) =>
+ set((s) => {
+ const c = s.chats[chatId]
+ if (!c) return {}
+ const messages = updateLast(c.messages, (t) => {
+ const permissions = t.permissions ? [...t.permissions] : []
+ const i = permissions.findIndex((x) => x.requestId === card.requestId)
+ if (i >= 0) permissions[i] = { ...permissions[i], ...card }
+ else permissions.push(card)
+ return { ...t, permissions }
+ })
+ return { chats: { ...s.chats, [chatId]: { ...c, messages } } }
+ }),
+ resolvePermission: (chatId, requestId, optionId) =>
+ set((s) => {
+ const c = s.chats[chatId]
+ if (!c) return {}
+ const messages = updateLast(c.messages, (t) => ({
+ ...t,
+ permissions: (t.permissions ?? []).map((p) => (p.requestId === requestId ? { ...p, resolvedOptionId: optionId } : p))
+ }))
+ return { chats: { ...s.chats, [chatId]: { ...c, messages } } }
+ }),
addNote: (name, content) =>
set((s) => ({
userItems: [...s.userItems, { id: `u_${Date.now()}_${++chatSeq}`, type: 'instruction', name: name.trim() || 'note', description: content.trim().slice(0, 80), tokens: Math.ceil(content.length / 4), scope: 'workspace', source: 'user', tags: ['note'], content, user: true }]
diff --git a/src/shared/runtime.ts b/src/shared/runtime.ts
index 94841da..0cc1d92 100644
--- a/src/shared/runtime.ts
+++ b/src/shared/runtime.ts
@@ -4,6 +4,7 @@
export interface RunRequest {
prompt: string
provider?: string // harness driver id; selects the adapter (e.g. 'claude' → real, else stub)
+ chatId?: string // session-affinity key for persistent transports
sessionId?: string // native session id to resume (e.g. Claude `--resume`) — same-provider fast-path (FR-4.2)
cwd?: string // working directory for the harness = the chat's workspace folder (agents act on real code)
yolo?: boolean // autonomy: on = full file/command access; off (default) = restricted per harness (M0-2)
From bf7d96247988de06a1c04a4f6b280250cbb529a3 Mon Sep 17 00:00:00 2001
From: Nathan Font-Fife
Date: Thu, 9 Jul 2026 05:25:08 -0600
Subject: [PATCH 06/11] fix(store): tool/permission reducers target the last
assistant turn (late-event race)
Co-Authored-By: Claude Fable 5
---
src/renderer/src/store/store.test.ts | 11 +++++++++++
src/renderer/src/store/store.ts | 19 ++++++++++++++++---
2 files changed, 27 insertions(+), 3 deletions(-)
diff --git a/src/renderer/src/store/store.test.ts b/src/renderer/src/store/store.test.ts
index 7636770..87b51a3 100644
--- a/src/renderer/src/store/store.test.ts
+++ b/src/renderer/src/store/store.test.ts
@@ -205,4 +205,15 @@ describe('app store — per-chat spine', () => {
const turn = useApp.getState().chats[id].messages.at(-1)!
expect(turn.permissions?.[0].resolvedOptionId).toBe('allow_once')
})
+
+ it('tool/permission events after a new user turn still target the last assistant turn', () => {
+ const s = useApp.getState()
+ const id = s.activeChatId
+ s.pushTurn(id, { id: 'a9', role: 'assistant', text: 'done', streaming: false })
+ s.pushTurn(id, { id: 'u9', role: 'user', text: 'next question' })
+ s.upsertTool(id, { toolCallId: 'late', title: 'Late tool', status: 'completed' })
+ const msgs = useApp.getState().chats[id].messages
+ expect(msgs.at(-1)!.tools).toBeUndefined() // user turn untouched
+ expect(msgs.at(-2)!.tools?.[0].toolCallId).toBe('late')
+ })
})
diff --git a/src/renderer/src/store/store.ts b/src/renderer/src/store/store.ts
index 72e682a..e9bdc1e 100644
--- a/src/renderer/src/store/store.ts
+++ b/src/renderer/src/store/store.ts
@@ -159,6 +159,19 @@ const updateLast = (msgs: Turn[], patch: (t: Turn) => Turn): Turn[] => {
return copy
}
+// Tool/permission events may arrive after endTurn (expiry fires post-completion) or after the user's
+// next message — target the last ASSISTANT turn, never whatever happens to be last.
+const updateLastAssistant = (msgs: Turn[], patch: (t: Turn) => Turn): Turn[] => {
+ for (let i = msgs.length - 1; i >= 0; i--) {
+ if (msgs[i].role === 'assistant') {
+ const copy = msgs.slice()
+ copy[i] = patch(copy[i])
+ return copy
+ }
+ }
+ return msgs
+}
+
// Collision-proof chat id (Date.now() alone collides on rapid creates within the same ms).
let chatSeq = 0
const nextChatId = (): string => `c_${Date.now()}_${++chatSeq}`
@@ -362,7 +375,7 @@ export const useApp = create()((set, get) => ({
set((s) => {
const c = s.chats[chatId]
if (!c) return {}
- const messages = updateLast(c.messages, (t) => {
+ const messages = updateLastAssistant(c.messages, (t) => {
const tools = t.tools ? [...t.tools] : []
const i = tools.findIndex((x) => x.toolCallId === row.toolCallId)
if (i >= 0) tools[i] = { ...tools[i], ...row }
@@ -375,7 +388,7 @@ export const useApp = create()((set, get) => ({
set((s) => {
const c = s.chats[chatId]
if (!c) return {}
- const messages = updateLast(c.messages, (t) => {
+ const messages = updateLastAssistant(c.messages, (t) => {
const permissions = t.permissions ? [...t.permissions] : []
const i = permissions.findIndex((x) => x.requestId === card.requestId)
if (i >= 0) permissions[i] = { ...permissions[i], ...card }
@@ -388,7 +401,7 @@ export const useApp = create()((set, get) => ({
set((s) => {
const c = s.chats[chatId]
if (!c) return {}
- const messages = updateLast(c.messages, (t) => ({
+ const messages = updateLastAssistant(c.messages, (t) => ({
...t,
permissions: (t.permissions ?? []).map((p) => (p.requestId === requestId ? { ...p, resolvedOptionId: optionId } : p))
}))
From 00b0a282f03e93078337171efb2b71f667966ce6 Mon Sep 17 00:00:00 2001
From: Nathan Font-Fife
Date: Thu, 9 Jul 2026 05:29:39 -0600
Subject: [PATCH 07/11] feat(ui): permission cards, expandable tool rows, stop
button
---
src/renderer/src/components/ChatView.tsx | 43 +++++++++++++++----
.../src/components/PermissionCard.tsx | 34 +++++++++++++++
src/renderer/src/components/ToolRow.tsx | 25 +++++++++++
src/renderer/src/store/runtime.ts | 5 +++
4 files changed, 98 insertions(+), 9 deletions(-)
create mode 100644 src/renderer/src/components/PermissionCard.tsx
create mode 100644 src/renderer/src/components/ToolRow.tsx
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 && (
+
+ )}