authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-02-07 20:07:11-08:00
committergravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-02-07 20:07:11-08:00
logf733cb7e5f44344a1feb4dc82b18739548a9c9a4
tree29f70edd9e4e98f7c5a34406fff923cefdb24995
parent58261b9ba01e4c73f2ef0298b3d8b59f7af7c464
signature Commit is signed but in an unrecognized format.

codex working ish


11 files changed, 1742 insertions(+), 146 deletions(-)

README.md+24-16
...@@ -1,16 +1,22 @@...@@ -1,16 +1,22 @@
1# Electron TODO Monorepo (Turborepo + Bun)1# CodeThing (Electron + Vite + Bun)
22
3A long-lived Electron starter focused on:3CodeThing is a desktop shell for coding agents. This first implementation is:
44
51. Performance: Vite renderer, small typed IPC surface, serialized persistence writes.51. Codex-first: connects to `codex app-server` and streams turn/item events.
62. Maintainability: strict TypeScript, explicit package boundaries, isolated renderer.62. Provider-ready: renderer speaks a provider abstraction so Claude Code can plug in later.
73. Modern best practices: Bun workspaces, Turborepo task graph, shared contracts.73. Typed end-to-end: contracts validate payloads at preload/main boundaries.
88
9## Workspace layout9## Workspace layout
1010
11- `apps/desktop`: Electron main + preload process.11- `/apps/desktop`: Electron main + preload process, includes provider and Codex session managers.
12- `apps/renderer`: React + Vite + Tailwind renderer UI.12- `/apps/renderer`: React + Vite UI for session control, conversation, and protocol event stream.
13- `packages/contracts`: shared runtime-validated schemas and IPC/type contracts.13- `/packages/contracts`: shared Zod schemas + TypeScript types for IPC and provider events.
14
15## Codex prerequisites
16
17- Install Codex CLI so `codex` is on your PATH.
18- Authenticate Codex before running CodeThing (for example via API key or ChatGPT auth supported by Codex).
19- CodeThing starts the server via `codex app-server` per session.
1420
15## Security and boundary model21## Security and boundary model
1622
...@@ -25,18 +31,20 @@ A long-lived Electron starter focused on:...@@ -25,18 +31,20 @@ A long-lived Electron starter focused on:
25- `bun run dev`: starts contract build/watch, renderer dev server, and Electron process.31- `bun run dev`: starts contract build/watch, renderer dev server, and Electron process.
26- `bun run build`: builds contracts, renderer, and desktop bundles through Turbo.32- `bun run build`: builds contracts, renderer, and desktop bundles through Turbo.
27- `bun run typecheck`: strict TypeScript checks for all packages.33- `bun run typecheck`: strict TypeScript checks for all packages.
34- `bun run test`: runs workspace tests.
2835
29Optional:36Optional:
30- `ELECTRON_RENDERER_PORT=5180 bun run dev` if `5173` is already in use.37- `ELECTRON_RENDERER_PORT=5180 bun run dev` if `5173` is already in use.
3138
32## Notes for future native bindings39## Provider architecture
3340
34Add new main-process capabilities by extending `packages/contracts` first:41The renderer now depends on `nativeApi.providers.*`:
3542
361. Add schema + types for payload/result.431. `startSession`
372. Add an IPC channel constant.442. `sendTurn`
383. Implement `ipcMain.handle` in `apps/desktop/src/main.ts`.453. `interruptTurn`
394. Expose a typed preload wrapper in `apps/desktop/src/preload.ts`.464. `stopSession`
405. Consume only via `window.nativeApi` in renderer.475. `listSessions`
486. `onEvent`
4149
42This keeps native concerns out of React and makes refactors low risk.50Codex is the only implemented provider right now. `claudeCode` is reserved in contracts/UI but returns a not-implemented error in main-process dispatch.
apps/desktop/src/codexAppServerManager.ts created+690
...@@ -0,0 +1,690 @@
1import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
2import { randomUUID } from "node:crypto";
3import { EventEmitter } from "node:events";
4import readline from "node:readline";
5
6import type {
7 ProviderEvent,
8 ProviderSendTurnInput,
9 ProviderSession,
10 ProviderSessionStartInput,
11 ProviderTurnStartResult,
12} from "@acme/contracts";
13
14type PendingRequestKey = string;
15
16interface PendingRequest {
17 method: string;
18 timeout: ReturnType<typeof setTimeout>;
19 resolve: (value: unknown) => void;
20 reject: (error: Error) => void;
21}
22
23interface CodexSessionContext {
24 session: ProviderSession;
25 child: ChildProcessWithoutNullStreams;
26 output: readline.Interface;
27 pending: Map<PendingRequestKey, PendingRequest>;
28 nextRequestId: number;
29 stopping: boolean;
30}
31
32interface JsonRpcError {
33 code?: number;
34 message?: string;
35}
36
37interface JsonRpcRequest {
38 id: string | number;
39 method: string;
40 params?: unknown;
41}
42
43interface JsonRpcResponse {
44 id: string | number;
45 result?: unknown;
46 error?: JsonRpcError;
47}
48
49interface JsonRpcNotification {
50 method: string;
51 params?: unknown;
52}
53
54export interface CodexAppServerManagerEvents {
55 event: [event: ProviderEvent];
56}
57
58export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEvents> {
59 private readonly sessions = new Map<string, CodexSessionContext>();
60
61 async startSession(
62 input: ProviderSessionStartInput,
63 ): Promise<ProviderSession> {
64 const sessionId = randomUUID();
65 const now = new Date().toISOString();
66
67 const session: ProviderSession = {
68 sessionId,
69 provider: "codex",
70 status: "connecting",
71 model: input.model,
72 cwd: input.cwd,
73 createdAt: now,
74 updatedAt: now,
75 };
76
77 const child = spawn("codex", ["app-server"], {
78 cwd: input.cwd,
79 env: process.env,
80 stdio: ["pipe", "pipe", "pipe"],
81 });
82 const output = readline.createInterface({ input: child.stdout });
83
84 const context: CodexSessionContext = {
85 session,
86 child,
87 output,
88 pending: new Map(),
89 nextRequestId: 1,
90 stopping: false,
91 };
92
93 this.sessions.set(sessionId, context);
94 this.attachProcessListeners(context);
95
96 this.emitLifecycleEvent(
97 context,
98 "session/connecting",
99 "Starting codex app-server",
100 );
101
102 try {
103 await this.sendRequest(context, "initialize", {
104 clientInfo: {
105 name: "codething_desktop",
106 title: "CodeThing Desktop",
107 version: "0.1.0",
108 },
109 capabilities: {
110 experimentalApi: false,
111 },
112 });
113
114 this.writeMessage(context, { method: "initialized" });
115
116 const threadStart = await this.sendRequest(context, "thread/start", {
117 model: input.model ?? null,
118 cwd: input.cwd ?? null,
119 approvalPolicy: input.approvalPolicy,
120 sandbox: input.sandboxMode,
121 experimentalRawEvents: false,
122 });
123
124 const threadId = this.readString(
125 this.readObject(threadStart)?.thread,
126 "id",
127 );
128 if (!threadId) {
129 throw new Error("thread/start response did not include a thread id.");
130 }
131
132 this.updateSession(context, {
133 status: "ready",
134 threadId,
135 });
136 this.emitLifecycleEvent(
137 context,
138 "session/ready",
139 `Connected to thread ${threadId}`,
140 );
141 return { ...context.session };
142 } catch (error) {
143 const message =
144 error instanceof Error
145 ? error.message
146 : "Failed to start Codex session.";
147 this.updateSession(context, {
148 status: "error",
149 lastError: message,
150 });
151 this.emitErrorEvent(context, "session/startFailed", message);
152 this.stopSession(sessionId);
153 throw new Error(message);
154 }
155 }
156
157 async sendTurn(
158 input: ProviderSendTurnInput,
159 ): Promise<ProviderTurnStartResult> {
160 const context = this.requireSession(input.sessionId);
161 if (!context.session.threadId) {
162 throw new Error("Session is missing a thread id.");
163 }
164
165 const response = await this.sendRequest(context, "turn/start", {
166 threadId: context.session.threadId,
167 input: [
168 {
169 type: "text",
170 text: input.input,
171 text_elements: [],
172 },
173 ],
174 });
175
176 const turn = this.readObject(this.readObject(response), "turn");
177 const turnId = this.readString(turn, "id");
178 if (!turnId) {
179 throw new Error("turn/start response did not include a turn id.");
180 }
181
182 this.updateSession(context, {
183 status: "running",
184 activeTurnId: turnId,
185 });
186
187 return {
188 threadId: context.session.threadId,
189 turnId,
190 };
191 }
192
193 async interruptTurn(sessionId: string, turnId?: string): Promise<void> {
194 const context = this.requireSession(sessionId);
195 const effectiveTurnId = turnId ?? context.session.activeTurnId;
196
197 if (!effectiveTurnId || !context.session.threadId) {
198 return;
199 }
200
201 await this.sendRequest(context, "turn/interrupt", {
202 threadId: context.session.threadId,
203 turnId: effectiveTurnId,
204 });
205 }
206
207 stopSession(sessionId: string): void {
208 const context = this.sessions.get(sessionId);
209 if (!context) {
210 return;
211 }
212
213 context.stopping = true;
214
215 for (const pending of context.pending.values()) {
216 clearTimeout(pending.timeout);
217 pending.reject(new Error("Session stopped before request completed."));
218 }
219 context.pending.clear();
220
221 context.output.close();
222
223 if (!context.child.killed) {
224 context.child.kill();
225 }
226
227 this.updateSession(context, {
228 status: "closed",
229 activeTurnId: undefined,
230 });
231 this.emitLifecycleEvent(context, "session/closed", "Session stopped");
232 this.sessions.delete(sessionId);
233 }
234
235 listSessions(): ProviderSession[] {
236 return Array.from(this.sessions.values(), ({ session }) => ({
237 ...session,
238 }));
239 }
240
241 hasSession(sessionId: string): boolean {
242 return this.sessions.has(sessionId);
243 }
244
245 stopAll(): void {
246 for (const sessionId of this.sessions.keys()) {
247 this.stopSession(sessionId);
248 }
249 }
250
251 private requireSession(sessionId: string): CodexSessionContext {
252 const context = this.sessions.get(sessionId);
253 if (!context) {
254 throw new Error(`Unknown session: ${sessionId}`);
255 }
256
257 if (context.session.status === "closed") {
258 throw new Error(`Session is closed: ${sessionId}`);
259 }
260
261 return context;
262 }
263
264 private attachProcessListeners(context: CodexSessionContext): void {
265 context.output.on("line", (line) => {
266 this.handleStdoutLine(context, line);
267 });
268
269 context.child.stderr.on("data", (chunk: Buffer) => {
270 const message = chunk.toString().trim();
271 if (!message) {
272 return;
273 }
274
275 this.emitErrorEvent(context, "process/stderr", message);
276 });
277
278 context.child.on("error", (error) => {
279 const message = error.message || "codex app-server process errored.";
280 this.updateSession(context, {
281 status: "error",
282 lastError: message,
283 });
284 this.emitErrorEvent(context, "process/error", message);
285 });
286
287 context.child.on("exit", (code, signal) => {
288 if (context.stopping) {
289 return;
290 }
291
292 const message = `codex app-server exited (code=${code ?? "null"}, signal=${signal ?? "null"}).`;
293 this.updateSession(context, {
294 status: "closed",
295 activeTurnId: undefined,
296 lastError: code === 0 ? context.session.lastError : message,
297 });
298 this.emitLifecycleEvent(context, "session/exited", message);
299 this.sessions.delete(context.session.sessionId);
300 });
301 }
302
303 private handleStdoutLine(context: CodexSessionContext, line: string): void {
304 let parsed: unknown;
305 try {
306 parsed = JSON.parse(line);
307 } catch {
308 this.emitErrorEvent(
309 context,
310 "protocol/parseError",
311 "Received invalid JSON from codex app-server.",
312 );
313 return;
314 }
315
316 if (!parsed || typeof parsed !== "object") {
317 this.emitErrorEvent(
318 context,
319 "protocol/invalidMessage",
320 "Received non-object protocol message.",
321 );
322 return;
323 }
324
325 if (this.isServerRequest(parsed)) {
326 this.handleServerRequest(context, parsed);
327 return;
328 }
329
330 if (this.isServerNotification(parsed)) {
331 this.handleServerNotification(context, parsed);
332 return;
333 }
334
335 if (this.isResponse(parsed)) {
336 this.handleResponse(context, parsed);
337 return;
338 }
339
340 this.emitErrorEvent(
341 context,
342 "protocol/unrecognizedMessage",
343 "Received protocol message in an unknown shape.",
344 );
345 }
346
347 private handleServerNotification(
348 context: CodexSessionContext,
349 notification: JsonRpcNotification,
350 ): void {
351 const route = this.readRouteFields(notification.params);
352 const textDelta =
353 notification.method === "item/agentMessage/delta"
354 ? this.readString(notification.params, "delta")
355 : undefined;
356
357 this.emitEvent({
358 id: randomUUID(),
359 kind: "notification",
360 provider: "codex",
361 sessionId: context.session.sessionId,
362 createdAt: new Date().toISOString(),
363 method: notification.method,
364 threadId: route.threadId,
365 turnId: route.turnId,
366 itemId: route.itemId,
367 textDelta,
368 payload: notification.params,
369 });
370
371 if (notification.method === "thread/started") {
372 const threadId = this.readString(
373 this.readObject(notification.params)?.thread,
374 "id",
375 );
376 if (threadId) {
377 this.updateSession(context, { threadId });
378 }
379 return;
380 }
381
382 if (notification.method === "turn/started") {
383 const turnId = this.readString(
384 this.readObject(notification.params)?.turn,
385 "id",
386 );
387 this.updateSession(context, {
388 status: "running",
389 activeTurnId: turnId,
390 });
391 return;
392 }
393
394 if (notification.method === "turn/completed") {
395 const turn = this.readObject(notification.params, "turn");
396 const status = this.readString(turn, "status");
397 const errorMessage = this.readString(
398 this.readObject(turn, "error"),
399 "message",
400 );
401 this.updateSession(context, {
402 status: status === "failed" ? "error" : "ready",
403 activeTurnId: undefined,
404 lastError: errorMessage ?? context.session.lastError,
405 });
406 return;
407 }
408
409 if (notification.method === "error") {
410 const message = this.readString(
411 this.readObject(notification.params)?.error,
412 "message",
413 );
414 const willRetry = this.readBoolean(notification.params, "willRetry");
415
416 this.updateSession(context, {
417 status: willRetry ? "running" : "error",
418 lastError: message ?? context.session.lastError,
419 });
420 }
421 }
422
423 private handleServerRequest(
424 context: CodexSessionContext,
425 request: JsonRpcRequest,
426 ): void {
427 const route = this.readRouteFields(request.params);
428 this.emitEvent({
429 id: randomUUID(),
430 kind: "request",
431 provider: "codex",
432 sessionId: context.session.sessionId,
433 createdAt: new Date().toISOString(),
434 method: request.method,
435 threadId: route.threadId,
436 turnId: route.turnId,
437 itemId: route.itemId,
438 payload: request.params,
439 });
440
441 if (request.method === "item/commandExecution/requestApproval") {
442 this.writeMessage(context, {
443 id: request.id,
444 result: { decision: "decline" },
445 });
446 return;
447 }
448
449 if (request.method === "item/fileChange/requestApproval") {
450 this.writeMessage(context, {
451 id: request.id,
452 result: { decision: "decline" },
453 });
454 return;
455 }
456
457 if (request.method === "item/tool/requestUserInput") {
458 this.writeMessage(context, {
459 id: request.id,
460 result: { answers: {} },
461 });
462 return;
463 }
464
465 this.writeMessage(context, {
466 id: request.id,
467 error: {
468 code: -32601,
469 message: `Unsupported server request: ${request.method}`,
470 },
471 });
472 }
473
474 private handleResponse(
475 context: CodexSessionContext,
476 response: JsonRpcResponse,
477 ): void {
478 const key = String(response.id);
479 const pending = context.pending.get(key);
480 if (!pending) {
481 return;
482 }
483
484 clearTimeout(pending.timeout);
485 context.pending.delete(key);
486
487 if (response.error?.message) {
488 pending.reject(
489 new Error(
490 `${pending.method} failed: ${String(response.error.message)}`,
491 ),
492 );
493 return;
494 }
495
496 pending.resolve(response.result);
497 }
498
499 private async sendRequest<TResponse>(
500 context: CodexSessionContext,
501 method: string,
502 params: unknown,
503 timeoutMs = 20_000,
504 ): Promise<TResponse> {
505 const id = context.nextRequestId;
506 context.nextRequestId += 1;
507
508 const result = await new Promise<unknown>((resolve, reject) => {
509 const timeout = setTimeout(() => {
510 context.pending.delete(String(id));
511 reject(new Error(`Timed out waiting for ${method}.`));
512 }, timeoutMs);
513
514 context.pending.set(String(id), {
515 method,
516 timeout,
517 resolve,
518 reject,
519 });
520 this.writeMessage(context, {
521 method,
522 id,
523 params,
524 });
525 });
526
527 return result as TResponse;
528 }
529
530 private writeMessage(context: CodexSessionContext, message: unknown): void {
531 const encoded = JSON.stringify(message);
532 if (!context.child.stdin.writable) {
533 throw new Error("Cannot write to codex app-server stdin.");
534 }
535
536 context.child.stdin.write(`${encoded}\n`);
537 }
538
539 private emitLifecycleEvent(
540 context: CodexSessionContext,
541 method: string,
542 message: string,
543 ): void {
544 this.emitEvent({
545 id: randomUUID(),
546 kind: "session",
547 provider: "codex",
548 sessionId: context.session.sessionId,
549 createdAt: new Date().toISOString(),
550 method,
551 message,
552 });
553 }
554
555 private emitErrorEvent(
556 context: CodexSessionContext,
557 method: string,
558 message: string,
559 ): void {
560 this.emitEvent({
561 id: randomUUID(),
562 kind: "error",
563 provider: "codex",
564 sessionId: context.session.sessionId,
565 createdAt: new Date().toISOString(),
566 method,
567 message,
568 });
569 }
570
571 private emitEvent(event: ProviderEvent): void {
572 this.emit("event", event);
573 }
574
575 private updateSession(
576 context: CodexSessionContext,
577 updates: Partial<ProviderSession>,
578 ): void {
579 context.session = {
580 ...context.session,
581 ...updates,
582 updatedAt: new Date().toISOString(),
583 };
584 }
585
586 private isServerRequest(value: unknown): value is JsonRpcRequest {
587 if (!value || typeof value !== "object") {
588 return false;
589 }
590
591 const candidate = value as Record<string, unknown>;
592 return (
593 typeof candidate.method === "string" &&
594 (typeof candidate.id === "string" || typeof candidate.id === "number")
595 );
596 }
597
598 private isServerNotification(value: unknown): value is JsonRpcNotification {
599 if (!value || typeof value !== "object") {
600 return false;
601 }
602
603 const candidate = value as Record<string, unknown>;
604 return typeof candidate.method === "string" && !("id" in candidate);
605 }
606
607 private isResponse(value: unknown): value is JsonRpcResponse {
608 if (!value || typeof value !== "object") {
609 return false;
610 }
611
612 const candidate = value as Record<string, unknown>;
613 const hasId =
614 typeof candidate.id === "string" || typeof candidate.id === "number";
615 const hasMethod = typeof candidate.method === "string";
616 return hasId && !hasMethod;
617 }
618
619 private readRouteFields(params: unknown): {
620 threadId?: string;
621 turnId?: string;
622 itemId?: string;
623 } {
624 const route: {
625 threadId?: string;
626 turnId?: string;
627 itemId?: string;
628 } = {};
629
630 const threadId =
631 this.readString(params, "threadId") ??
632 this.readString(this.readObject(params, "thread"), "id");
633 const turnId =
634 this.readString(params, "turnId") ??
635 this.readString(this.readObject(params, "turn"), "id");
636 const itemId =
637 this.readString(params, "itemId") ??
638 this.readString(this.readObject(params, "item"), "id");
639
640 if (threadId) {
641 route.threadId = threadId;
642 }
643
644 if (turnId) {
645 route.turnId = turnId;
646 }
647
648 if (itemId) {
649 route.itemId = itemId;
650 }
651
652 return route;
653 }
654
655 private readObject(
656 value: unknown,
657 key?: string,
658 ): Record<string, unknown> | undefined {
659 const target =
660 key === undefined
661 ? value
662 : value && typeof value === "object"
663 ? (value as Record<string, unknown>)[key]
664 : undefined;
665
666 if (!target || typeof target !== "object") {
667 return undefined;
668 }
669
670 return target as Record<string, unknown>;
671 }
672
673 private readString(value: unknown, key: string): string | undefined {
674 if (!value || typeof value !== "object") {
675 return undefined;
676 }
677
678 const candidate = (value as Record<string, unknown>)[key];
679 return typeof candidate === "string" ? candidate : undefined;
680 }
681
682 private readBoolean(value: unknown, key: string): boolean | undefined {
683 if (!value || typeof value !== "object") {
684 return undefined;
685 }
686
687 const candidate = (value as Record<string, unknown>)[key];
688 return typeof candidate === "boolean" ? candidate : undefined;
689 }
690}
apps/desktop/src/main.ts+55-3
...@@ -1,8 +1,8 @@...@@ -1,8 +1,8 @@
1import { fixPath } from "./fixPath";1import { fixPath } from "./fixPath";
2fixPath();2fixPath();
33
4import path from "node:path";
5import { spawn } from "node:child_process";4import { spawn } from "node:child_process";
5import path from "node:path";
6import { BrowserWindow, app, ipcMain, session } from "electron";6import { BrowserWindow, app, ipcMain, session } from "electron";
77
8import {8import {
...@@ -16,12 +16,14 @@ import {...@@ -16,12 +16,14 @@ import {
16 todoIdSchema,16 todoIdSchema,
17} from "@acme/contracts";17} from "@acme/contracts";
18import { ProcessManager } from "./processManager";18import { ProcessManager } from "./processManager";
19import { ProviderManager } from "./providerManager";
19import { TodoStore } from "./todoStore";20import { TodoStore } from "./todoStore";
2021
21const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL);22const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL);
2223
23let todoStore: TodoStore;24let todoStore: TodoStore;
24const processManager = new ProcessManager();25const processManager = new ProcessManager();
26const providerManager = new ProviderManager();
2527
26function createWindow(): BrowserWindow {28function createWindow(): BrowserWindow {
27 const window = new BrowserWindow({29 const window = new BrowserWindow({
...@@ -94,6 +96,47 @@ function registerIpcHandlers(): void {...@@ -94,6 +96,47 @@ function registerIpcHandlers(): void {
94 processManager.write(agentSessionIdSchema.parse(sessionId), String(data));96 processManager.write(agentSessionIdSchema.parse(sessionId), String(data));
95 },97 },
96 );98 );
99
100 // Provider handlers
101 ipcMain.handle(
102 IPC_CHANNELS.providerSessionStart,
103 async (_event, payload: unknown) => {
104 return providerManager.startSession(
105 payload as Parameters<typeof providerManager.startSession>[0],
106 );
107 },
108 );
109
110 ipcMain.handle(
111 IPC_CHANNELS.providerTurnStart,
112 async (_event, payload: unknown) => {
113 return providerManager.sendTurn(
114 payload as Parameters<typeof providerManager.sendTurn>[0],
115 );
116 },
117 );
118
119 ipcMain.handle(
120 IPC_CHANNELS.providerTurnInterrupt,
121 async (_event, payload: unknown) => {
122 await providerManager.interruptTurn(
123 payload as Parameters<typeof providerManager.interruptTurn>[0],
124 );
125 },
126 );
127
128 ipcMain.handle(
129 IPC_CHANNELS.providerSessionStop,
130 async (_event, payload: unknown) => {
131 providerManager.stopSession(
132 payload as Parameters<typeof providerManager.stopSession>[0],
133 );
134 },
135 );
136
137 ipcMain.handle(IPC_CHANNELS.providerSessionList, async () => {
138 return providerManager.listSessions();
139 });
97}140}
98141
99async function runTerminalCommand(142async function runTerminalCommand(
...@@ -101,8 +144,8 @@ async function runTerminalCommand(...@@ -101,8 +144,8 @@ async function runTerminalCommand(
101): Promise<TerminalCommandResult> {144): Promise<TerminalCommandResult> {
102 const shellPath =145 const shellPath =
103 process.platform === "win32"146 process.platform === "win32"
104 ? process.env.ComSpec ?? "cmd.exe"147 ? (process.env.ComSpec ?? "cmd.exe")
105 : process.env.SHELL ?? "/bin/sh";148 : (process.env.SHELL ?? "/bin/sh");
106149
107 const args =150 const args =
108 process.platform === "win32"151 process.platform === "win32"
...@@ -169,12 +212,20 @@ function setupEventForwarding(window: BrowserWindow): void {...@@ -169,12 +212,20 @@ function setupEventForwarding(window: BrowserWindow): void {
169 }212 }
170 };213 };
171214
215 const onProviderEvent = (event: unknown) => {
216 if (!window.isDestroyed()) {
217 window.webContents.send(IPC_CHANNELS.providerEvent, event);
218 }
219 };
220
172 processManager.on("output", onOutput);221 processManager.on("output", onOutput);
173 processManager.on("exit", onExit);222 processManager.on("exit", onExit);
223 providerManager.on("event", onProviderEvent);
174224
175 window.on("closed", () => {225 window.on("closed", () => {
176 processManager.off("output", onOutput);226 processManager.off("output", onOutput);
177 processManager.off("exit", onExit);227 processManager.off("exit", onExit);
228 providerManager.off("event", onProviderEvent);
178 });229 });
179}230}
180231
...@@ -211,6 +262,7 @@ async function bootstrap(): Promise<void> {...@@ -211,6 +262,7 @@ async function bootstrap(): Promise<void> {
211262
212app.on("before-quit", () => {263app.on("before-quit", () => {
213 processManager.killAll();264 processManager.killAll();
265 providerManager.stopAll();
214});266});
215267
216app.whenReady().then(() => {268app.whenReady().then(() => {
apps/desktop/src/preload.ts+18
...@@ -31,6 +31,24 @@ const nativeApi: NativeApi = {...@@ -31,6 +31,24 @@ const nativeApi: NativeApi = {
31 return () => ipcRenderer.removeListener(IPC_CHANNELS.agentExit, listener);31 return () => ipcRenderer.removeListener(IPC_CHANNELS.agentExit, listener);
32 },32 },
33 },33 },
34 providers: {
35 startSession: (input) =>
36 ipcRenderer.invoke(IPC_CHANNELS.providerSessionStart, input),
37 sendTurn: (input) =>
38 ipcRenderer.invoke(IPC_CHANNELS.providerTurnStart, input),
39 interruptTurn: (input) =>
40 ipcRenderer.invoke(IPC_CHANNELS.providerTurnInterrupt, input),
41 stopSession: (input) =>
42 ipcRenderer.invoke(IPC_CHANNELS.providerSessionStop, input),
43 listSessions: () => ipcRenderer.invoke(IPC_CHANNELS.providerSessionList),
44 onEvent: (callback) => {
45 const listener = (_event: Electron.IpcRendererEvent, payload: unknown) =>
46 callback(payload as Parameters<typeof callback>[0]);
47 ipcRenderer.on(IPC_CHANNELS.providerEvent, listener);
48 return () =>
49 ipcRenderer.removeListener(IPC_CHANNELS.providerEvent, listener);
50 },
51 },
34};52};
3553
36contextBridge.exposeInMainWorld("nativeApi", nativeApi);54contextBridge.exposeInMainWorld("nativeApi", nativeApi);
apps/desktop/src/providerManager.ts created+69
...@@ -0,0 +1,69 @@
1import { EventEmitter } from "node:events";
2
3import {
4 type ProviderEvent,
5 type ProviderInterruptTurnInput,
6 type ProviderSendTurnInput,
7 type ProviderSession,
8 type ProviderSessionStartInput,
9 type ProviderStopSessionInput,
10 type ProviderTurnStartResult,
11 providerInterruptTurnInputSchema,
12 providerSendTurnInputSchema,
13 providerSessionStartInputSchema,
14 providerStopSessionInputSchema,
15} from "@acme/contracts";
16import { CodexAppServerManager } from "./codexAppServerManager";
17
18export interface ProviderManagerEvents {
19 event: [event: ProviderEvent];
20}
21
22export class ProviderManager extends EventEmitter<ProviderManagerEvents> {
23 private readonly codex = new CodexAppServerManager();
24
25 constructor() {
26 super();
27 this.codex.on("event", (event) => this.emit("event", event));
28 }
29
30 async startSession(raw: ProviderSessionStartInput): Promise<ProviderSession> {
31 const input = providerSessionStartInputSchema.parse(raw);
32 if (input.provider !== "codex") {
33 throw new Error(`Provider '${input.provider}' is not implemented yet.`);
34 }
35
36 return this.codex.startSession(input);
37 }
38
39 async sendTurn(raw: ProviderSendTurnInput): Promise<ProviderTurnStartResult> {
40 const input = providerSendTurnInputSchema.parse(raw);
41 if (!this.codex.hasSession(input.sessionId)) {
42 throw new Error(`Unknown provider session: ${input.sessionId}`);
43 }
44
45 return this.codex.sendTurn(input);
46 }
47
48 async interruptTurn(raw: ProviderInterruptTurnInput): Promise<void> {
49 const input = providerInterruptTurnInputSchema.parse(raw);
50 if (!this.codex.hasSession(input.sessionId)) {
51 throw new Error(`Unknown provider session: ${input.sessionId}`);
52 }
53
54 await this.codex.interruptTurn(input.sessionId, input.turnId);
55 }
56
57 stopSession(raw: ProviderStopSessionInput): void {
58 const input = providerStopSessionInputSchema.parse(raw);
59 this.codex.stopSession(input.sessionId);
60 }
61
62 listSessions(): ProviderSession[] {
63 return this.codex.listSessions();
64 }
65
66 stopAll(): void {
67 this.codex.stopAll();
68 }
69}
apps/renderer/src/App.tsx+552-121
...@@ -1,12 +1,55 @@...@@ -1,12 +1,55 @@
1import { type FormEvent, useMemo, useState } from "react";1import {
2 type FormEvent,
3 type MutableRefObject,
4 useEffect,
5 useMemo,
6 useRef,
7 useState,
8} from "react";
29
3import type { TerminalCommandResult } from "@acme/contracts";10import type {
11 ProviderEvent,
12 ProviderKind,
13 ProviderSession,
14} from "@acme/contracts";
415
5interface TerminalEntry {16type SessionPhase = "disconnected" | "connecting" | "ready" | "running";
17
18interface ChatMessage {
6 id: string;19 id: string;
7 command: string;20 role: "user" | "assistant";
8 result: TerminalCommandResult;21 text: string;
9 createdAt: string;22 createdAt: string;
23 streaming: boolean;
24}
25
26const PROVIDER_OPTIONS: Array<{
27 value: ProviderKind;
28 label: string;
29 available: boolean;
30}> = [
31 { value: "codex", label: "Codex", available: true },
32 { value: "claudeCode", label: "Claude Code (soon)", available: false },
33];
34
35function readNativeApi() {
36 if (typeof window === "undefined") {
37 return undefined;
38 }
39
40 return window.nativeApi;
41}
42
43function asObject(value: unknown): Record<string, unknown> | undefined {
44 if (!value || typeof value !== "object") {
45 return undefined;
46 }
47
48 return value as Record<string, unknown>;
49}
50
51function asString(value: unknown): string | undefined {
52 return typeof value === "string" ? value : undefined;
10}53}
1154
12function formatTimestamp(isoDate: string): string {55function formatTimestamp(isoDate: string): string {
...@@ -17,66 +60,364 @@ function formatTimestamp(isoDate: string): string {...@@ -17,66 +60,364 @@ function formatTimestamp(isoDate: string): string {
17 }).format(new Date(isoDate));60 }).format(new Date(isoDate));
18}61}
1962
20function readNativeApi() {63function derivePhase(session: ProviderSession | null): SessionPhase {
21 if (typeof window === "undefined") {64 if (!session || session.status === "closed") {
22 return undefined;65 return "disconnected";
23 }66 }
2467
25 return window.nativeApi;68 if (session.status === "connecting") {
69 return "connecting";
70 }
71
72 if (session.status === "running") {
73 return "running";
74 }
75
76 return "ready";
77}
78
79function evolveSession(
80 previous: ProviderSession,
81 event: ProviderEvent,
82): ProviderSession {
83 const payload = asObject(event.payload);
84
85 if (event.method === "thread/started") {
86 const thread = asObject(payload?.thread);
87 return {
88 ...previous,
89 threadId: asString(thread?.id) ?? event.threadId ?? previous.threadId,
90 updatedAt: event.createdAt,
91 };
92 }
93
94 if (event.method === "turn/started") {
95 const turn = asObject(payload?.turn);
96 return {
97 ...previous,
98 status: "running",
99 activeTurnId: asString(turn?.id) ?? event.turnId ?? previous.activeTurnId,
100 updatedAt: event.createdAt,
101 };
102 }
103
104 if (event.method === "turn/completed") {
105 const turn = asObject(payload?.turn);
106 const status = asString(turn?.status);
107 const turnError = asObject(turn?.error);
108 return {
109 ...previous,
110 status: status === "failed" ? "error" : "ready",
111 activeTurnId: undefined,
112 lastError: asString(turnError?.message) ?? previous.lastError,
113 updatedAt: event.createdAt,
114 };
115 }
116
117 if (event.kind === "error") {
118 return {
119 ...previous,
120 status: "error",
121 lastError: event.message ?? previous.lastError,
122 updatedAt: event.createdAt,
123 };
124 }
125
126 if (event.method === "session/closed" || event.method === "session/exited") {
127 return {
128 ...previous,
129 status: "closed",
130 activeTurnId: undefined,
131 lastError: event.message ?? previous.lastError,
132 updatedAt: event.createdAt,
133 };
134 }
135
136 return {
137 ...previous,
138 updatedAt: event.createdAt,
139 };
140}
141
142function applyEventToMessages(
143 previous: ChatMessage[],
144 event: ProviderEvent,
145 activeAssistantItemRef: MutableRefObject<string | null>,
146): ChatMessage[] {
147 const payload = asObject(event.payload);
148
149 if (event.method === "item/started") {
150 const item = asObject(payload?.item);
151 if (asString(item?.type) !== "agentMessage") {
152 return previous;
153 }
154
155 const itemId = asString(item?.id);
156 if (!itemId) {
157 return previous;
158 }
159
160 activeAssistantItemRef.current = itemId;
161 const seedText = asString(item?.text) ?? "";
162 const filtered = previous.filter((entry) => entry.id !== itemId);
163 return [
164 ...filtered,
165 {
166 id: itemId,
167 role: "assistant",
168 text: seedText,
169 createdAt: event.createdAt,
170 streaming: true,
171 },
172 ];
173 }
174
175 if (event.method === "item/agentMessage/delta") {
176 const itemId = event.itemId ?? asString(payload?.itemId);
177 const delta = event.textDelta ?? asString(payload?.delta) ?? "";
178 if (!itemId || !delta) {
179 return previous;
180 }
181
182 const existingIndex = previous.findIndex((entry) => entry.id === itemId);
183 if (existingIndex === -1) {
184 activeAssistantItemRef.current = itemId;
185 return [
186 ...previous,
187 {
188 id: itemId,
189 role: "assistant",
190 text: delta,
191 createdAt: event.createdAt,
192 streaming: true,
193 },
194 ];
195 }
196
197 const updated = [...previous];
198 const existing = updated[existingIndex];
199 if (!existing) {
200 return previous;
201 }
202 updated[existingIndex] = {
203 ...existing,
204 text: `${existing.text}${delta}`,
205 streaming: true,
206 };
207 return updated;
208 }
209
210 if (event.method === "item/completed") {
211 const item = asObject(payload?.item);
212 if (asString(item?.type) !== "agentMessage") {
213 return previous;
214 }
215
216 const itemId = asString(item?.id);
217 if (!itemId) {
218 return previous;
219 }
220
221 const fullText = asString(item?.text);
222 const existingIndex = previous.findIndex((entry) => entry.id === itemId);
223 if (existingIndex === -1) {
224 return [
225 ...previous,
226 {
227 id: itemId,
228 role: "assistant",
229 text: fullText ?? "",
230 createdAt: event.createdAt,
231 streaming: false,
232 },
233 ];
234 }
235
236 const updated = [...previous];
237 const existing = updated[existingIndex];
238 if (!existing) {
239 return previous;
240 }
241 updated[existingIndex] = {
242 ...existing,
243 text: fullText ?? existing.text,
244 streaming: false,
245 };
246
247 if (activeAssistantItemRef.current === itemId) {
248 activeAssistantItemRef.current = null;
249 }
250
251 return updated;
252 }
253
254 if (event.method === "turn/completed") {
255 return previous.map((entry) => ({ ...entry, streaming: false }));
256 }
257
258 return previous;
26}259}
27260
28export default function App() {261export default function App() {
29 const api = useMemo(() => readNativeApi(), []);262 const api = useMemo(() => readNativeApi(), []);
30 const [command, setCommand] = useState("");263 const [provider, setProvider] = useState<ProviderKind>("codex");
31 const [history, setHistory] = useState<TerminalEntry[]>([]);264 const [cwd, setCwd] = useState("");
32 const [isRunning, setIsRunning] = useState(false);265 const [model, setModel] = useState("gpt-5.1-codex");
266 const [prompt, setPrompt] = useState("");
267 const [session, setSession] = useState<ProviderSession | null>(null);
268 const [messages, setMessages] = useState<ChatMessage[]>([]);
269 const [events, setEvents] = useState<ProviderEvent[]>([]);
270 const [isConnecting, setIsConnecting] = useState(false);
271 const [isSending, setIsSending] = useState(false);
33 const [error, setError] = useState<string | null>(null);272 const [error, setError] = useState<string | null>(null);
34273
35 const onSubmit = async (event: FormEvent<HTMLFormElement>) => {274 const currentSessionIdRef = useRef<string | null>(null);
275 const activeAssistantItemRef = useRef<string | null>(null);
276
277 useEffect(() => {
278 currentSessionIdRef.current = session?.sessionId ?? null;
279 }, [session?.sessionId]);
280
281 useEffect(() => {
282 if (!api) {
283 return;
284 }
285
286 return api.providers.onEvent((event) => {
287 const currentSessionId = currentSessionIdRef.current;
288 if (!currentSessionId || currentSessionId !== event.sessionId) {
289 return;
290 }
291
292 setEvents((previous) => [event, ...previous].slice(0, 200));
293 setSession((previous) => {
294 if (!previous || previous.sessionId !== event.sessionId) {
295 return previous;
296 }
297 return evolveSession(previous, event);
298 });
299 setMessages((previous) =>
300 applyEventToMessages(previous, event, activeAssistantItemRef),
301 );
302
303 if (event.kind === "error" && event.message) {
304 setError(event.message);
305 }
306 });
307 }, [api]);
308
309 const phase = derivePhase(session);
310
311 const onConnect = async (event: FormEvent<HTMLFormElement>) => {
36 event.preventDefault();312 event.preventDefault();
313 if (!api || provider !== "codex" || isConnecting) {
314 return;
315 }
316
317 setError(null);
318 setIsConnecting(true);
319 try {
320 const nextSession = await api.providers.startSession({
321 provider,
322 cwd: cwd.trim() || undefined,
323 model: model.trim() || undefined,
324 });
325 currentSessionIdRef.current = nextSession.sessionId;
326 activeAssistantItemRef.current = null;
327 setSession(nextSession);
328 setMessages([]);
329 setEvents([]);
330 } catch (connectError) {
331 setError(
332 connectError instanceof Error
333 ? connectError.message
334 : "Unable to connect to Codex app-server.",
335 );
336 } finally {
337 setIsConnecting(false);
338 }
339 };
340
341 const onDisconnect = async () => {
342 if (!api || !session) {
343 return;
344 }
37345
38 if (!api || isRunning) {346 await api.providers.stopSession({ sessionId: session.sessionId });
347 currentSessionIdRef.current = null;
348 setSession((previous) =>
349 previous
350 ? {
351 ...previous,
352 status: "closed",
353 activeTurnId: undefined,
354 updatedAt: new Date().toISOString(),
355 }
356 : previous,
357 );
358 };
359
360 const onSendPrompt = async (event: FormEvent<HTMLFormElement>) => {
361 event.preventDefault();
362 if (!api || !session || isSending) {
39 return;363 return;
40 }364 }
41365
42 const trimmed = command.trim();366 const trimmed = prompt.trim();
43 if (!trimmed) {367 if (!trimmed) {
44 return;368 return;
45 }369 }
46370
47 setError(null);371 setError(null);
48 setIsRunning(true);372 setMessages((previous) => [
373 ...previous,
374 {
375 id: crypto.randomUUID(),
376 role: "user",
377 text: trimmed,
378 createdAt: new Date().toISOString(),
379 streaming: false,
380 },
381 ]);
382 setPrompt("");
383 setIsSending(true);
49384
50 try {385 try {
51 const result = await api.terminal.run({ command: trimmed });386 await api.providers.sendTurn({
52 setHistory((previous) => [387 sessionId: session.sessionId,
53 ...previous,388 input: trimmed,
54 {389 });
55 id: crypto.randomUUID(),390 } catch (sendError) {
56 command: trimmed,391 setError(
57 result,392 sendError instanceof Error
58 createdAt: new Date().toISOString(),393 ? sendError.message
59 },394 : "Failed to start a Codex turn.",
60 ]);395 );
61 setCommand("");
62 } catch (runError) {
63 const message =
64 runError instanceof Error
65 ? runError.message
66 : "Could not execute command.";
67 setError(message);
68 } finally {396 } finally {
69 setIsRunning(false);397 setIsSending(false);
398 }
399 };
400
401 const onInterrupt = async () => {
402 if (!api || !session) {
403 return;
70 }404 }
405
406 await api.providers.interruptTurn({
407 sessionId: session.sessionId,
408 turnId: session.activeTurnId,
409 });
71 };410 };
72411
73 if (!api) {412 if (!api) {
74 return (413 return (
75 <main className="mx-auto flex min-h-screen w-full max-w-4xl items-center justify-center px-8">414 <main className="mx-auto flex min-h-screen w-full max-w-3xl items-center justify-center px-6 py-10">
76 <section className="rounded-3xl border border-amber-200 bg-amber-50 px-6 py-5 text-amber-900 shadow-soft">415 <section className="surface-card w-full rounded-3xl p-8">
77 <h1 className="text-lg font-semibold">Native bridge unavailable</h1>416 <p className="label-chip">CodeThing</p>
78 <p className="mt-2 text-sm">417 <h1 className="mt-3 text-3xl">Native bridge unavailable</h1>
79 Launch this UI through Electron so the preload API is available.418 <p className="mt-3 text-sm text-amber-100/80">
419 Launch the renderer through Electron so `window.nativeApi` is
420 exposed by preload.
80 </p>421 </p>
81 </section>422 </section>
82 </main>423 </main>
...@@ -84,102 +425,192 @@ export default function App() {...@@ -84,102 +425,192 @@ export default function App() {
84 }425 }
85426
86 return (427 return (
87 <main className="mx-auto flex min-h-screen w-full max-w-5xl flex-col px-6 py-10 text-slate-900 sm:px-10">428 <main className="mx-auto min-h-screen w-full max-w-7xl px-4 py-6 text-[#f3f0e9] sm:px-8">
88 <header className="rounded-3xl border border-slate-800/80 bg-slate-900 p-7 text-slate-100 shadow-soft">429 <header className="surface-card relative overflow-hidden rounded-3xl px-6 py-7 sm:px-8">
89 <p className="text-xs uppercase tracking-[0.18em] text-emerald-300">430 <div className="grain absolute inset-0 pointer-events-none" />
90 Demo Terminal431 <p className="label-chip relative">CodeThing / Provider Shell</p>
91 </p>432 <h1 className="relative mt-3 text-4xl tracking-tight sm:text-5xl">
92 <h1 className="mt-2 font-mono text-3xl font-semibold tracking-tight">433 Codex-first workspace
93 Shell Command Runner
94 </h1>434 </h1>
95 <p className="mt-3 text-sm text-slate-300">435 <p className="relative mt-3 max-w-3xl text-sm text-[#efe8d7]/82 sm:text-base">
96 Runs one command at a time in your shell and prints stdout/stderr.436 Built on the Codex app-server protocol with a provider abstraction in
437 the IPC layer, so Claude Code can be added without reshaping the UI
438 contract.
97 </p>439 </p>
98440
99 <form className="mt-6 flex gap-3" onSubmit={onSubmit}>441 <form
100 <input442 className="relative mt-6 grid gap-3 rounded-2xl border border-[#f6a40f]/30 bg-[#0f2034]/65 p-4 sm:grid-cols-6"
101 value={command}443 onSubmit={onConnect}
102 onChange={(event) => setCommand(event.target.value)}444 >
103 placeholder="e.g. ls -la"445 <label className="sm:col-span-2">
104 className="h-11 flex-1 rounded-xl border border-emerald-500/50 bg-slate-950 px-4 font-mono text-sm text-emerald-100 outline-none ring-0 transition focus:border-emerald-300"446 <span className="field-label">Provider</span>
105 maxLength={4000}447 <select
106 />448 className="field-input"
107 <button449 value={provider}
108 type="submit"450 onChange={(event) =>
109 className="h-11 rounded-xl bg-emerald-500 px-5 text-sm font-semibold text-slate-950 transition hover:bg-emerald-400 disabled:cursor-not-allowed disabled:bg-emerald-800 disabled:text-emerald-300"451 setProvider(event.target.value as ProviderKind)
110 disabled={isRunning}452 }
111 >453 disabled={isConnecting || phase !== "disconnected"}
112 {isRunning ? "Running..." : "Run"}454 >
113 </button>455 {PROVIDER_OPTIONS.map((option) => (
114 <button456 <option
115 type="button"457 key={option.value}
116 className="h-11 rounded-xl border border-slate-600 px-5 text-sm font-medium text-slate-200 transition hover:border-slate-400 hover:text-white disabled:cursor-not-allowed disabled:opacity-50"458 value={option.value}
117 disabled={history.length === 0 || isRunning}459 disabled={!option.available}
118 onClick={() => setHistory([])}460 >
119 >461 {option.label}
120 Clear462 </option>
121 </button>463 ))}
464 </select>
465 </label>
466
467 <label className="sm:col-span-2">
468 <span className="field-label">Working directory</span>
469 <input
470 className="field-input font-mono text-xs sm:text-sm"
471 value={cwd}
472 onChange={(event) => setCwd(event.target.value)}
473 placeholder="/Users/theo/Code/Work/..."
474 disabled={isConnecting || phase !== "disconnected"}
475 />
476 </label>
477
478 <label className="sm:col-span-1">
479 <span className="field-label">Model</span>
480 <input
481 className="field-input font-mono text-xs sm:text-sm"
482 value={model}
483 onChange={(event) => setModel(event.target.value)}
484 placeholder="gpt-5.1-codex"
485 disabled={isConnecting || phase !== "disconnected"}
486 />
487 </label>
488
489 <div className="sm:col-span-1 flex items-end">
490 {phase === "disconnected" ? (
491 <button
492 type="submit"
493 className="action-button action-primary w-full"
494 disabled={provider !== "codex" || isConnecting}
495 >
496 {isConnecting ? "Connecting..." : "Connect"}
497 </button>
498 ) : (
499 <button
500 type="button"
501 className="action-button action-danger w-full"
502 onClick={() => void onDisconnect()}
503 >
504 Disconnect
505 </button>
506 )}
507 </div>
122 </form>508 </form>
509
510 <div className="relative mt-4 flex flex-wrap gap-2 text-xs">
511 <span className="status-pill">
512 phase={phase} provider={session?.provider ?? "none"}
513 </span>
514 <span className="status-pill">
515 thread={session?.threadId ?? "none"}
516 </span>
517 <span className="status-pill">
518 turn={session?.activeTurnId ?? "none"}
519 </span>
520 </div>
123 </header>521 </header>
124522
125 {error ? (523 {error ? (
126 <div className="mt-6 rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">524 <div className="mt-4 rounded-2xl border border-rose-300/35 bg-rose-900/35 px-4 py-3 text-sm text-rose-100">
127 {error}525 {error}
128 </div>526 </div>
129 ) : null}527 ) : null}
130528
131 <section className="mt-6 flex-1 space-y-4">529 <section className="mt-5 grid gap-4 lg:grid-cols-[2fr_1fr]">
132 {history.length === 0 ? (530 <article className="surface-card rounded-3xl p-4 sm:p-5">
133 <div className="rounded-2xl border border-dashed border-slate-300 bg-slate-50 px-4 py-6 text-sm text-slate-500">531 <h2 className="text-xl">Conversation</h2>
134 No commands run yet.532 <div className="mt-4 max-h-[52vh] space-y-3 overflow-y-auto pr-1">
533 {messages.length === 0 ? (
534 <div className="rounded-2xl border border-dashed border-[#f6d79e]/30 bg-[#0d1827]/70 px-4 py-5 text-sm text-[#d8d0c3]/75">
535 No turns yet. Start a session, then send a prompt.
536 </div>
537 ) : null}
538
539 {messages.map((entry) => (
540 <div
541 key={entry.id}
542 className={`message-card ${
543 entry.role === "user" ? "message-user" : "message-assistant"
544 }`}
545 >
546 <header className="mb-2 flex items-center justify-between text-[11px] uppercase tracking-[0.12em] text-[#d8d0c3]/68">
547 <span>{entry.role === "user" ? "you" : "codex"}</span>
548 <span>{formatTimestamp(entry.createdAt)}</span>
549 </header>
550 <pre className="whitespace-pre-wrap break-words font-mono text-sm leading-6">
551 {entry.text || (entry.streaming ? "..." : "(empty response)")}
552 </pre>
553 </div>
554 ))}
135 </div>555 </div>
136 ) : null}556
137557 <form className="mt-4 grid gap-3" onSubmit={onSendPrompt}>
138 {history.map((entry) => (558 <textarea
139 <article559 className="field-input min-h-28 resize-y font-mono text-sm leading-6"
140 key={entry.id}560 value={prompt}
141 className="overflow-hidden rounded-2xl border border-slate-700 bg-slate-950 shadow-soft"561 onChange={(event) => setPrompt(event.target.value)}
142 >562 placeholder="Ask Codex to inspect, plan, or edit this project..."
143 <header className="flex items-center justify-between border-b border-slate-800 px-4 py-3 font-mono text-xs text-slate-300">563 disabled={phase === "disconnected" || isSending}
144 <p>$ {entry.command}</p>564 />
145 <p>{formatTimestamp(entry.createdAt)}</p>565 <div className="flex gap-2">
146 </header>566 <button
147567 type="submit"
148 <div className="space-y-3 px-4 py-4 font-mono text-sm">568 className="action-button action-primary"
149 {entry.result.stdout ? (569 disabled={phase === "disconnected" || isSending}
150 <div>570 >
151 <p className="mb-1 text-xs uppercase tracking-wide text-emerald-300">571 {isSending ? "Starting turn..." : "Send"}
152 stdout572 </button>
153 </p>573 <button
154 <pre className="whitespace-pre-wrap break-words text-emerald-100">574 type="button"
155 {entry.result.stdout}575 className="action-button action-muted"
156 </pre>576 disabled={phase !== "running"}
157 </div>577 onClick={() => void onInterrupt()}
158 ) : null}578 >
159579 Interrupt
160 {entry.result.stderr ? (580 </button>
161 <div>
162 <p className="mb-1 text-xs uppercase tracking-wide text-rose-300">
163 stderr
164 </p>
165 <pre className="whitespace-pre-wrap break-words text-rose-200">
166 {entry.result.stderr}
167 </pre>
168 </div>
169 ) : null}
170
171 {!entry.result.stdout && !entry.result.stderr ? (
172 <p className="text-slate-400">(no output)</p>
173 ) : null}
174 </div>581 </div>
582 </form>
583 </article>
175584
176 <footer className="border-t border-slate-800 bg-slate-900/60 px-4 py-2 font-mono text-xs text-slate-400">585 <aside className="surface-card rounded-3xl p-4 sm:p-5">
177 exit={entry.result.code ?? "null"} signal=586 <h2 className="text-xl">Protocol stream</h2>
178 {entry.result.signal ?? "null"} timedOut=587 <p className="mt-2 text-xs text-[#d8d0c3]/76">
179 {entry.result.timedOut ? "yes" : "no"}588 Live notifications from the app-server (latest first).
180 </footer>589 </p>
181 </article>590 <div className="mt-4 max-h-[58vh] space-y-2 overflow-y-auto pr-1">
182 ))}591 {events.length === 0 ? (
592 <p className="rounded-xl border border-dashed border-[#f6d79e]/30 bg-[#0d1827]/70 px-3 py-4 text-xs text-[#d8d0c3]/76">
593 Waiting for events...
594 </p>
595 ) : null}
596 {events.map((entry) => (
597 <div key={entry.id} className="event-card">
598 <p className="font-mono text-[11px] uppercase tracking-[0.12em] text-[#f8ca64]">
599 {entry.method}
600 </p>
601 {entry.message ? (
602 <p className="mt-1 text-xs text-[#efe8d7]/88">
603 {entry.message}
604 </p>
605 ) : null}
606 <p className="mt-2 text-[11px] text-[#d8d0c3]/66">
607 {formatTimestamp(entry.createdAt)}
608 {entry.turnId ? ` · ${entry.turnId}` : ""}
609 </p>
610 </div>
611 ))}
612 </div>
613 </aside>
183 </section>614 </section>
184 </main>615 </main>
185 );616 );
apps/renderer/src/index.css+153-6
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1@import url("https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400;9..144,600;9..144,700&family=IBM+Plex+Mono:wght@400;500;600&display=swap");
1@import "tailwindcss";2@import "tailwindcss";
23
3@theme {4@theme {
...@@ -5,14 +6,160 @@...@@ -5,14 +6,160 @@
5}6}
67
7:root {8:root {
8 color-scheme: light;9 color-scheme: dark;
9}10}
1011
11body {12body {
12 font-family: "Iowan Old Style", "Georgia", serif;13 font-family: "Fraunces", "Iowan Old Style", serif;
13 min-height: 100vh;14 min-height: 100vh;
14 background:15 color: #f3f0e9;
15 radial-gradient(circle at top left, #d1fae5 0%, transparent 38%),16 background: radial-gradient(circle at 8% 3%, #e69f15 0%, transparent 33%),
16 radial-gradient(circle at 88% 20%, #fef3c7 0%, transparent 28%),17 radial-gradient(circle at 92% 6%, #2d8ba8 0%, transparent 30%),
17 linear-gradient(130deg, #f8fafc 0%, #eef6f2 50%, #f8fafc 100%);18 linear-gradient(160deg, #050913 0%, #091320 42%, #090f1a 100%);
19}
20
21pre,
22code,
23textarea,
24input,
25select {
26 font-family: "IBM Plex Mono", "SFMono-Regular", Consolas, monospace;
27}
28
29.surface-card {
30 border: 1px solid rgba(246, 215, 158, 0.24);
31 background: linear-gradient(
32 140deg,
33 rgba(11, 24, 40, 0.9),
34 rgba(9, 16, 29, 0.84)
35 ), radial-gradient(circle at top, rgba(245, 166, 35, 0.15), transparent 45%);
36 box-shadow: 0 28px 70px -45px rgba(0, 0, 0, 0.85), inset 0 1px 0
37 rgba(255, 255, 255, 0.08);
38}
39
40.grain {
41 opacity: 0.18;
42 mix-blend-mode: soft-light;
43 background-image: linear-gradient(
44 45deg,
45 rgba(255, 255, 255, 0.04) 25%,
46 transparent 25%,
47 transparent 50%,
48 rgba(255, 255, 255, 0.04) 50%,
49 rgba(255, 255, 255, 0.04) 75%,
50 transparent 75%,
51 transparent
52 );
53 background-size: 4px 4px;
54}
55
56.label-chip {
57 display: inline-flex;
58 border-radius: 9999px;
59 border: 1px solid rgba(246, 215, 158, 0.44);
60 background: rgba(23, 38, 56, 0.55);
61 padding: 0.28rem 0.72rem;
62 font-size: 0.67rem;
63 letter-spacing: 0.14em;
64 text-transform: uppercase;
65 color: #f8ca64;
66}
67
68.field-label {
69 display: block;
70 margin-bottom: 0.32rem;
71 font-size: 0.67rem;
72 letter-spacing: 0.14em;
73 text-transform: uppercase;
74 color: rgba(246, 233, 201, 0.82);
75}
76
77.field-input {
78 width: 100%;
79 border-radius: 0.8rem;
80 border: 1px solid rgba(246, 215, 158, 0.3);
81 background: rgba(5, 12, 22, 0.85);
82 padding: 0.65rem 0.75rem;
83 color: #f7f1e6;
84 transition: border-color 140ms ease, box-shadow 140ms ease;
85}
86
87.field-input:focus {
88 outline: none;
89 border-color: rgba(250, 206, 99, 0.75);
90 box-shadow: 0 0 0 2px rgba(250, 206, 99, 0.18);
91}
92
93.action-button {
94 border-radius: 0.8rem;
95 padding: 0.62rem 0.9rem;
96 font-size: 0.8rem;
97 font-weight: 600;
98 letter-spacing: 0.08em;
99 text-transform: uppercase;
100 transition: filter 140ms ease, transform 140ms ease;
101}
102
103.action-button:disabled {
104 cursor: not-allowed;
105 opacity: 0.45;
106}
107
108.action-button:not(:disabled):hover {
109 filter: brightness(1.06);
110 transform: translateY(-1px);
111}
112
113.action-primary {
114 border: 1px solid rgba(250, 206, 99, 0.6);
115 background: linear-gradient(140deg, #f8c861, #e59f0d);
116 color: #1f2530;
117}
118
119.action-danger {
120 border: 1px solid rgba(252, 148, 124, 0.44);
121 background: linear-gradient(140deg, #8f2c2b, #bf4738);
122 color: #fff3ef;
123}
124
125.action-muted {
126 border: 1px solid rgba(149, 181, 205, 0.4);
127 background: rgba(13, 31, 49, 0.85);
128 color: #dfecfb;
129}
130
131.status-pill {
132 border-radius: 9999px;
133 border: 1px solid rgba(246, 215, 158, 0.29);
134 background: rgba(9, 20, 33, 0.9);
135 padding: 0.27rem 0.64rem;
136 font-family: "IBM Plex Mono", "SFMono-Regular", Consolas, monospace;
137 color: rgba(248, 235, 206, 0.86);
138}
139
140.message-card {
141 border-radius: 1rem;
142 border: 1px solid rgba(246, 215, 158, 0.25);
143 background: rgba(8, 16, 27, 0.82);
144 padding: 0.9rem;
145}
146
147.message-user {
148 border-color: rgba(111, 198, 238, 0.38);
149 background: linear-gradient(
150 160deg,
151 rgba(14, 44, 64, 0.92),
152 rgba(8, 21, 36, 0.92)
153 );
154}
155
156.message-assistant {
157 border-color: rgba(250, 206, 99, 0.34);
158}
159
160.event-card {
161 border-radius: 0.85rem;
162 border: 1px solid rgba(246, 215, 158, 0.2);
163 background: rgba(8, 16, 27, 0.76);
164 padding: 0.62rem 0.72rem;
18}165}
packages/contracts/src/index.ts+1
...@@ -2,3 +2,4 @@ export * from "./todo";...@@ -2,3 +2,4 @@ export * from "./todo";
2export * from "./ipc";2export * from "./ipc";
3export * from "./agent";3export * from "./agent";
4export * from "./terminal";4export * from "./terminal";
5export * from "./provider";
packages/contracts/src/ipc.ts+27
...@@ -1,4 +1,13 @@...@@ -1,4 +1,13 @@
1import type { AgentConfig, AgentExit, OutputChunk } from "./agent";1import type { AgentConfig, AgentExit, OutputChunk } from "./agent";
2import type {
3 ProviderEvent,
4 ProviderInterruptTurnInput,
5 ProviderSendTurnInput,
6 ProviderSession,
7 ProviderSessionStartInput,
8 ProviderStopSessionInput,
9 ProviderTurnStartResult,
10} from "./provider";
2import type { TerminalCommandInput, TerminalCommandResult } from "./terminal";11import type { TerminalCommandInput, TerminalCommandResult } from "./terminal";
3import type { NewTodoInput, Todo } from "./todo";12import type { NewTodoInput, Todo } from "./todo";
413
...@@ -13,6 +22,12 @@ export const IPC_CHANNELS = {...@@ -13,6 +22,12 @@ export const IPC_CHANNELS = {
13 agentWrite: "agent:write",22 agentWrite: "agent:write",
14 agentOutput: "agent:output",23 agentOutput: "agent:output",
15 agentExit: "agent:exit",24 agentExit: "agent:exit",
25 providerSessionStart: "provider:session:start",
26 providerTurnStart: "provider:turn:start",
27 providerTurnInterrupt: "provider:turn:interrupt",
28 providerSessionStop: "provider:session:stop",
29 providerSessionList: "provider:session:list",
30 providerEvent: "provider:event",
16} as const;31} as const;
1732
18export interface NativeApi {33export interface NativeApi {
...@@ -32,4 +47,16 @@ export interface NativeApi {...@@ -32,4 +47,16 @@ export interface NativeApi {
32 onOutput: (callback: (chunk: OutputChunk) => void) => () => void;47 onOutput: (callback: (chunk: OutputChunk) => void) => () => void;
33 onExit: (callback: (exit: AgentExit) => void) => () => void;48 onExit: (callback: (exit: AgentExit) => void) => () => void;
34 };49 };
50 providers: {
51 startSession: (
52 input: ProviderSessionStartInput,
53 ) => Promise<ProviderSession>;
54 sendTurn: (
55 input: ProviderSendTurnInput,
56 ) => Promise<ProviderTurnStartResult>;
57 interruptTurn: (input: ProviderInterruptTurnInput) => Promise<void>;
58 stopSession: (input: ProviderStopSessionInput) => Promise<void>;
59 listSessions: () => Promise<ProviderSession[]>;
60 onEvent: (callback: (event: ProviderEvent) => void) => () => void;
61 };
35}62}
packages/contracts/src/provider.test.ts created+44
...@@ -0,0 +1,44 @@
1import { describe, expect, it } from "vitest";
2
3import {
4 providerEventSchema,
5 providerSendTurnInputSchema,
6 providerSessionStartInputSchema,
7} from "./provider";
8
9describe("providerSessionStartInputSchema", () => {
10 it("defaults to codex with safe policies", () => {
11 const parsed = providerSessionStartInputSchema.parse({});
12 expect(parsed.provider).toBe("codex");
13 expect(parsed.approvalPolicy).toBe("never");
14 expect(parsed.sandboxMode).toBe("workspace-write");
15 });
16});
17
18describe("providerSendTurnInputSchema", () => {
19 it("trims input text", () => {
20 const parsed = providerSendTurnInputSchema.parse({
21 sessionId: "sess_1",
22 input: " summarize this repo ",
23 });
24 expect(parsed.input).toBe("summarize this repo");
25 });
26});
27
28describe("providerEventSchema", () => {
29 it("accepts notification events with routing metadata", () => {
30 const parsed = providerEventSchema.parse({
31 id: "evt_1",
32 kind: "notification",
33 provider: "codex",
34 sessionId: "sess_1",
35 createdAt: "2026-01-01T00:00:00.000Z",
36 method: "item/agentMessage/delta",
37 threadId: "thr_1",
38 turnId: "turn_1",
39 itemId: "item_1",
40 textDelta: "hi",
41 });
42 expect(parsed.method).toBe("item/agentMessage/delta");
43 });
44});
packages/contracts/src/provider.ts created+109
...@@ -0,0 +1,109 @@
1import { z } from "zod";
2
3export const providerKindSchema = z.enum(["codex", "claudeCode"]);
4
5export const providerApprovalPolicySchema = z.enum([
6 "untrusted",
7 "on-failure",
8 "on-request",
9 "never",
10]);
11
12export const providerSandboxModeSchema = z.enum([
13 "read-only",
14 "workspace-write",
15 "danger-full-access",
16]);
17
18export const providerSessionStatusSchema = z.enum([
19 "connecting",
20 "ready",
21 "running",
22 "error",
23 "closed",
24]);
25
26export const providerSessionSchema = z.object({
27 sessionId: z.string().min(1),
28 provider: providerKindSchema,
29 status: providerSessionStatusSchema,
30 cwd: z.string().min(1).optional(),
31 model: z.string().min(1).optional(),
32 threadId: z.string().min(1).optional(),
33 activeTurnId: z.string().min(1).optional(),
34 createdAt: z.string().datetime(),
35 updatedAt: z.string().datetime(),
36 lastError: z.string().min(1).optional(),
37});
38
39export const providerSessionStartInputSchema = z.object({
40 provider: providerKindSchema.default("codex"),
41 cwd: z.string().min(1).optional(),
42 model: z.string().trim().min(1).optional(),
43 approvalPolicy: providerApprovalPolicySchema.default("never"),
44 sandboxMode: providerSandboxModeSchema.default("workspace-write"),
45});
46
47export const providerSendTurnInputSchema = z.object({
48 sessionId: z.string().min(1),
49 input: z.string().trim().min(1).max(120_000),
50});
51
52export const providerTurnStartResultSchema = z.object({
53 threadId: z.string().min(1),
54 turnId: z.string().min(1),
55});
56
57export const providerInterruptTurnInputSchema = z.object({
58 sessionId: z.string().min(1),
59 turnId: z.string().min(1).optional(),
60});
61
62export const providerStopSessionInputSchema = z.object({
63 sessionId: z.string().min(1),
64});
65
66export const providerEventKindSchema = z.enum([
67 "session",
68 "notification",
69 "request",
70 "error",
71]);
72
73export const providerEventSchema = z.object({
74 id: z.string().min(1),
75 kind: providerEventKindSchema,
76 provider: providerKindSchema,
77 sessionId: z.string().min(1),
78 createdAt: z.string().datetime(),
79 method: z.string().min(1),
80 message: z.string().min(1).optional(),
81 threadId: z.string().min(1).optional(),
82 turnId: z.string().min(1).optional(),
83 itemId: z.string().min(1).optional(),
84 textDelta: z.string().optional(),
85 payload: z.unknown().optional(),
86});
87
88export type ProviderKind = z.infer<typeof providerKindSchema>;
89export type ProviderApprovalPolicy = z.infer<
90 typeof providerApprovalPolicySchema
91>;
92export type ProviderSandboxMode = z.infer<typeof providerSandboxModeSchema>;
93export type ProviderSessionStatus = z.infer<typeof providerSessionStatusSchema>;
94export type ProviderSession = z.infer<typeof providerSessionSchema>;
95export type ProviderSessionStartInput = z.input<
96 typeof providerSessionStartInputSchema
97>;
98export type ProviderSendTurnInput = z.input<typeof providerSendTurnInputSchema>;
99export type ProviderTurnStartResult = z.infer<
100 typeof providerTurnStartResultSchema
101>;
102export type ProviderInterruptTurnInput = z.input<
103 typeof providerInterruptTurnInputSchema
104>;
105export type ProviderStopSessionInput = z.input<
106 typeof providerStopSessionInputSchema
107>;
108export type ProviderEventKind = z.infer<typeof providerEventKindSchema>;
109export type ProviderEvent = z.infer<typeof providerEventSchema>;