authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-02-09 00:16:23-08:00
committergravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-02-09 00:16:23-08:00
log99a906753ec9c0f86eff6fe306062f0c2c3d15df
tree48d9a4af7013057cd6b3c8fe74139fc5a08a3d08
parentde5df2690de58406a480071eb2c7ba7e2fd4e3bd
signature Commit is signed but in an unrecognized format.

approvals


16 files changed, 560 insertions(+), 33 deletions(-)

README.md+15-3
......@@ -26,6 +26,17 @@ CodeThing is a desktop shell for coding agents. This first implementation is:
2626- Renderer talks only to `window.nativeApi` exposed by preload.
2727- Preload and main both validate inputs using shared Zod schemas.
2828
29`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.
30
31## Runtime modes
32
33CodeThing has a global runtime mode switch in the sidebar:
34
35- `Full access` (default): starts new sessions with `approvalPolicy: never` and `sandboxMode: danger-full-access`.
36- `Approval required`: starts new sessions with `approvalPolicy: on-request` and `sandboxMode: workspace-write`, then prompts in-app for command/file approvals.
37
38Mode changes apply across all threads. Existing live sessions are restarted so old and new threads use the selected mode.
39
2940## Scripts
3041
3142- `bun run dev`: starts contract build/watch, renderer dev server, and Electron process.
......@@ -47,8 +58,9 @@ The renderer now depends on `nativeApi.providers.*`:
47581. `startSession`
48592. `sendTurn`
49603. `interruptTurn`
504. `stopSession`
515. `listSessions`
526. `onEvent`
614. `respondToRequest`
625. `stopSession`
636. `listSessions`
647. `onEvent`
5365
5466Codex is the only implemented provider right now. `claudeCode` is reserved in contracts/UI but returns a not-implemented error in main-process dispatch.
TODO.md+2-4
......@@ -1,6 +1,4 @@
11## TODO
22
3- [x] Remove model picker from new project menu
4- [x] CMD+Shift+O to create new thread in current project
5- [x] Remove project title pill from input box and topnav
6- [x] Remove connection status from topnav
3- [ ] 1 click copy in codeblocks
4- [ ] Pasting/adding images to submit with prompts
apps/desktop/src/codexAppServerManager.ts+92-13
......@@ -4,7 +4,9 @@ import { EventEmitter } from "node:events";
44import readline from "node:readline";
55
66import type {
7 ProviderApprovalDecision,
78 ProviderEvent,
9 ProviderRequestKind,
810 ProviderSendTurnInput,
911 ProviderSession,
1012 ProviderSessionStartInput,
......@@ -21,11 +23,24 @@ interface PendingRequest {
2123 reject: (error: Error) => void;
2224}
2325
26interface PendingApprovalRequest {
27 requestId: string;
28 jsonRpcId: string | number;
29 method:
30 | "item/commandExecution/requestApproval"
31 | "item/fileChange/requestApproval";
32 requestKind: ProviderRequestKind;
33 threadId?: string;
34 turnId?: string;
35 itemId?: string;
36}
37
2438interface CodexSessionContext {
2539 session: ProviderSession;
2640 child: ChildProcessWithoutNullStreams;
2741 output: readline.Interface;
2842 pending: Map<PendingRequestKey, PendingRequest>;
43 pendingApprovals: Map<string, PendingApprovalRequest>;
2944 nextRequestId: number;
3045 stopping: boolean;
3146}
......@@ -137,6 +152,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
137152 child,
138153 output,
139154 pending: new Map(),
155 pendingApprovals: new Map(),
140156 nextRequestId: 1,
141157 stopping: false,
142158 };
......@@ -273,6 +289,45 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
273289 });
274290 }
275291
292 async respondToRequest(
293 sessionId: string,
294 requestId: string,
295 decision: ProviderApprovalDecision,
296 ): Promise<void> {
297 const context = this.requireSession(sessionId);
298 const pendingRequest = context.pendingApprovals.get(requestId);
299 if (!pendingRequest) {
300 throw new Error(`Unknown pending approval request: ${requestId}`);
301 }
302
303 context.pendingApprovals.delete(requestId);
304 this.writeMessage(context, {
305 id: pendingRequest.jsonRpcId,
306 result: {
307 decision,
308 },
309 });
310
311 this.emitEvent({
312 id: randomUUID(),
313 kind: "notification",
314 provider: "codex",
315 sessionId: context.session.sessionId,
316 createdAt: new Date().toISOString(),
317 method: "item/requestApproval/decision",
318 threadId: pendingRequest.threadId,
319 turnId: pendingRequest.turnId,
320 itemId: pendingRequest.itemId,
321 requestId: pendingRequest.requestId,
322 requestKind: pendingRequest.requestKind,
323 payload: {
324 requestId: pendingRequest.requestId,
325 requestKind: pendingRequest.requestKind,
326 decision,
327 },
328 });
329 }
330
276331 stopSession(sessionId: string): void {
277332 const context = this.sessions.get(sessionId);
278333 if (!context) {
......@@ -286,6 +341,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
286341 pending.reject(new Error("Session stopped before request completed."));
287342 }
288343 context.pending.clear();
344 context.pendingApprovals.clear();
289345
290346 context.output.close();
291347
......@@ -498,6 +554,25 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
498554 request: JsonRpcRequest,
499555 ): void {
500556 const route = this.readRouteFields(request.params);
557 const requestKind = this.requestKindForMethod(request.method);
558 let requestId: string | undefined;
559 if (requestKind) {
560 requestId = randomUUID();
561 const pendingRequest: PendingApprovalRequest = {
562 requestId,
563 jsonRpcId: request.id,
564 method:
565 requestKind === "command"
566 ? "item/commandExecution/requestApproval"
567 : "item/fileChange/requestApproval",
568 requestKind,
569 ...(route.threadId ? { threadId: route.threadId } : {}),
570 ...(route.turnId ? { turnId: route.turnId } : {}),
571 ...(route.itemId ? { itemId: route.itemId } : {}),
572 };
573 context.pendingApprovals.set(requestId, pendingRequest);
574 }
575
501576 this.emitEvent({
502577 id: randomUUID(),
503578 kind: "request",
......@@ -508,22 +583,12 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
508583 threadId: route.threadId,
509584 turnId: route.turnId,
510585 itemId: route.itemId,
586 requestId,
587 requestKind,
511588 payload: request.params,
512589 });
513590
514 if (request.method === "item/commandExecution/requestApproval") {
515 this.writeMessage(context, {
516 id: request.id,
517 result: { decision: "decline" },
518 });
519 return;
520 }
521
522 if (request.method === "item/fileChange/requestApproval") {
523 this.writeMessage(context, {
524 id: request.id,
525 result: { decision: "decline" },
526 });
591 if (requestKind) {
527592 return;
528593 }
529594
......@@ -656,6 +721,20 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
656721 };
657722 }
658723
724 private requestKindForMethod(
725 method: string,
726 ): ProviderRequestKind | undefined {
727 if (method === "item/commandExecution/requestApproval") {
728 return "command";
729 }
730
731 if (method === "item/fileChange/requestApproval") {
732 return "file-change";
733 }
734
735 return undefined;
736 }
737
659738 private isServerRequest(value: unknown): value is JsonRpcRequest {
660739 if (!value || typeof value !== "object") {
661740 return false;
apps/desktop/src/main.ts+11
......@@ -13,6 +13,7 @@ import {
1313 agentSessionIdSchema,
1414 newTodoInputSchema,
1515 providerInterruptTurnInputSchema,
16 providerRespondToRequestInputSchema,
1617 providerSendTurnInputSchema,
1718 providerSessionStartInputSchema,
1819 providerStopSessionInputSchema,
......@@ -175,6 +176,16 @@ function registerIpcHandlers(): void {
175176 ),
176177 );
177178
179 ipcMain.handle(
180 IPC_CHANNELS.providerRequestRespond,
181 withParsedPayload(
182 providerRespondToRequestInputSchema,
183 async (_event, payload) => {
184 await providerManager.respondToRequest(payload);
185 },
186 ),
187 );
188
178189 ipcMain.handle(
179190 IPC_CHANNELS.providerSessionStop,
180191 withParsedPayload(
apps/desktop/src/preload.ts+2
......@@ -41,6 +41,8 @@ const nativeApi: NativeApi = {
4141 ipcRenderer.invoke(IPC_CHANNELS.providerTurnStart, input),
4242 interruptTurn: (input) =>
4343 ipcRenderer.invoke(IPC_CHANNELS.providerTurnInterrupt, input),
44 respondToRequest: (input) =>
45 ipcRenderer.invoke(IPC_CHANNELS.providerRequestRespond, input),
4446 stopSession: (input) =>
4547 ipcRenderer.invoke(IPC_CHANNELS.providerSessionStop, input),
4648 listSessions: () => ipcRenderer.invoke(IPC_CHANNELS.providerSessionList),
apps/desktop/src/providerManager.test.ts+14
......@@ -48,4 +48,18 @@ describe("ProviderManager", () => {
4848 manager.dispose();
4949 expect(() => manager.dispose()).not.toThrow();
5050 });
51
52 it("rejects request responses for unknown sessions", async () => {
53 const manager = new ProviderManager();
54
55 await expect(
56 manager.respondToRequest({
57 sessionId: "missing-session",
58 requestId: "req-1",
59 decision: "accept",
60 }),
61 ).rejects.toThrow("Unknown provider session: missing-session");
62
63 manager.dispose();
64 });
5165});
apps/desktop/src/providerManager.ts+15
......@@ -5,12 +5,14 @@ import path from "node:path";
55import {
66 type ProviderEvent,
77 type ProviderInterruptTurnInput,
8 type ProviderRespondToRequestInput,
89 type ProviderSendTurnInput,
910 type ProviderSession,
1011 type ProviderSessionStartInput,
1112 type ProviderStopSessionInput,
1213 type ProviderTurnStartResult,
1314 providerInterruptTurnInputSchema,
15 providerRespondToRequestInputSchema,
1416 providerSendTurnInputSchema,
1517 providerSessionStartInputSchema,
1618 providerStopSessionInputSchema,
......@@ -79,6 +81,19 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> {
7981 await this.codex.interruptTurn(input.sessionId, input.turnId);
8082 }
8183
84 async respondToRequest(raw: ProviderRespondToRequestInput): Promise<void> {
85 const input = providerRespondToRequestInputSchema.parse(raw);
86 if (!this.codex.hasSession(input.sessionId)) {
87 throw new Error(`Unknown provider session: ${input.sessionId}`);
88 }
89
90 await this.codex.respondToRequest(
91 input.sessionId,
92 input.requestId,
93 input.decision,
94 );
95 }
96
8297 stopSession(raw: ProviderStopSessionInput): void {
8398 const input = providerStopSessionInputSchema.parse(raw);
8499 this.codex.stopSession(input.sessionId);
apps/renderer/src/components/ChatView.tsx+190
......@@ -1,3 +1,4 @@
1import type { ProviderApprovalDecision, ProviderEvent } from "@acme/contracts";
12import {
23 type FormEvent,
34 Fragment,
......@@ -39,6 +40,70 @@ function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string {
3940 return "text-[#606060]";
4041}
4142
43interface PendingApprovalCard {
44 requestId: string;
45 requestKind: "command" | "file-change";
46 createdAt: string;
47 detail?: string;
48}
49
50function asRecord(value: unknown): Record<string, unknown> | undefined {
51 if (!value || typeof value !== "object") return undefined;
52 return value as Record<string, unknown>;
53}
54
55function asString(value: unknown): string | undefined {
56 return typeof value === "string" ? value : undefined;
57}
58
59function approvalDetail(event: ProviderEvent): string | undefined {
60 const payload = asRecord(event.payload);
61 const command = asString(payload?.command);
62 if (command) return command;
63 return asString(payload?.reason);
64}
65
66function derivePendingApprovals(
67 events: ProviderEvent[],
68): PendingApprovalCard[] {
69 const pending = new Map<string, PendingApprovalCard>();
70 const ordered = [...events].reverse();
71
72 for (const event of ordered) {
73 if (
74 event.method === "session/closed" ||
75 event.method === "session/exited"
76 ) {
77 pending.clear();
78 continue;
79 }
80
81 const requestId =
82 event.requestId ?? asString(asRecord(event.payload)?.requestId);
83 if (!requestId) continue;
84
85 if (
86 event.kind === "request" &&
87 (event.requestKind === "command" || event.requestKind === "file-change")
88 ) {
89 const detail = approvalDetail(event);
90 pending.set(requestId, {
91 requestId,
92 requestKind: event.requestKind,
93 createdAt: event.createdAt,
94 ...(detail ? { detail } : {}),
95 });
96 continue;
97 }
98
99 if (event.method === "item/requestApproval/decision") {
100 pending.delete(requestId);
101 }
102 }
103
104 return Array.from(pending.values());
105}
106
42107export default function ChatView() {
43108 const { state, dispatch } = useStore();
44109 const api = useMemo(() => readNativeApi(), []);
......@@ -48,6 +113,9 @@ export default function ChatView() {
48113 const [isModelMenuOpen, setIsModelMenuOpen] = useState(false);
49114 const [selectedEffort, setSelectedEffort] =
50115 useState<string>(DEFAULT_REASONING);
116 const [respondingRequestIds, setRespondingRequestIds] = useState<string[]>(
117 [],
118 );
51119 const [nowTick, setNowTick] = useState(() => Date.now());
52120 const messagesEndRef = useRef<HTMLDivElement>(null);
53121 const textareaRef = useRef<HTMLTextAreaElement>(null);
......@@ -68,6 +136,10 @@ export default function ChatView() {
68136 () => deriveWorkLogEntries(activeThread?.events ?? [], undefined),
69137 [activeThread?.events],
70138 );
139 const pendingApprovals = useMemo(
140 () => derivePendingApprovals(activeThread?.events ?? []),
141 [activeThread?.events],
142 );
71143 const assistantCompletionByItemId = useMemo(() => {
72144 const map = new Map<string, string>();
73145 const ordered = [...(activeThread?.events ?? [])].reverse();
......@@ -127,6 +199,16 @@ export default function ChatView() {
127199 timelineEntries,
128200 workLogEntries.length,
129201 ]);
202 const runtimeSessionConfig =
203 state.runtimeMode === "full-access"
204 ? ({
205 approvalPolicy: "never",
206 sandboxMode: "danger-full-access",
207 } as const)
208 : ({
209 approvalPolicy: "on-request",
210 sandboxMode: "workspace-write",
211 } as const);
130212
131213 // Auto-scroll on new messages
132214 const messageCount = activeThread?.messages.length ?? 0;
......@@ -191,6 +273,8 @@ export default function ChatView() {
191273 provider: "codex",
192274 cwd: activeProject.cwd || undefined,
193275 model: selectedModel || undefined,
276 approvalPolicy: runtimeSessionConfig.approvalPolicy,
277 sandboxMode: runtimeSessionConfig.sandboxMode,
194278 });
195279 dispatch({
196280 type: "UPDATE_SESSION",
......@@ -270,6 +354,37 @@ export default function ChatView() {
270354 });
271355 };
272356
357 const onRespondToApproval = async (
358 requestId: string,
359 decision: ProviderApprovalDecision,
360 ) => {
361 if (!api || !activeThread?.session) return;
362
363 setRespondingRequestIds((existing) =>
364 existing.includes(requestId) ? existing : [...existing, requestId],
365 );
366 try {
367 await api.providers.respondToRequest({
368 sessionId: activeThread.session.sessionId,
369 requestId,
370 decision,
371 });
372 } catch (err) {
373 dispatch({
374 type: "SET_ERROR",
375 threadId: activeThread.id,
376 error:
377 err instanceof Error
378 ? err.message
379 : "Failed to submit approval decision.",
380 });
381 } finally {
382 setRespondingRequestIds((existing) =>
383 existing.filter((id) => id !== requestId),
384 );
385 }
386 };
387
273388 const onModelSelect = (model: string) => {
274389 if (!activeThread) return;
275390 dispatch({
......@@ -335,6 +450,81 @@ export default function ChatView() {
335450 </div>
336451 )}
337452
453 {pendingApprovals.length > 0 && (
454 <div className="mx-4 mt-3 space-y-2">
455 {pendingApprovals.map((approval) => {
456 const isResponding = respondingRequestIds.includes(
457 approval.requestId,
458 );
459 return (
460 <div
461 key={approval.requestId}
462 className="rounded-lg border border-amber-300/20 bg-amber-500/[0.07] px-3 py-2"
463 >
464 <p className="text-xs font-medium text-amber-100">
465 {approval.requestKind === "command"
466 ? "Command approval requested"
467 : "File-change approval requested"}
468 </p>
469 {approval.detail && (
470 <p
471 className="mt-1 truncate font-mono text-[11px] text-amber-100/75"
472 title={approval.detail}
473 >
474 {approval.detail}
475 </p>
476 )}
477 <div className="mt-2 flex flex-wrap gap-1.5">
478 <button
479 type="button"
480 className="rounded-md border border-white/[0.15] bg-white/[0.08] px-2 py-1 text-[11px] text-[#e8e8e8] transition-colors duration-150 hover:bg-white/[0.13] disabled:cursor-not-allowed disabled:opacity-50"
481 disabled={isResponding}
482 onClick={() =>
483 void onRespondToApproval(approval.requestId, "accept")
484 }
485 >
486 Approve once
487 </button>
488 <button
489 type="button"
490 className="rounded-md border border-sky-300/30 bg-sky-500/[0.15] px-2 py-1 text-[11px] text-sky-100 transition-colors duration-150 hover:bg-sky-500/[0.22] disabled:cursor-not-allowed disabled:opacity-50"
491 disabled={isResponding}
492 onClick={() =>
493 void onRespondToApproval(
494 approval.requestId,
495 "acceptForSession",
496 )
497 }
498 >
499 Always allow this session
500 </button>
501 <button
502 type="button"
503 className="rounded-md border border-white/[0.15] px-2 py-1 text-[11px] text-[#d8d8d8] transition-colors duration-150 hover:bg-white/[0.06] disabled:cursor-not-allowed disabled:opacity-50"
504 disabled={isResponding}
505 onClick={() =>
506 void onRespondToApproval(approval.requestId, "decline")
507 }
508 >
509 Decline
510 </button>
511 <button
512 type="button"
513 className="rounded-md border border-rose-300/30 bg-rose-500/[0.12] px-2 py-1 text-[11px] text-rose-100 transition-colors duration-150 hover:bg-rose-500/[0.2] disabled:cursor-not-allowed disabled:opacity-50"
514 disabled={isResponding}
515 onClick={() =>
516 void onRespondToApproval(approval.requestId, "cancel")
517 }
518 >
519 Cancel turn
520 </button>
521 </div>
522 </div>
523 );
524 })}
525 </div>
526 )}
527
338528 {/* Messages */}
339529 <div className="flex-1 overflow-y-auto px-5 py-4">
340530 {activeThread.messages.length === 0 && !isWorking ? (
apps/renderer/src/components/Sidebar.tsx+75
......@@ -28,6 +28,7 @@ export default function Sidebar() {
2828 const [addingProject, setAddingProject] = useState(false);
2929 const [newCwd, setNewCwd] = useState("");
3030 const [isPickingFolder, setIsPickingFolder] = useState(false);
31 const [isSwitchingRuntimeMode, setIsSwitchingRuntimeMode] = useState(false);
3132
3233 const handleAddProject = () => {
3334 const cwd = newCwd.trim();
......@@ -79,6 +80,43 @@ export default function Sidebar() {
7980 }
8081 };
8182
83 const handleRuntimeModeChange = useCallback(
84 async (mode: "approval-required" | "full-access") => {
85 if (mode === state.runtimeMode) return;
86
87 dispatch({
88 type: "SET_RUNTIME_MODE",
89 mode,
90 });
91
92 if (!api) return;
93
94 const sessionIds = state.threads
95 .map((thread) => thread.session)
96 .filter(
97 (session): session is NonNullable<typeof session> =>
98 session !== null && session.status !== "closed",
99 )
100 .map((session) => session.sessionId);
101
102 if (sessionIds.length === 0) {
103 return;
104 }
105
106 setIsSwitchingRuntimeMode(true);
107 try {
108 await Promise.all(
109 sessionIds.map((sessionId) =>
110 api.providers.stopSession({ sessionId }).catch(() => undefined),
111 ),
112 );
113 } finally {
114 setIsSwitchingRuntimeMode(false);
115 }
116 },
117 [api, dispatch, state.runtimeMode, state.threads],
118 );
119
82120 useEffect(() => {
83121 const onWindowKeyDown = (event: KeyboardEvent) => {
84122 const isNewThreadShortcut =
......@@ -151,6 +189,43 @@ export default function Sidebar() {
151189 </button>
152190 </div>
153191
192 <div className="px-3 pb-3">
193 <div className="rounded-lg border border-white/[0.08] bg-white/[0.03] p-2">
194 <p className="px-1 text-[10px] font-medium uppercase tracking-[0.08em] text-[#a0a0a0]/60">
195 Runtime mode
196 </p>
197 <p className="px-1 pt-0.5 pb-2 text-[10px] text-[#a0a0a0]/45">
198 Switching restarts live sessions.
199 </p>
200 <div className="grid grid-cols-2 gap-1">
201 <button
202 type="button"
203 className={`rounded-md px-2 py-1.5 text-[11px] transition-colors duration-150 ${
204 state.runtimeMode === "approval-required"
205 ? "bg-white/12 text-white"
206 : "text-[#a0a0a0]/70 hover:bg-white/[0.06]"
207 }`}
208 disabled={isSwitchingRuntimeMode}
209 onClick={() => void handleRuntimeModeChange("approval-required")}
210 >
211 Approval required
212 </button>
213 <button
214 type="button"
215 className={`rounded-md px-2 py-1.5 text-[11px] transition-colors duration-150 ${
216 state.runtimeMode === "full-access"
217 ? "bg-white/12 text-white"
218 : "text-[#a0a0a0]/70 hover:bg-white/[0.06]"
219 }`}
220 disabled={isSwitchingRuntimeMode}
221 onClick={() => void handleRuntimeModeChange("full-access")}
222 >
223 Full access
224 </button>
225 </div>
226 </div>
227 </div>
228
154229 {/* Project list */}
155230 <nav className="flex-1 overflow-y-auto px-2">
156231 {state.projects.map((project) => {
apps/renderer/src/persistenceSchema.test.ts+27-2
......@@ -47,6 +47,7 @@ describe("hydratePersistedState", () => {
4747 expect(hydrated?.projects[0]?.model).toBe(DEFAULT_MODEL);
4848 expect(hydrated?.threads[0]?.model).toBe(DEFAULT_MODEL);
4949 expect(hydrated?.threads[0]?.messages[0]?.streaming).toBe(false);
50 expect(hydrated?.runtimeMode).toBe("full-access");
5051 });
5152
5253 it("filters unknown project references and repairs active thread", () => {
......@@ -85,11 +86,33 @@ describe("hydratePersistedState", () => {
8586 expect(hydrated).not.toBeNull();
8687 expect(hydrated?.threads.map((thread) => thread.id)).toEqual(["t-1"]);
8788 expect(hydrated?.activeThreadId).toBe("t-1");
89 expect(hydrated?.runtimeMode).toBe("full-access");
90 });
91
92 it("hydrates runtime mode from v3 payload", () => {
93 const payload = JSON.stringify({
94 version: 3,
95 runtimeMode: "approval-required",
96 projects: [
97 {
98 id: "p-1",
99 name: "Project",
100 cwd: "/tmp/project",
101 model: "gpt-5.3-codex",
102 expanded: false,
103 },
104 ],
105 threads: [],
106 activeThreadId: null,
107 });
108
109 const hydrated = hydratePersistedState(payload, false);
110 expect(hydrated?.runtimeMode).toBe("approval-required");
88111 });
89112});
90113
91114describe("toPersistedState", () => {
92 it("writes v2 payload and strips non-persisted thread fields", () => {
115 it("writes v3 payload and strips non-persisted thread fields", () => {
93116 const thread: Thread = {
94117 id: "t-1",
95118 projectId: "p-1",
......@@ -122,9 +145,11 @@ describe("toPersistedState", () => {
122145 ],
123146 threads: [thread],
124147 activeThreadId: "t-1",
148 runtimeMode: "full-access",
125149 });
126150
127 expect(persisted.version).toBe(2);
151 expect(persisted.version).toBe(3);
152 expect(persisted.runtimeMode).toBe("full-access");
128153 expect(persisted.threads[0]).toEqual({
129154 id: "t-1",
130155 projectId: "p-1",
apps/renderer/src/persistenceSchema.ts+22-3
......@@ -1,7 +1,12 @@
11import { z } from "zod";
22
33import { DEFAULT_MODEL, resolveModelSlug } from "./model-logic";
4import type { Project, Thread } from "./types";
4import {
5 DEFAULT_RUNTIME_MODE,
6 type Project,
7 type RuntimeMode,
8 type Thread,
9} from "./types";
510
611const LEGACY_DEFAULT_MODEL = "gpt-5.2-codex";
712
......@@ -36,6 +41,8 @@ const persistedStateBodySchema = z.object({
3641 activeThreadId: z.string().min(1).nullable(),
3742});
3843
44const runtimeModeSchema = z.enum(["approval-required", "full-access"]);
45
3946export const persistedStateV1Schema = persistedStateBodySchema.extend({
4047 version: z.literal(1).optional(),
4148});
......@@ -44,7 +51,13 @@ export const persistedStateV2Schema = persistedStateBodySchema.extend({
4451 version: z.literal(2).optional(),
4552});
4653
54export const persistedStateV3Schema = persistedStateBodySchema.extend({
55 runtimeMode: runtimeModeSchema.default(DEFAULT_RUNTIME_MODE),
56 version: z.literal(3).optional(),
57});
58
4759const persistedStateSchema = z.union([
60 persistedStateV3Schema,
4861 persistedStateV2Schema,
4962 persistedStateV1Schema,
5063]);
......@@ -53,6 +66,7 @@ export interface PersistedStoreSnapshot {
5366 projects: Project[];
5467 threads: Thread[];
5568 activeThreadId: string | null;
69 runtimeMode: RuntimeMode;
5670}
5771
5872function maybeMigrateLegacyModel(
......@@ -134,14 +148,18 @@ export function hydratePersistedState(
134148 activeThreadId: hasActiveThread
135149 ? parsedState.data.activeThreadId
136150 : (threads[0]?.id ?? null),
151 runtimeMode:
152 "runtimeMode" in parsedState.data
153 ? parsedState.data.runtimeMode
154 : DEFAULT_RUNTIME_MODE,
137155 };
138156}
139157
140158export function toPersistedState(
141159 state: PersistedStoreSnapshot,
142): z.infer<typeof persistedStateV2Schema> {
160): z.infer<typeof persistedStateV3Schema> {
143161 return {
144 version: 2,
162 version: 3,
145163 projects: state.projects,
146164 threads: state.threads.map((thread) => ({
147165 id: thread.id,
......@@ -152,5 +170,6 @@ export function toPersistedState(
152170 createdAt: thread.createdAt,
153171 })),
154172 activeThreadId: state.activeThreadId,
173 runtimeMode: state.runtimeMode,
155174 };
156175}
apps/renderer/src/store.ts+29-8
......@@ -17,7 +17,12 @@ import {
1717 asString,
1818 evolveSession,
1919} from "./session-logic";
20import type { Project, Thread } from "./types";
20import {
21 DEFAULT_RUNTIME_MODE,
22 type Project,
23 type RuntimeMode,
24 type Thread,
25} from "./types";
2126
2227// ── Actions ──────────────────────────────────────────────────────────
2328
......@@ -36,7 +41,8 @@ type Action =
3641 | { type: "PUSH_USER_MESSAGE"; threadId: string; id: string; text: string }
3742 | { type: "SET_ERROR"; threadId: string; error: string | null }
3843 | { type: "SET_THREAD_TITLE"; threadId: string; title: string }
39 | { type: "SET_THREAD_MODEL"; threadId: string; model: string };
44 | { type: "SET_THREAD_MODEL"; threadId: string; model: string }
45 | { type: "SET_RUNTIME_MODE"; mode: RuntimeMode };
4046
4147// ── State ────────────────────────────────────────────────────────────
4248
......@@ -44,16 +50,21 @@ export interface AppState {
4450 projects: Project[];
4551 threads: Thread[];
4652 activeThreadId: string | null;
53 runtimeMode: RuntimeMode;
4754 diffOpen: boolean;
4855}
4956
50const PERSISTED_STATE_KEY = "codething:renderer-state:v2";
51const LEGACY_PERSISTED_STATE_KEY = "codething:renderer-state:v1";
57const PERSISTED_STATE_KEY = "codething:renderer-state:v3";
58const LEGACY_PERSISTED_STATE_KEYS = [
59 "codething:renderer-state:v2",
60 "codething:renderer-state:v1",
61] as const;
5262
5363const initialState: AppState = {
5464 projects: [],
5565 threads: [],
5666 activeThreadId: null,
67 runtimeMode: DEFAULT_RUNTIME_MODE,
5768 diffOpen: false,
5869};
5970
......@@ -64,12 +75,14 @@ function readPersistedState(): AppState {
6475
6576 try {
6677 const rawCurrent = window.localStorage.getItem(PERSISTED_STATE_KEY);
67 const rawLegacy = window.localStorage.getItem(LEGACY_PERSISTED_STATE_KEY);
68 const raw = rawCurrent ?? rawLegacy;
78 const [legacyV2Key, legacyV1Key] = LEGACY_PERSISTED_STATE_KEYS;
79 const rawLegacyV2 = window.localStorage.getItem(legacyV2Key);
80 const rawLegacyV1 = window.localStorage.getItem(legacyV1Key);
81 const raw = rawCurrent ?? rawLegacyV2 ?? rawLegacyV1;
6982 if (!raw) return initialState;
7083 const hydrated = hydratePersistedState(
7184 raw,
72 !rawCurrent && Boolean(rawLegacy),
85 !rawCurrent && !rawLegacyV2 && Boolean(rawLegacyV1),
7386 );
7487 if (!hydrated) return initialState;
7588
......@@ -87,7 +100,9 @@ function persistState(state: AppState): void {
87100 PERSISTED_STATE_KEY,
88101 JSON.stringify(toPersistedState(state)),
89102 );
90 window.localStorage.removeItem(LEGACY_PERSISTED_STATE_KEY);
103 for (const legacyKey of LEGACY_PERSISTED_STATE_KEYS) {
104 window.localStorage.removeItem(legacyKey);
105 }
91106 } catch {
92107 // Ignore quota/storage errors to avoid breaking chat UX.
93108 }
......@@ -285,6 +300,12 @@ function reducer(state: AppState, action: Action): AppState {
285300 })),
286301 };
287302
303 case "SET_RUNTIME_MODE":
304 return {
305 ...state,
306 runtimeMode: action.mode,
307 };
308
288309 default:
289310 return state;
290311 }
apps/renderer/src/types.ts+2
......@@ -1,6 +1,8 @@
11import type { ProviderEvent, ProviderSession } from "@acme/contracts";
22
33export type SessionPhase = "disconnected" | "connecting" | "ready" | "running";
4export type RuntimeMode = "approval-required" | "full-access";
5export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access";
46
57export interface ChatMessage {
68 id: string;
packages/contracts/src/ipc.ts+3
......@@ -2,6 +2,7 @@ import type { AgentConfig, AgentExit, OutputChunk } from "./agent";
22import type {
33 ProviderEvent,
44 ProviderInterruptTurnInput,
5 ProviderRespondToRequestInput,
56 ProviderSendTurnInput,
67 ProviderSession,
78 ProviderSessionStartInput,
......@@ -28,6 +29,7 @@ export const IPC_CHANNELS = {
2829 providerTurnInterrupt: "provider:turn:interrupt",
2930 providerSessionStop: "provider:session:stop",
3031 providerSessionList: "provider:session:list",
32 providerRequestRespond: "provider:request:respond",
3133 providerEvent: "provider:event",
3234} as const;
3335
......@@ -59,6 +61,7 @@ export interface NativeApi {
5961 input: ProviderSendTurnInput,
6062 ) => Promise<ProviderTurnStartResult>;
6163 interruptTurn: (input: ProviderInterruptTurnInput) => Promise<void>;
64 respondToRequest: (input: ProviderRespondToRequestInput) => Promise<void>;
6265 stopSession: (input: ProviderStopSessionInput) => Promise<void>;
6366 listSessions: () => Promise<ProviderSession[]>;
6467 onEvent: (callback: (event: ProviderEvent) => void) => () => void;
packages/contracts/src/provider.test.ts+37
......@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
22
33import {
44 providerEventSchema,
5 providerRespondToRequestInputSchema,
56 providerSendTurnInputSchema,
67 providerSessionStartInputSchema,
78} from "./provider";
......@@ -45,4 +46,40 @@ describe("providerEventSchema", () => {
4546 });
4647 expect(parsed.method).toBe("item/agentMessage/delta");
4748 });
49
50 it("accepts request approval metadata", () => {
51 const parsed = providerEventSchema.parse({
52 id: "evt_2",
53 kind: "request",
54 provider: "codex",
55 sessionId: "sess_1",
56 createdAt: "2026-01-01T00:00:00.000Z",
57 method: "item/commandExecution/requestApproval",
58 requestId: "req_123",
59 requestKind: "command",
60 });
61 expect(parsed.requestId).toBe("req_123");
62 expect(parsed.requestKind).toBe("command");
63 });
64});
65
66describe("providerRespondToRequestInputSchema", () => {
67 it("accepts valid decisions", () => {
68 const parsed = providerRespondToRequestInputSchema.parse({
69 sessionId: "sess_1",
70 requestId: "req_1",
71 decision: "acceptForSession",
72 });
73 expect(parsed.decision).toBe("acceptForSession");
74 });
75
76 it("rejects unknown decisions", () => {
77 expect(() =>
78 providerRespondToRequestInputSchema.parse({
79 sessionId: "sess_1",
80 requestId: "req_1",
81 decision: "always",
82 }),
83 ).toThrow();
84 });
4885});
packages/contracts/src/provider.ts+24
......@@ -15,6 +15,15 @@ export const providerSandboxModeSchema = z.enum([
1515 "danger-full-access",
1616]);
1717
18export const providerRequestKindSchema = z.enum(["command", "file-change"]);
19
20export const providerApprovalDecisionSchema = z.enum([
21 "accept",
22 "acceptForSession",
23 "decline",
24 "cancel",
25]);
26
1827export const providerSessionStatusSchema = z.enum([
1928 "connecting",
2029 "ready",
......@@ -65,6 +74,12 @@ export const providerStopSessionInputSchema = z.object({
6574 sessionId: z.string().min(1),
6675});
6776
77export const providerRespondToRequestInputSchema = z.object({
78 sessionId: z.string().min(1),
79 requestId: z.string().min(1),
80 decision: providerApprovalDecisionSchema,
81});
82
6883export const providerEventKindSchema = z.enum([
6984 "session",
7085 "notification",
......@@ -83,6 +98,8 @@ export const providerEventSchema = z.object({
8398 threadId: z.string().min(1).optional(),
8499 turnId: z.string().min(1).optional(),
85100 itemId: z.string().min(1).optional(),
101 requestId: z.string().min(1).optional(),
102 requestKind: providerRequestKindSchema.optional(),
86103 textDelta: z.string().optional(),
87104 payload: z.unknown().optional(),
88105});
......@@ -92,6 +109,10 @@ export type ProviderApprovalPolicy = z.infer<
92109 typeof providerApprovalPolicySchema
93110>;
94111export type ProviderSandboxMode = z.infer<typeof providerSandboxModeSchema>;
112export type ProviderRequestKind = z.infer<typeof providerRequestKindSchema>;
113export type ProviderApprovalDecision = z.infer<
114 typeof providerApprovalDecisionSchema
115>;
95116export type ProviderSessionStatus = z.infer<typeof providerSessionStatusSchema>;
96117export type ProviderSession = z.infer<typeof providerSessionSchema>;
97118export type ProviderSessionStartInput = z.input<
......@@ -107,5 +128,8 @@ export type ProviderInterruptTurnInput = z.input<
107128export type ProviderStopSessionInput = z.input<
108129 typeof providerStopSessionInputSchema
109130>;
131export type ProviderRespondToRequestInput = z.input<
132 typeof providerRespondToRequestInputSchema
133>;
110134export type ProviderEventKind = z.infer<typeof providerEventKindSchema>;
111135export type ProviderEvent = z.infer<typeof providerEventSchema>;