From 99a906753ec9c0f86eff6fe306062f0c2c3d15df Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Mon, 9 Feb 2026 00:16:23 -0800 Subject: [PATCH] approvals --- README.md | 18 +- TODO.md | 6 +- apps/desktop/src/codexAppServerManager.ts | 105 +++++++++-- apps/desktop/src/main.ts | 11 ++ apps/desktop/src/preload.ts | 2 + apps/desktop/src/providerManager.test.ts | 14 ++ apps/desktop/src/providerManager.ts | 15 ++ apps/renderer/src/components/ChatView.tsx | 190 ++++++++++++++++++++ apps/renderer/src/components/Sidebar.tsx | 75 ++++++++ apps/renderer/src/persistenceSchema.test.ts | 29 ++- apps/renderer/src/persistenceSchema.ts | 25 ++- apps/renderer/src/store.ts | 37 +++- apps/renderer/src/types.ts | 2 + packages/contracts/src/ipc.ts | 3 + packages/contracts/src/provider.test.ts | 37 ++++ packages/contracts/src/provider.ts | 24 +++ 16 files changed, 560 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 6cabcfb0fa1cd0f991a6231f1512e78f01ed5bb4..2009730addff1e0028d10ecf59311c177576663a 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,17 @@ CodeThing is a desktop shell for coding agents. This first implementation is: - Renderer talks only to `window.nativeApi` exposed by preload. - Preload and main both validate inputs using shared Zod schemas. +`sandbox: true` above is Electron renderer sandboxing. It is separate from Codex execution sandbox policy (`read-only`, `workspace-write`, `danger-full-access`) used when starting provider sessions. + +## Runtime modes + +CodeThing has a global runtime mode switch in the sidebar: + +- `Full access` (default): starts new sessions with `approvalPolicy: never` and `sandboxMode: danger-full-access`. +- `Approval required`: starts new sessions with `approvalPolicy: on-request` and `sandboxMode: workspace-write`, then prompts in-app for command/file approvals. + +Mode changes apply across all threads. Existing live sessions are restarted so old and new threads use the selected mode. + ## Scripts - `bun run dev`: starts contract build/watch, renderer dev server, and Electron process. @@ -47,8 +58,9 @@ The renderer now depends on `nativeApi.providers.*`: 1. `startSession` 2. `sendTurn` 3. `interruptTurn` -4. `stopSession` -5. `listSessions` -6. `onEvent` +4. `respondToRequest` +5. `stopSession` +6. `listSessions` +7. `onEvent` Codex is the only implemented provider right now. `claudeCode` is reserved in contracts/UI but returns a not-implemented error in main-process dispatch. diff --git a/TODO.md b/TODO.md index 8e22ac8ac08ea1ae502db9227a621578734599c1..fe1796510d5946676c699395f86402fe7950f3ba 100644 --- a/TODO.md +++ b/TODO.md @@ -1,6 +1,4 @@ ## TODO -- [x] Remove model picker from new project menu -- [x] CMD+Shift+O to create new thread in current project -- [x] Remove project title pill from input box and topnav -- [x] Remove connection status from topnav +- [ ] 1 click copy in codeblocks +- [ ] Pasting/adding images to submit with prompts diff --git a/apps/desktop/src/codexAppServerManager.ts b/apps/desktop/src/codexAppServerManager.ts index c7a93c27860ee296ea1f03b00a1acd4ef4a496fc..d40c761e07c3f2f50f3abe52dcb3861342682ed6 100644 --- a/apps/desktop/src/codexAppServerManager.ts +++ b/apps/desktop/src/codexAppServerManager.ts @@ -4,7 +4,9 @@ import { EventEmitter } from "node:events"; import readline from "node:readline"; import type { + ProviderApprovalDecision, ProviderEvent, + ProviderRequestKind, ProviderSendTurnInput, ProviderSession, ProviderSessionStartInput, @@ -21,11 +23,24 @@ interface PendingRequest { reject: (error: Error) => void; } +interface PendingApprovalRequest { + requestId: string; + jsonRpcId: string | number; + method: + | "item/commandExecution/requestApproval" + | "item/fileChange/requestApproval"; + requestKind: ProviderRequestKind; + threadId?: string; + turnId?: string; + itemId?: string; +} + interface CodexSessionContext { session: ProviderSession; child: ChildProcessWithoutNullStreams; output: readline.Interface; pending: Map; + pendingApprovals: Map; nextRequestId: number; stopping: boolean; } @@ -137,6 +152,7 @@ export class CodexAppServerManager extends EventEmitter { + const context = this.requireSession(sessionId); + const pendingRequest = context.pendingApprovals.get(requestId); + if (!pendingRequest) { + throw new Error(`Unknown pending approval request: ${requestId}`); + } + + context.pendingApprovals.delete(requestId); + this.writeMessage(context, { + id: pendingRequest.jsonRpcId, + result: { + decision, + }, + }); + + this.emitEvent({ + id: randomUUID(), + kind: "notification", + provider: "codex", + sessionId: context.session.sessionId, + createdAt: new Date().toISOString(), + method: "item/requestApproval/decision", + threadId: pendingRequest.threadId, + turnId: pendingRequest.turnId, + itemId: pendingRequest.itemId, + requestId: pendingRequest.requestId, + requestKind: pendingRequest.requestKind, + payload: { + requestId: pendingRequest.requestId, + requestKind: pendingRequest.requestKind, + decision, + }, + }); + } + stopSession(sessionId: string): void { const context = this.sessions.get(sessionId); if (!context) { @@ -286,6 +341,7 @@ export class CodexAppServerManager extends EventEmitter { + await providerManager.respondToRequest(payload); + }, + ), + ); + ipcMain.handle( IPC_CHANNELS.providerSessionStop, withParsedPayload( diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index b72be5e13dd0c16c89491d5c7053467876b799c9..498403942587f3f045db02b383db910c4aa13983 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -41,6 +41,8 @@ const nativeApi: NativeApi = { ipcRenderer.invoke(IPC_CHANNELS.providerTurnStart, input), interruptTurn: (input) => ipcRenderer.invoke(IPC_CHANNELS.providerTurnInterrupt, input), + respondToRequest: (input) => + ipcRenderer.invoke(IPC_CHANNELS.providerRequestRespond, input), stopSession: (input) => ipcRenderer.invoke(IPC_CHANNELS.providerSessionStop, input), listSessions: () => ipcRenderer.invoke(IPC_CHANNELS.providerSessionList), diff --git a/apps/desktop/src/providerManager.test.ts b/apps/desktop/src/providerManager.test.ts index 3d3516c62373e303b83dae7d4d35a7725d4bac75..c159bb296cf6a358d3ce79b616f0debd40ea5a7a 100644 --- a/apps/desktop/src/providerManager.test.ts +++ b/apps/desktop/src/providerManager.test.ts @@ -48,4 +48,18 @@ describe("ProviderManager", () => { manager.dispose(); expect(() => manager.dispose()).not.toThrow(); }); + + it("rejects request responses for unknown sessions", async () => { + const manager = new ProviderManager(); + + await expect( + manager.respondToRequest({ + sessionId: "missing-session", + requestId: "req-1", + decision: "accept", + }), + ).rejects.toThrow("Unknown provider session: missing-session"); + + manager.dispose(); + }); }); diff --git a/apps/desktop/src/providerManager.ts b/apps/desktop/src/providerManager.ts index 4d21b2f1f0e19ff1c9c1b3fb2a6ca3aba7097b37..ebba31b20dd9125d26b71f2a8a9af97e26cd3a73 100644 --- a/apps/desktop/src/providerManager.ts +++ b/apps/desktop/src/providerManager.ts @@ -5,12 +5,14 @@ import path from "node:path"; import { type ProviderEvent, type ProviderInterruptTurnInput, + type ProviderRespondToRequestInput, type ProviderSendTurnInput, type ProviderSession, type ProviderSessionStartInput, type ProviderStopSessionInput, type ProviderTurnStartResult, providerInterruptTurnInputSchema, + providerRespondToRequestInputSchema, providerSendTurnInputSchema, providerSessionStartInputSchema, providerStopSessionInputSchema, @@ -79,6 +81,19 @@ export class ProviderManager extends EventEmitter { await this.codex.interruptTurn(input.sessionId, input.turnId); } + async respondToRequest(raw: ProviderRespondToRequestInput): Promise { + const input = providerRespondToRequestInputSchema.parse(raw); + if (!this.codex.hasSession(input.sessionId)) { + throw new Error(`Unknown provider session: ${input.sessionId}`); + } + + await this.codex.respondToRequest( + input.sessionId, + input.requestId, + input.decision, + ); + } + stopSession(raw: ProviderStopSessionInput): void { const input = providerStopSessionInputSchema.parse(raw); this.codex.stopSession(input.sessionId); diff --git a/apps/renderer/src/components/ChatView.tsx b/apps/renderer/src/components/ChatView.tsx index f86f3b42a7774ea77a3c5cadc4267b1d5fc2cc43..985f7aefd3db7d65897ea7c7fb799f355dc45370 100644 --- a/apps/renderer/src/components/ChatView.tsx +++ b/apps/renderer/src/components/ChatView.tsx @@ -1,3 +1,4 @@ +import type { ProviderApprovalDecision, ProviderEvent } from "@acme/contracts"; import { type FormEvent, Fragment, @@ -39,6 +40,70 @@ function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { return "text-[#606060]"; } +interface PendingApprovalCard { + requestId: string; + requestKind: "command" | "file-change"; + createdAt: string; + detail?: string; +} + +function asRecord(value: unknown): Record | undefined { + if (!value || typeof value !== "object") return undefined; + return value as Record; +} + +function asString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +function approvalDetail(event: ProviderEvent): string | undefined { + const payload = asRecord(event.payload); + const command = asString(payload?.command); + if (command) return command; + return asString(payload?.reason); +} + +function derivePendingApprovals( + events: ProviderEvent[], +): PendingApprovalCard[] { + const pending = new Map(); + const ordered = [...events].reverse(); + + for (const event of ordered) { + if ( + event.method === "session/closed" || + event.method === "session/exited" + ) { + pending.clear(); + continue; + } + + const requestId = + event.requestId ?? asString(asRecord(event.payload)?.requestId); + if (!requestId) continue; + + if ( + event.kind === "request" && + (event.requestKind === "command" || event.requestKind === "file-change") + ) { + const detail = approvalDetail(event); + pending.set(requestId, { + requestId, + requestKind: event.requestKind, + createdAt: event.createdAt, + ...(detail ? { detail } : {}), + }); + continue; + } + + if (event.method === "item/requestApproval/decision") { + pending.delete(requestId); + } + } + + return Array.from(pending.values()); +} + export default function ChatView() { const { state, dispatch } = useStore(); const api = useMemo(() => readNativeApi(), []); @@ -48,6 +113,9 @@ export default function ChatView() { const [isModelMenuOpen, setIsModelMenuOpen] = useState(false); const [selectedEffort, setSelectedEffort] = useState(DEFAULT_REASONING); + const [respondingRequestIds, setRespondingRequestIds] = useState( + [], + ); const [nowTick, setNowTick] = useState(() => Date.now()); const messagesEndRef = useRef(null); const textareaRef = useRef(null); @@ -68,6 +136,10 @@ export default function ChatView() { () => deriveWorkLogEntries(activeThread?.events ?? [], undefined), [activeThread?.events], ); + const pendingApprovals = useMemo( + () => derivePendingApprovals(activeThread?.events ?? []), + [activeThread?.events], + ); const assistantCompletionByItemId = useMemo(() => { const map = new Map(); const ordered = [...(activeThread?.events ?? [])].reverse(); @@ -127,6 +199,16 @@ export default function ChatView() { timelineEntries, workLogEntries.length, ]); + const runtimeSessionConfig = + state.runtimeMode === "full-access" + ? ({ + approvalPolicy: "never", + sandboxMode: "danger-full-access", + } as const) + : ({ + approvalPolicy: "on-request", + sandboxMode: "workspace-write", + } as const); // Auto-scroll on new messages const messageCount = activeThread?.messages.length ?? 0; @@ -191,6 +273,8 @@ export default function ChatView() { provider: "codex", cwd: activeProject.cwd || undefined, model: selectedModel || undefined, + approvalPolicy: runtimeSessionConfig.approvalPolicy, + sandboxMode: runtimeSessionConfig.sandboxMode, }); dispatch({ type: "UPDATE_SESSION", @@ -270,6 +354,37 @@ export default function ChatView() { }); }; + const onRespondToApproval = async ( + requestId: string, + decision: ProviderApprovalDecision, + ) => { + if (!api || !activeThread?.session) return; + + setRespondingRequestIds((existing) => + existing.includes(requestId) ? existing : [...existing, requestId], + ); + try { + await api.providers.respondToRequest({ + sessionId: activeThread.session.sessionId, + requestId, + decision, + }); + } catch (err) { + dispatch({ + type: "SET_ERROR", + threadId: activeThread.id, + error: + err instanceof Error + ? err.message + : "Failed to submit approval decision.", + }); + } finally { + setRespondingRequestIds((existing) => + existing.filter((id) => id !== requestId), + ); + } + }; + const onModelSelect = (model: string) => { if (!activeThread) return; dispatch({ @@ -335,6 +450,81 @@ export default function ChatView() { )} + {pendingApprovals.length > 0 && ( +
+ {pendingApprovals.map((approval) => { + const isResponding = respondingRequestIds.includes( + approval.requestId, + ); + return ( +
+

+ {approval.requestKind === "command" + ? "Command approval requested" + : "File-change approval requested"} +

+ {approval.detail && ( +

+ {approval.detail} +

+ )} +
+ + + + +
+
+ ); + })} +
+ )} + {/* Messages */}
{activeThread.messages.length === 0 && !isWorking ? ( diff --git a/apps/renderer/src/components/Sidebar.tsx b/apps/renderer/src/components/Sidebar.tsx index 4da99be8947d47591e663b5d98986c3a81e18d6f..0cdaa14b4a5b6a2869fb647d309f2f58fe029c68 100644 --- a/apps/renderer/src/components/Sidebar.tsx +++ b/apps/renderer/src/components/Sidebar.tsx @@ -28,6 +28,7 @@ export default function Sidebar() { const [addingProject, setAddingProject] = useState(false); const [newCwd, setNewCwd] = useState(""); const [isPickingFolder, setIsPickingFolder] = useState(false); + const [isSwitchingRuntimeMode, setIsSwitchingRuntimeMode] = useState(false); const handleAddProject = () => { const cwd = newCwd.trim(); @@ -79,6 +80,43 @@ export default function Sidebar() { } }; + const handleRuntimeModeChange = useCallback( + async (mode: "approval-required" | "full-access") => { + if (mode === state.runtimeMode) return; + + dispatch({ + type: "SET_RUNTIME_MODE", + mode, + }); + + if (!api) return; + + const sessionIds = state.threads + .map((thread) => thread.session) + .filter( + (session): session is NonNullable => + session !== null && session.status !== "closed", + ) + .map((session) => session.sessionId); + + if (sessionIds.length === 0) { + return; + } + + setIsSwitchingRuntimeMode(true); + try { + await Promise.all( + sessionIds.map((sessionId) => + api.providers.stopSession({ sessionId }).catch(() => undefined), + ), + ); + } finally { + setIsSwitchingRuntimeMode(false); + } + }, + [api, dispatch, state.runtimeMode, state.threads], + ); + useEffect(() => { const onWindowKeyDown = (event: KeyboardEvent) => { const isNewThreadShortcut = @@ -151,6 +189,43 @@ export default function Sidebar() {
+
+
+

+ Runtime mode +

+

+ Switching restarts live sessions. +

+
+ + +
+
+
+ {/* Project list */}