| author | |
| committer | |
| log | 99a906753ec9c0f86eff6fe306062f0c2c3d15df |
| tree | 48d9a4af7013057cd6b3c8fe74139fc5a08a3d08 |
| parent | de5df2690de58406a480071eb2c7ba7e2fd4e3bd |
| signature | Commit is signed but in an unrecognized format. |
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: |
| 26 | 26 | - Renderer talks only to `window.nativeApi` exposed by preload. |
| 27 | 27 | - Preload and main both validate inputs using shared Zod schemas. |
| 28 | 28 | |
| 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 | ||
| 33 | CodeThing 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 | ||
| 38 | Mode changes apply across all threads. Existing live sessions are restarted so old and new threads use the selected mode. | |
| 39 | ||
| 29 | 40 | ## Scripts |
| 30 | 41 | |
| 31 | 42 | - `bun run dev`: starts contract build/watch, renderer dev server, and Electron process. |
| ... | ... | @@ -47,8 +58,9 @@ The renderer now depends on `nativeApi.providers.*`: |
| 47 | 58 | 1. `startSession` |
| 48 | 59 | 2. `sendTurn` |
| 49 | 60 | 3. `interruptTurn` |
| 50 | 4. `stopSession` | |
| 51 | 5. `listSessions` | |
| 52 | 6. `onEvent` | |
| 61 | 4. `respondToRequest` | |
| 62 | 5. `stopSession` | |
| 63 | 6. `listSessions` | |
| 64 | 7. `onEvent` | |
| 53 | 65 | |
| 54 | 66 | Codex 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 @@ |
| 1 | 1 | ## TODO |
| 2 | 2 | |
| 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"; |
| 4 | 4 | import readline from "node:readline"; |
| 5 | 5 | |
| 6 | 6 | import type { |
| 7 | ProviderApprovalDecision, | |
| 7 | 8 | ProviderEvent, |
| 9 | ProviderRequestKind, | |
| 8 | 10 | ProviderSendTurnInput, |
| 9 | 11 | ProviderSession, |
| 10 | 12 | ProviderSessionStartInput, |
| ... | ... | @@ -21,11 +23,24 @@ interface PendingRequest { |
| 21 | 23 | reject: (error: Error) => void; |
| 22 | 24 | } |
| 23 | 25 | |
| 26 | interface 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 | ||
| 24 | 38 | interface CodexSessionContext { |
| 25 | 39 | session: ProviderSession; |
| 26 | 40 | child: ChildProcessWithoutNullStreams; |
| 27 | 41 | output: readline.Interface; |
| 28 | 42 | pending: Map<PendingRequestKey, PendingRequest>; |
| 43 | pendingApprovals: Map<string, PendingApprovalRequest>; | |
| 29 | 44 | nextRequestId: number; |
| 30 | 45 | stopping: boolean; |
| 31 | 46 | } |
| ... | ... | @@ -137,6 +152,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 137 | 152 | child, |
| 138 | 153 | output, |
| 139 | 154 | pending: new Map(), |
| 155 | pendingApprovals: new Map(), | |
| 140 | 156 | nextRequestId: 1, |
| 141 | 157 | stopping: false, |
| 142 | 158 | }; |
| ... | ... | @@ -273,6 +289,45 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 273 | 289 | }); |
| 274 | 290 | } |
| 275 | 291 | |
| 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 | ||
| 276 | 331 | stopSession(sessionId: string): void { |
| 277 | 332 | const context = this.sessions.get(sessionId); |
| 278 | 333 | if (!context) { |
| ... | ... | @@ -286,6 +341,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 286 | 341 | pending.reject(new Error("Session stopped before request completed.")); |
| 287 | 342 | } |
| 288 | 343 | context.pending.clear(); |
| 344 | context.pendingApprovals.clear(); | |
| 289 | 345 | |
| 290 | 346 | context.output.close(); |
| 291 | 347 | |
| ... | ... | @@ -498,6 +554,25 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 498 | 554 | request: JsonRpcRequest, |
| 499 | 555 | ): void { |
| 500 | 556 | 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 | ||
| 501 | 576 | this.emitEvent({ |
| 502 | 577 | id: randomUUID(), |
| 503 | 578 | kind: "request", |
| ... | ... | @@ -508,22 +583,12 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 508 | 583 | threadId: route.threadId, |
| 509 | 584 | turnId: route.turnId, |
| 510 | 585 | itemId: route.itemId, |
| 586 | requestId, | |
| 587 | requestKind, | |
| 511 | 588 | payload: request.params, |
| 512 | 589 | }); |
| 513 | 590 | |
| 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) { | |
| 527 | 592 | return; |
| 528 | 593 | } |
| 529 | 594 | |
| ... | ... | @@ -656,6 +721,20 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 656 | 721 | }; |
| 657 | 722 | } |
| 658 | 723 | |
| 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 | ||
| 659 | 738 | private isServerRequest(value: unknown): value is JsonRpcRequest { |
| 660 | 739 | if (!value || typeof value !== "object") { |
| 661 | 740 | return false; |
apps/desktop/src/main.ts+11| ... | ... | @@ -13,6 +13,7 @@ import { |
| 13 | 13 | agentSessionIdSchema, |
| 14 | 14 | newTodoInputSchema, |
| 15 | 15 | providerInterruptTurnInputSchema, |
| 16 | providerRespondToRequestInputSchema, | |
| 16 | 17 | providerSendTurnInputSchema, |
| 17 | 18 | providerSessionStartInputSchema, |
| 18 | 19 | providerStopSessionInputSchema, |
| ... | ... | @@ -175,6 +176,16 @@ function registerIpcHandlers(): void { |
| 175 | 176 | ), |
| 176 | 177 | ); |
| 177 | 178 | |
| 179 | ipcMain.handle( | |
| 180 | IPC_CHANNELS.providerRequestRespond, | |
| 181 | withParsedPayload( | |
| 182 | providerRespondToRequestInputSchema, | |
| 183 | async (_event, payload) => { | |
| 184 | await providerManager.respondToRequest(payload); | |
| 185 | }, | |
| 186 | ), | |
| 187 | ); | |
| 188 | ||
| 178 | 189 | ipcMain.handle( |
| 179 | 190 | IPC_CHANNELS.providerSessionStop, |
| 180 | 191 | withParsedPayload( |
apps/desktop/src/preload.ts+2| ... | ... | @@ -41,6 +41,8 @@ const nativeApi: NativeApi = { |
| 41 | 41 | ipcRenderer.invoke(IPC_CHANNELS.providerTurnStart, input), |
| 42 | 42 | interruptTurn: (input) => |
| 43 | 43 | ipcRenderer.invoke(IPC_CHANNELS.providerTurnInterrupt, input), |
| 44 | respondToRequest: (input) => | |
| 45 | ipcRenderer.invoke(IPC_CHANNELS.providerRequestRespond, input), | |
| 44 | 46 | stopSession: (input) => |
| 45 | 47 | ipcRenderer.invoke(IPC_CHANNELS.providerSessionStop, input), |
| 46 | 48 | listSessions: () => ipcRenderer.invoke(IPC_CHANNELS.providerSessionList), |
apps/desktop/src/providerManager.test.ts+14| ... | ... | @@ -48,4 +48,18 @@ describe("ProviderManager", () => { |
| 48 | 48 | manager.dispose(); |
| 49 | 49 | expect(() => manager.dispose()).not.toThrow(); |
| 50 | 50 | }); |
| 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 | }); | |
| 51 | 65 | }); |
apps/desktop/src/providerManager.ts+15| ... | ... | @@ -5,12 +5,14 @@ import path from "node:path"; |
| 5 | 5 | import { |
| 6 | 6 | type ProviderEvent, |
| 7 | 7 | type ProviderInterruptTurnInput, |
| 8 | type ProviderRespondToRequestInput, | |
| 8 | 9 | type ProviderSendTurnInput, |
| 9 | 10 | type ProviderSession, |
| 10 | 11 | type ProviderSessionStartInput, |
| 11 | 12 | type ProviderStopSessionInput, |
| 12 | 13 | type ProviderTurnStartResult, |
| 13 | 14 | providerInterruptTurnInputSchema, |
| 15 | providerRespondToRequestInputSchema, | |
| 14 | 16 | providerSendTurnInputSchema, |
| 15 | 17 | providerSessionStartInputSchema, |
| 16 | 18 | providerStopSessionInputSchema, |
| ... | ... | @@ -79,6 +81,19 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> { |
| 79 | 81 | await this.codex.interruptTurn(input.sessionId, input.turnId); |
| 80 | 82 | } |
| 81 | 83 | |
| 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 | ||
| 82 | 97 | stopSession(raw: ProviderStopSessionInput): void { |
| 83 | 98 | const input = providerStopSessionInputSchema.parse(raw); |
| 84 | 99 | this.codex.stopSession(input.sessionId); |
apps/renderer/src/components/ChatView.tsx+190| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import type { ProviderApprovalDecision, ProviderEvent } from "@acme/contracts"; | |
| 1 | 2 | import { |
| 2 | 3 | type FormEvent, |
| 3 | 4 | Fragment, |
| ... | ... | @@ -39,6 +40,70 @@ function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { |
| 39 | 40 | return "text-[#606060]"; |
| 40 | 41 | } |
| 41 | 42 | |
| 43 | interface PendingApprovalCard { | |
| 44 | requestId: string; | |
| 45 | requestKind: "command" | "file-change"; | |
| 46 | createdAt: string; | |
| 47 | detail?: string; | |
| 48 | } | |
| 49 | ||
| 50 | function asRecord(value: unknown): Record<string, unknown> | undefined { | |
| 51 | if (!value || typeof value !== "object") return undefined; | |
| 52 | return value as Record<string, unknown>; | |
| 53 | } | |
| 54 | ||
| 55 | function asString(value: unknown): string | undefined { | |
| 56 | return typeof value === "string" ? value : undefined; | |
| 57 | } | |
| 58 | ||
| 59 | function 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 | ||
| 66 | function 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 | ||
| 42 | 107 | export default function ChatView() { |
| 43 | 108 | const { state, dispatch } = useStore(); |
| 44 | 109 | const api = useMemo(() => readNativeApi(), []); |
| ... | ... | @@ -48,6 +113,9 @@ export default function ChatView() { |
| 48 | 113 | const [isModelMenuOpen, setIsModelMenuOpen] = useState(false); |
| 49 | 114 | const [selectedEffort, setSelectedEffort] = |
| 50 | 115 | useState<string>(DEFAULT_REASONING); |
| 116 | const [respondingRequestIds, setRespondingRequestIds] = useState<string[]>( | |
| 117 | [], | |
| 118 | ); | |
| 51 | 119 | const [nowTick, setNowTick] = useState(() => Date.now()); |
| 52 | 120 | const messagesEndRef = useRef<HTMLDivElement>(null); |
| 53 | 121 | const textareaRef = useRef<HTMLTextAreaElement>(null); |
| ... | ... | @@ -68,6 +136,10 @@ export default function ChatView() { |
| 68 | 136 | () => deriveWorkLogEntries(activeThread?.events ?? [], undefined), |
| 69 | 137 | [activeThread?.events], |
| 70 | 138 | ); |
| 139 | const pendingApprovals = useMemo( | |
| 140 | () => derivePendingApprovals(activeThread?.events ?? []), | |
| 141 | [activeThread?.events], | |
| 142 | ); | |
| 71 | 143 | const assistantCompletionByItemId = useMemo(() => { |
| 72 | 144 | const map = new Map<string, string>(); |
| 73 | 145 | const ordered = [...(activeThread?.events ?? [])].reverse(); |
| ... | ... | @@ -127,6 +199,16 @@ export default function ChatView() { |
| 127 | 199 | timelineEntries, |
| 128 | 200 | workLogEntries.length, |
| 129 | 201 | ]); |
| 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); | |
| 130 | 212 | |
| 131 | 213 | // Auto-scroll on new messages |
| 132 | 214 | const messageCount = activeThread?.messages.length ?? 0; |
| ... | ... | @@ -191,6 +273,8 @@ export default function ChatView() { |
| 191 | 273 | provider: "codex", |
| 192 | 274 | cwd: activeProject.cwd || undefined, |
| 193 | 275 | model: selectedModel || undefined, |
| 276 | approvalPolicy: runtimeSessionConfig.approvalPolicy, | |
| 277 | sandboxMode: runtimeSessionConfig.sandboxMode, | |
| 194 | 278 | }); |
| 195 | 279 | dispatch({ |
| 196 | 280 | type: "UPDATE_SESSION", |
| ... | ... | @@ -270,6 +354,37 @@ export default function ChatView() { |
| 270 | 354 | }); |
| 271 | 355 | }; |
| 272 | 356 | |
| 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 | ||
| 273 | 388 | const onModelSelect = (model: string) => { |
| 274 | 389 | if (!activeThread) return; |
| 275 | 390 | dispatch({ |
| ... | ... | @@ -335,6 +450,81 @@ export default function ChatView() { |
| 335 | 450 | </div> |
| 336 | 451 | )} |
| 337 | 452 | |
| 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 | ||
| 338 | 528 | {/* Messages */} |
| 339 | 529 | <div className="flex-1 overflow-y-auto px-5 py-4"> |
| 340 | 530 | {activeThread.messages.length === 0 && !isWorking ? ( |
apps/renderer/src/components/Sidebar.tsx+75| ... | ... | @@ -28,6 +28,7 @@ export default function Sidebar() { |
| 28 | 28 | const [addingProject, setAddingProject] = useState(false); |
| 29 | 29 | const [newCwd, setNewCwd] = useState(""); |
| 30 | 30 | const [isPickingFolder, setIsPickingFolder] = useState(false); |
| 31 | const [isSwitchingRuntimeMode, setIsSwitchingRuntimeMode] = useState(false); | |
| 31 | 32 | |
| 32 | 33 | const handleAddProject = () => { |
| 33 | 34 | const cwd = newCwd.trim(); |
| ... | ... | @@ -79,6 +80,43 @@ export default function Sidebar() { |
| 79 | 80 | } |
| 80 | 81 | }; |
| 81 | 82 | |
| 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 | ||
| 82 | 120 | useEffect(() => { |
| 83 | 121 | const onWindowKeyDown = (event: KeyboardEvent) => { |
| 84 | 122 | const isNewThreadShortcut = |
| ... | ... | @@ -151,6 +189,43 @@ export default function Sidebar() { |
| 151 | 189 | </button> |
| 152 | 190 | </div> |
| 153 | 191 | |
| 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 | ||
| 154 | 229 | {/* Project list */} |
| 155 | 230 | <nav className="flex-1 overflow-y-auto px-2"> |
| 156 | 231 | {state.projects.map((project) => { |
apps/renderer/src/persistenceSchema.test.ts+27-2| ... | ... | @@ -47,6 +47,7 @@ describe("hydratePersistedState", () => { |
| 47 | 47 | expect(hydrated?.projects[0]?.model).toBe(DEFAULT_MODEL); |
| 48 | 48 | expect(hydrated?.threads[0]?.model).toBe(DEFAULT_MODEL); |
| 49 | 49 | expect(hydrated?.threads[0]?.messages[0]?.streaming).toBe(false); |
| 50 | expect(hydrated?.runtimeMode).toBe("full-access"); | |
| 50 | 51 | }); |
| 51 | 52 | |
| 52 | 53 | it("filters unknown project references and repairs active thread", () => { |
| ... | ... | @@ -85,11 +86,33 @@ describe("hydratePersistedState", () => { |
| 85 | 86 | expect(hydrated).not.toBeNull(); |
| 86 | 87 | expect(hydrated?.threads.map((thread) => thread.id)).toEqual(["t-1"]); |
| 87 | 88 | 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"); | |
| 88 | 111 | }); |
| 89 | 112 | }); |
| 90 | 113 | |
| 91 | 114 | describe("toPersistedState", () => { |
| 92 | it("writes v2 payload and strips non-persisted thread fields", () => { | |
| 115 | it("writes v3 payload and strips non-persisted thread fields", () => { | |
| 93 | 116 | const thread: Thread = { |
| 94 | 117 | id: "t-1", |
| 95 | 118 | projectId: "p-1", |
| ... | ... | @@ -122,9 +145,11 @@ describe("toPersistedState", () => { |
| 122 | 145 | ], |
| 123 | 146 | threads: [thread], |
| 124 | 147 | activeThreadId: "t-1", |
| 148 | runtimeMode: "full-access", | |
| 125 | 149 | }); |
| 126 | 150 | |
| 127 | expect(persisted.version).toBe(2); | |
| 151 | expect(persisted.version).toBe(3); | |
| 152 | expect(persisted.runtimeMode).toBe("full-access"); | |
| 128 | 153 | expect(persisted.threads[0]).toEqual({ |
| 129 | 154 | id: "t-1", |
| 130 | 155 | projectId: "p-1", |
apps/renderer/src/persistenceSchema.ts+22-3| ... | ... | @@ -1,7 +1,12 @@ |
| 1 | 1 | import { z } from "zod"; |
| 2 | 2 | |
| 3 | 3 | import { DEFAULT_MODEL, resolveModelSlug } from "./model-logic"; |
| 4 | import type { Project, Thread } from "./types"; | |
| 4 | import { | |
| 5 | DEFAULT_RUNTIME_MODE, | |
| 6 | type Project, | |
| 7 | type RuntimeMode, | |
| 8 | type Thread, | |
| 9 | } from "./types"; | |
| 5 | 10 | |
| 6 | 11 | const LEGACY_DEFAULT_MODEL = "gpt-5.2-codex"; |
| 7 | 12 | |
| ... | ... | @@ -36,6 +41,8 @@ const persistedStateBodySchema = z.object({ |
| 36 | 41 | activeThreadId: z.string().min(1).nullable(), |
| 37 | 42 | }); |
| 38 | 43 | |
| 44 | const runtimeModeSchema = z.enum(["approval-required", "full-access"]); | |
| 45 | ||
| 39 | 46 | export const persistedStateV1Schema = persistedStateBodySchema.extend({ |
| 40 | 47 | version: z.literal(1).optional(), |
| 41 | 48 | }); |
| ... | ... | @@ -44,7 +51,13 @@ export const persistedStateV2Schema = persistedStateBodySchema.extend({ |
| 44 | 51 | version: z.literal(2).optional(), |
| 45 | 52 | }); |
| 46 | 53 | |
| 54 | export const persistedStateV3Schema = persistedStateBodySchema.extend({ | |
| 55 | runtimeMode: runtimeModeSchema.default(DEFAULT_RUNTIME_MODE), | |
| 56 | version: z.literal(3).optional(), | |
| 57 | }); | |
| 58 | ||
| 47 | 59 | const persistedStateSchema = z.union([ |
| 60 | persistedStateV3Schema, | |
| 48 | 61 | persistedStateV2Schema, |
| 49 | 62 | persistedStateV1Schema, |
| 50 | 63 | ]); |
| ... | ... | @@ -53,6 +66,7 @@ export interface PersistedStoreSnapshot { |
| 53 | 66 | projects: Project[]; |
| 54 | 67 | threads: Thread[]; |
| 55 | 68 | activeThreadId: string | null; |
| 69 | runtimeMode: RuntimeMode; | |
| 56 | 70 | } |
| 57 | 71 | |
| 58 | 72 | function maybeMigrateLegacyModel( |
| ... | ... | @@ -134,14 +148,18 @@ export function hydratePersistedState( |
| 134 | 148 | activeThreadId: hasActiveThread |
| 135 | 149 | ? parsedState.data.activeThreadId |
| 136 | 150 | : (threads[0]?.id ?? null), |
| 151 | runtimeMode: | |
| 152 | "runtimeMode" in parsedState.data | |
| 153 | ? parsedState.data.runtimeMode | |
| 154 | : DEFAULT_RUNTIME_MODE, | |
| 137 | 155 | }; |
| 138 | 156 | } |
| 139 | 157 | |
| 140 | 158 | export function toPersistedState( |
| 141 | 159 | state: PersistedStoreSnapshot, |
| 142 | ): z.infer<typeof persistedStateV2Schema> { | |
| 160 | ): z.infer<typeof persistedStateV3Schema> { | |
| 143 | 161 | return { |
| 144 | version: 2, | |
| 162 | version: 3, | |
| 145 | 163 | projects: state.projects, |
| 146 | 164 | threads: state.threads.map((thread) => ({ |
| 147 | 165 | id: thread.id, |
| ... | ... | @@ -152,5 +170,6 @@ export function toPersistedState( |
| 152 | 170 | createdAt: thread.createdAt, |
| 153 | 171 | })), |
| 154 | 172 | activeThreadId: state.activeThreadId, |
| 173 | runtimeMode: state.runtimeMode, | |
| 155 | 174 | }; |
| 156 | 175 | } |
apps/renderer/src/store.ts+29-8| ... | ... | @@ -17,7 +17,12 @@ import { |
| 17 | 17 | asString, |
| 18 | 18 | evolveSession, |
| 19 | 19 | } from "./session-logic"; |
| 20 | import type { Project, Thread } from "./types"; | |
| 20 | import { | |
| 21 | DEFAULT_RUNTIME_MODE, | |
| 22 | type Project, | |
| 23 | type RuntimeMode, | |
| 24 | type Thread, | |
| 25 | } from "./types"; | |
| 21 | 26 | |
| 22 | 27 | // ── Actions ────────────────────────────────────────────────────────── |
| 23 | 28 | |
| ... | ... | @@ -36,7 +41,8 @@ type Action = |
| 36 | 41 | | { type: "PUSH_USER_MESSAGE"; threadId: string; id: string; text: string } |
| 37 | 42 | | { type: "SET_ERROR"; threadId: string; error: string | null } |
| 38 | 43 | | { 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 }; | |
| 40 | 46 | |
| 41 | 47 | // ── State ──────────────────────────────────────────────────────────── |
| 42 | 48 | |
| ... | ... | @@ -44,16 +50,21 @@ export interface AppState { |
| 44 | 50 | projects: Project[]; |
| 45 | 51 | threads: Thread[]; |
| 46 | 52 | activeThreadId: string | null; |
| 53 | runtimeMode: RuntimeMode; | |
| 47 | 54 | diffOpen: boolean; |
| 48 | 55 | } |
| 49 | 56 | |
| 50 | const PERSISTED_STATE_KEY = "codething:renderer-state:v2"; | |
| 51 | const LEGACY_PERSISTED_STATE_KEY = "codething:renderer-state:v1"; | |
| 57 | const PERSISTED_STATE_KEY = "codething:renderer-state:v3"; | |
| 58 | const LEGACY_PERSISTED_STATE_KEYS = [ | |
| 59 | "codething:renderer-state:v2", | |
| 60 | "codething:renderer-state:v1", | |
| 61 | ] as const; | |
| 52 | 62 | |
| 53 | 63 | const initialState: AppState = { |
| 54 | 64 | projects: [], |
| 55 | 65 | threads: [], |
| 56 | 66 | activeThreadId: null, |
| 67 | runtimeMode: DEFAULT_RUNTIME_MODE, | |
| 57 | 68 | diffOpen: false, |
| 58 | 69 | }; |
| 59 | 70 | |
| ... | ... | @@ -64,12 +75,14 @@ function readPersistedState(): AppState { |
| 64 | 75 | |
| 65 | 76 | try { |
| 66 | 77 | 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; | |
| 69 | 82 | if (!raw) return initialState; |
| 70 | 83 | const hydrated = hydratePersistedState( |
| 71 | 84 | raw, |
| 72 | !rawCurrent && Boolean(rawLegacy), | |
| 85 | !rawCurrent && !rawLegacyV2 && Boolean(rawLegacyV1), | |
| 73 | 86 | ); |
| 74 | 87 | if (!hydrated) return initialState; |
| 75 | 88 | |
| ... | ... | @@ -87,7 +100,9 @@ function persistState(state: AppState): void { |
| 87 | 100 | PERSISTED_STATE_KEY, |
| 88 | 101 | JSON.stringify(toPersistedState(state)), |
| 89 | 102 | ); |
| 90 | window.localStorage.removeItem(LEGACY_PERSISTED_STATE_KEY); | |
| 103 | for (const legacyKey of LEGACY_PERSISTED_STATE_KEYS) { | |
| 104 | window.localStorage.removeItem(legacyKey); | |
| 105 | } | |
| 91 | 106 | } catch { |
| 92 | 107 | // Ignore quota/storage errors to avoid breaking chat UX. |
| 93 | 108 | } |
| ... | ... | @@ -285,6 +300,12 @@ function reducer(state: AppState, action: Action): AppState { |
| 285 | 300 | })), |
| 286 | 301 | }; |
| 287 | 302 | |
| 303 | case "SET_RUNTIME_MODE": | |
| 304 | return { | |
| 305 | ...state, | |
| 306 | runtimeMode: action.mode, | |
| 307 | }; | |
| 308 | ||
| 288 | 309 | default: |
| 289 | 310 | return state; |
| 290 | 311 | } |
apps/renderer/src/types.ts+2| ... | ... | @@ -1,6 +1,8 @@ |
| 1 | 1 | import type { ProviderEvent, ProviderSession } from "@acme/contracts"; |
| 2 | 2 | |
| 3 | 3 | export type SessionPhase = "disconnected" | "connecting" | "ready" | "running"; |
| 4 | export type RuntimeMode = "approval-required" | "full-access"; | |
| 5 | export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; | |
| 4 | 6 | |
| 5 | 7 | export interface ChatMessage { |
| 6 | 8 | id: string; |
packages/contracts/src/ipc.ts+3| ... | ... | @@ -2,6 +2,7 @@ import type { AgentConfig, AgentExit, OutputChunk } from "./agent"; |
| 2 | 2 | import type { |
| 3 | 3 | ProviderEvent, |
| 4 | 4 | ProviderInterruptTurnInput, |
| 5 | ProviderRespondToRequestInput, | |
| 5 | 6 | ProviderSendTurnInput, |
| 6 | 7 | ProviderSession, |
| 7 | 8 | ProviderSessionStartInput, |
| ... | ... | @@ -28,6 +29,7 @@ export const IPC_CHANNELS = { |
| 28 | 29 | providerTurnInterrupt: "provider:turn:interrupt", |
| 29 | 30 | providerSessionStop: "provider:session:stop", |
| 30 | 31 | providerSessionList: "provider:session:list", |
| 32 | providerRequestRespond: "provider:request:respond", | |
| 31 | 33 | providerEvent: "provider:event", |
| 32 | 34 | } as const; |
| 33 | 35 | |
| ... | ... | @@ -59,6 +61,7 @@ export interface NativeApi { |
| 59 | 61 | input: ProviderSendTurnInput, |
| 60 | 62 | ) => Promise<ProviderTurnStartResult>; |
| 61 | 63 | interruptTurn: (input: ProviderInterruptTurnInput) => Promise<void>; |
| 64 | respondToRequest: (input: ProviderRespondToRequestInput) => Promise<void>; | |
| 62 | 65 | stopSession: (input: ProviderStopSessionInput) => Promise<void>; |
| 63 | 66 | listSessions: () => Promise<ProviderSession[]>; |
| 64 | 67 | onEvent: (callback: (event: ProviderEvent) => void) => () => void; |
packages/contracts/src/provider.test.ts+37| ... | ... | @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; |
| 2 | 2 | |
| 3 | 3 | import { |
| 4 | 4 | providerEventSchema, |
| 5 | providerRespondToRequestInputSchema, | |
| 5 | 6 | providerSendTurnInputSchema, |
| 6 | 7 | providerSessionStartInputSchema, |
| 7 | 8 | } from "./provider"; |
| ... | ... | @@ -45,4 +46,40 @@ describe("providerEventSchema", () => { |
| 45 | 46 | }); |
| 46 | 47 | expect(parsed.method).toBe("item/agentMessage/delta"); |
| 47 | 48 | }); |
| 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 | ||
| 66 | describe("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 | }); | |
| 48 | 85 | }); |
packages/contracts/src/provider.ts+24| ... | ... | @@ -15,6 +15,15 @@ export const providerSandboxModeSchema = z.enum([ |
| 15 | 15 | "danger-full-access", |
| 16 | 16 | ]); |
| 17 | 17 | |
| 18 | export const providerRequestKindSchema = z.enum(["command", "file-change"]); | |
| 19 | ||
| 20 | export const providerApprovalDecisionSchema = z.enum([ | |
| 21 | "accept", | |
| 22 | "acceptForSession", | |
| 23 | "decline", | |
| 24 | "cancel", | |
| 25 | ]); | |
| 26 | ||
| 18 | 27 | export const providerSessionStatusSchema = z.enum([ |
| 19 | 28 | "connecting", |
| 20 | 29 | "ready", |
| ... | ... | @@ -65,6 +74,12 @@ export const providerStopSessionInputSchema = z.object({ |
| 65 | 74 | sessionId: z.string().min(1), |
| 66 | 75 | }); |
| 67 | 76 | |
| 77 | export const providerRespondToRequestInputSchema = z.object({ | |
| 78 | sessionId: z.string().min(1), | |
| 79 | requestId: z.string().min(1), | |
| 80 | decision: providerApprovalDecisionSchema, | |
| 81 | }); | |
| 82 | ||
| 68 | 83 | export const providerEventKindSchema = z.enum([ |
| 69 | 84 | "session", |
| 70 | 85 | "notification", |
| ... | ... | @@ -83,6 +98,8 @@ export const providerEventSchema = z.object({ |
| 83 | 98 | threadId: z.string().min(1).optional(), |
| 84 | 99 | turnId: z.string().min(1).optional(), |
| 85 | 100 | itemId: z.string().min(1).optional(), |
| 101 | requestId: z.string().min(1).optional(), | |
| 102 | requestKind: providerRequestKindSchema.optional(), | |
| 86 | 103 | textDelta: z.string().optional(), |
| 87 | 104 | payload: z.unknown().optional(), |
| 88 | 105 | }); |
| ... | ... | @@ -92,6 +109,10 @@ export type ProviderApprovalPolicy = z.infer< |
| 92 | 109 | typeof providerApprovalPolicySchema |
| 93 | 110 | >; |
| 94 | 111 | export type ProviderSandboxMode = z.infer<typeof providerSandboxModeSchema>; |
| 112 | export type ProviderRequestKind = z.infer<typeof providerRequestKindSchema>; | |
| 113 | export type ProviderApprovalDecision = z.infer< | |
| 114 | typeof providerApprovalDecisionSchema | |
| 115 | >; | |
| 95 | 116 | export type ProviderSessionStatus = z.infer<typeof providerSessionStatusSchema>; |
| 96 | 117 | export type ProviderSession = z.infer<typeof providerSessionSchema>; |
| 97 | 118 | export type ProviderSessionStartInput = z.input< |
| ... | ... | @@ -107,5 +128,8 @@ export type ProviderInterruptTurnInput = z.input< |
| 107 | 128 | export type ProviderStopSessionInput = z.input< |
| 108 | 129 | typeof providerStopSessionInputSchema |
| 109 | 130 | >; |
| 131 | export type ProviderRespondToRequestInput = z.input< | |
| 132 | typeof providerRespondToRequestInputSchema | |
| 133 | >; | |
| 110 | 134 | export type ProviderEventKind = z.infer<typeof providerEventKindSchema>; |
| 111 | 135 | export type ProviderEvent = z.infer<typeof providerEventSchema>; |