@sanqianx/claude-ai-workbench 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +129 -0
  3. package/apps/agent-terminal/app.css +567 -0
  4. package/apps/agent-terminal/app.js +2392 -0
  5. package/apps/agent-terminal/index.html +158 -0
  6. package/apps/ai-profile-settings-host/index.html +25 -0
  7. package/apps/ai-profile-settings-host/page.css +1 -0
  8. package/apps/ai-profile-settings-host/page.js +113 -0
  9. package/apps/minimal-test-host/README.md +15 -0
  10. package/apps/minimal-test-host/host.css +2 -0
  11. package/apps/minimal-test-host/host.js +35 -0
  12. package/apps/minimal-test-host/index.html +58 -0
  13. package/apps/minimal-test-host/optional-mock/fault-injection.js +15 -0
  14. package/docs/sdk/FILE_LAYOUT.md +31 -0
  15. package/docs/sdk/INTEGRATION.md +126 -0
  16. package/docs/sdk/README.md +21 -0
  17. package/docs/sdk/openapi.yaml +760 -0
  18. package/package.json +51 -0
  19. package/packages/client/README.md +11 -0
  20. package/packages/client/index.d.ts +81 -0
  21. package/packages/client/index.js +198 -0
  22. package/packages/client/package.json +29 -0
  23. package/packages/contracts/README.md +9 -0
  24. package/packages/contracts/index.d.ts +252 -0
  25. package/packages/contracts/index.js +295 -0
  26. package/packages/contracts/package.json +24 -0
  27. package/packages/core/README.md +8 -0
  28. package/packages/core/index.d.ts +76 -0
  29. package/packages/core/index.js +214 -0
  30. package/packages/core/package.json +29 -0
  31. package/packages/server/README.md +17 -0
  32. package/packages/server/bin/agent-terminal-server.js +36 -0
  33. package/packages/server/bin/claude-workbench-server.js +46 -0
  34. package/packages/server/bin/claude-workbench.js +225 -0
  35. package/packages/server/index.d.ts +145 -0
  36. package/packages/server/index.js +183 -0
  37. package/packages/server/lib/agent-discovery.js +112 -0
  38. package/packages/server/lib/agent-registry.js +98 -0
  39. package/packages/server/lib/attachment-store.js +78 -0
  40. package/packages/server/lib/bridges/app-server-bridge.js +372 -0
  41. package/packages/server/lib/bridges/opencode-server-bridge.js +490 -0
  42. package/packages/server/lib/capture-settings-store.js +34 -0
  43. package/packages/server/lib/context-resolver.js +40 -0
  44. package/packages/server/lib/drivers/cli-json-runner.js +960 -0
  45. package/packages/server/lib/drivers/codex-driver.js +1413 -0
  46. package/packages/server/lib/drivers/opencode-driver.js +1527 -0
  47. package/packages/server/lib/drivers/zcode-driver.js +1167 -0
  48. package/packages/server/lib/event-stream.js +35 -0
  49. package/packages/server/lib/folder-picker.js +623 -0
  50. package/packages/server/lib/http-handler.js +254 -0
  51. package/packages/server/lib/json-file.js +19 -0
  52. package/packages/server/lib/model-detection.js +295 -0
  53. package/packages/server/lib/profile-store.js +149 -0
  54. package/packages/server/lib/project-store.js +102 -0
  55. package/packages/server/lib/session-archive-store.js +53 -0
  56. package/packages/server/lib/system-doctor.js +119 -0
  57. package/packages/server/lib/ui-preferences-store.js +48 -0
  58. package/packages/server/lib/workbench-service.js +620 -0
  59. package/packages/server/package.json +34 -0
  60. package/packages/ui/README.md +30 -0
  61. package/packages/ui/ai-profile-settings.d.ts +45 -0
  62. package/packages/ui/ai-profile-settings.js +283 -0
  63. package/packages/ui/claude-terminal.d.ts +31 -0
  64. package/packages/ui/claude-terminal.js +343 -0
  65. package/packages/ui/index.d.ts +18 -0
  66. package/packages/ui/index.js +51 -0
  67. package/packages/ui/package.json +42 -0
  68. package/packages/ui/theme.css +13 -0
  69. package/src/assets/claude-prompts.json +21 -0
  70. package/src/backend/http/ai-profile-routes.js +61 -0
  71. package/src/backend/http/claude-routes.js +170 -0
  72. package/src/backend/http/node-helpers.js +45 -0
  73. package/src/backend/index.js +21 -0
  74. package/src/backend/lib/ai-adapter.js +144 -0
  75. package/src/backend/lib/ai-profile-store.js +259 -0
  76. package/src/backend/lib/ai-vendor-presets.js +83 -0
  77. package/src/backend/lib/ai-workspace.js +140 -0
  78. package/src/backend/lib/automation-config.js +253 -0
  79. package/src/backend/lib/claude-cli-runner.js +1749 -0
  80. package/src/backend/lib/data-dir.js +169 -0
  81. package/src/backend/lib/llm-client.js +176 -0
  82. package/src/backend/lib/model-context-windows.js +82 -0
  83. package/src/backend/lib/prompt-registry.js +166 -0
  84. package/src/backend/lib/runtime-config.js +121 -0
  85. package/src/index.js +7 -0
  86. package/src/sdk-wave-a.ts +1 -0
  87. package/src/sdk-wave-b.ts +1 -0
  88. package/src/sdk-wave-c.ts +1 -0
  89. package/src/sdk-wave-d.ts +1 -0
  90. package/src/sdk-wave-e.ts +1 -0
@@ -0,0 +1,1413 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const os = require('os');
5
+ const path = require('path');
6
+ const crypto = require('crypto');
7
+ const { createCliJsonRunner, resolveCliInvocation } = require('./cli-json-runner');
8
+ const { createAppServerBridge } = require('../bridges/app-server-bridge');
9
+ const {
10
+ appendTextAttachments, normalizeAttachments, resolveOpenAIModel, resolveOpenAIBaseUrl,
11
+ } = require('../../../contracts');
12
+
13
+ const PROVIDER_ID = 'agent_terminal';
14
+ const PROVIDER_KEY_ENV = 'AGENT_TERMINAL_PROVIDER_API_KEY';
15
+ const ACTIVE_STATES = new Set(['running', 'thinking', 'tool-running']);
16
+
17
+ // Codex driver, two transports:
18
+ // app-server (preferred) — one persistent `codex app-server` child speaking
19
+ // line-delimited JSON-RPC over stdio, giving true token streaming
20
+ // (`item/agentMessage/delta`), live reasoning deltas, token usage and
21
+ // interactive approvals. Protocol verified against codex-cli 0.137.0.
22
+ // cli-json (fallback) — the historical `codex exec --json` one-shot turns,
23
+ // used automatically whenever the app-server path is unavailable,
24
+ // unsupported by the installed CLI, or fails its handshake.
25
+ //
26
+ // Auth is agent-owned (codex login) or profile-injected; permission modes map
27
+ // to app-server sandboxes / approval policies, or exec sandboxes on the CLI.
28
+ const SANDBOX_FOR_PERMISSION = {
29
+ default: 'read-only',
30
+ plan: 'read-only',
31
+ acceptEdits: 'workspace-write',
32
+ bypassPermissions: 'danger-full-access',
33
+ };
34
+
35
+ const APPROVAL_POLICY_FOR_PERMISSION = {
36
+ default: 'on-request',
37
+ plan: 'untrusted',
38
+ acceptEdits: 'on-request',
39
+ bypassPermissions: 'never',
40
+ };
41
+
42
+ function createCodexDriver(options = {}) {
43
+ // Test doubles inject `spawn` and script CLI subprocess behavior; honoring
44
+ // that injection with the CLI path keeps the legacy contract intact.
45
+ const forcedCli = options.bridge === false
46
+ || process.env.CLAUDE_WORKBENCH_CODEX_TRANSPORT === 'cli'
47
+ || Boolean(options.spawn);
48
+ if (!forcedCli && (options.appServerSpawn || codexCommandExists(options.command || 'codex'))) {
49
+ return createAppServerCodexDriver(options);
50
+ }
51
+ return createCliCodexRunner(options);
52
+ }
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // CLI transport (previous behavior, kept verbatim as the fallback)
56
+ // ---------------------------------------------------------------------------
57
+
58
+ function createCliCodexRunner(options = {}) {
59
+ const ignoreUserConfig = options.ignoreUserConfig !== false;
60
+ return createCliJsonRunner({
61
+ agentId: 'codex',
62
+ command: options.command || 'codex',
63
+ spawn: options.spawn,
64
+ promptViaStdin: true,
65
+ // Disk session persistence flows through both transports so fallback
66
+ // mirrors and pure-CLI runners share one store (undefined = B's default).
67
+ ...(options.persistence !== undefined ? { persistence: options.persistence } : {}),
68
+
69
+ modelFor(profile) {
70
+ const model = resolveOpenAIModel(profile);
71
+ return model && isSafeToken(model) ? model : null;
72
+ },
73
+
74
+ sandboxFor(permissionMode) { return SANDBOX_FOR_PERMISSION[permissionMode] || 'read-only'; },
75
+
76
+ envFor(profile) {
77
+ return isInjected(profile) ? { [PROVIDER_KEY_ENV]: profile.apiKey } : {};
78
+ },
79
+
80
+ spawnArgs({ projectPath, model, sandbox, threadId, profile, imagePaths, effort }) {
81
+ const args = ['exec', '--json', '--skip-git-repo-check', '--color', 'never', '-C', projectPath];
82
+ // A broken user config.toml must not take the terminal down with it;
83
+ // auth still flows through CODEX_HOME when the config is ignored.
84
+ if (ignoreUserConfig || isInjected(profile)) args.push('--ignore-user-config');
85
+ if (isInjected(profile)) args.push(...injectedProviderArgs(profile));
86
+ if (effort) args.push('-c', `model_reasoning_effort=${JSON.stringify(effortFor(effort))}`);
87
+ if (model) args.push('-m', model);
88
+ // codex 0.137 on Windows silently downgrades `-s workspace-write` to a
89
+ // read-only policy; the dedicated bypass flag is the only working write
90
+ // channel there. Read-only modes keep the regular sandbox flag.
91
+ const writeMode = sandbox === 'workspace-write' || sandbox === 'danger-full-access';
92
+ if (writeMode && process.platform === 'win32') {
93
+ args.push('--dangerously-bypass-approvals-and-sandbox');
94
+ } else if (sandbox) {
95
+ args.push('-s', sandbox);
96
+ }
97
+ // The prompt itself is written to stdin; "-" tells codex to read it there.
98
+ if (threadId) args.push('resume', threadId);
99
+ for (const file of imagePaths || []) args.push('-i', file);
100
+ args.push('-');
101
+ return args;
102
+ },
103
+
104
+ parseLine: parseCodexLine,
105
+ });
106
+ }
107
+
108
+ function injectedProviderArgs(profile) {
109
+ const endpoint = resolveOpenAIBaseUrl(profile);
110
+ if (!endpoint) throw new Error('the selected provider needs an OpenAI base URL for Codex');
111
+ const config = {
112
+ model_provider: PROVIDER_ID,
113
+ [`model_providers.${PROVIDER_ID}.name`]: 'Agent Terminal',
114
+ [`model_providers.${PROVIDER_ID}.base_url`]: endpoint,
115
+ [`model_providers.${PROVIDER_ID}.env_key`]: PROVIDER_KEY_ENV,
116
+ [`model_providers.${PROVIDER_ID}.wire_api`]: 'responses',
117
+ [`model_providers.${PROVIDER_ID}.requires_openai_auth`]: false,
118
+ [`model_providers.${PROVIDER_ID}.supports_websockets`]: false,
119
+ };
120
+ const args = [];
121
+ for (const [key, value] of Object.entries(config)) args.push('-c', `${key}=${JSON.stringify(value)}`);
122
+ return args;
123
+ }
124
+
125
+ function parseCodexLine(event, session, helpers) {
126
+ const out = [];
127
+ const type = event && event.type;
128
+ if (type === 'thread.started') {
129
+ helpers.setThreadId(event.thread_id || event.threadId);
130
+ return out;
131
+ }
132
+ if (type === 'turn.started' || type === 'turn.delta') return out;
133
+ if (type === 'item.started') {
134
+ const item = event.item || {};
135
+ if (item.type === 'command_execution') {
136
+ helpers.setState(session, 'tool-running');
137
+ out.push({
138
+ type: 'claude/tool-use',
139
+ id: item.id || `cmd-${session.turns}-${session.outputBuffer.length}`,
140
+ name: 'Bash',
141
+ input: { command: displayCommand(item.command) },
142
+ });
143
+ }
144
+ return out;
145
+ }
146
+ if (type === 'item.completed' || type === 'item.updated') {
147
+ const item = event.item || {};
148
+ if (item.type === 'agent_message' && item.text) {
149
+ helpers.setResult(item.text);
150
+ out.push({ type: 'claude/text-start', id: item.id || `text-${session.turns}` });
151
+ out.push({ type: 'claude/text-delta', id: item.id || `text-${session.turns}`, text: item.text });
152
+ } else if (item.type === 'reasoning' && item.text) {
153
+ out.push({ type: 'claude/thinking-start', id: item.id || `think-${session.turns}` });
154
+ out.push({ type: 'claude/thinking-delta', id: item.id || `think-${session.turns}`, text: item.text });
155
+ } else if (item.type === 'command_execution') {
156
+ out.push({
157
+ type: 'claude/tool-result',
158
+ id: item.id || `cmd-${session.turns}-${session.outputBuffer.length}`,
159
+ output: `exit ${item.exit_code != null ? item.exit_code : '?'}: ${item.aggregated_output || item.last_agent_message || displayCommand(item.command) || ''}`.slice(0, 4000),
160
+ });
161
+ } else if (item.type === 'file_change') {
162
+ const files = Array.isArray(item.changes)
163
+ ? item.changes.map(change => change.path || '').filter(Boolean).join(', ')
164
+ : '';
165
+ out.push({
166
+ type: 'claude/tool-use',
167
+ id: item.id || `file-${session.turns}-${session.outputBuffer.length}`,
168
+ name: 'Edit',
169
+ input: { files, changes: item.changes || [] },
170
+ });
171
+ out.push({
172
+ type: 'claude/tool-result',
173
+ id: item.id || `file-${session.turns}-${session.outputBuffer.length}`,
174
+ output: files.slice(0, 4000),
175
+ changes: item.changes || [],
176
+ });
177
+ } else if (item.type === 'error') {
178
+ // Codex surfaces non-fatal notices (requirements.toml warnings) as
179
+ // error items; keep them visible without failing the turn.
180
+ out.push({ type: 'claude/stderr', text: String(item.message || 'codex notice').slice(0, 2000) });
181
+ }
182
+ return out;
183
+ }
184
+ if (type === 'turn.completed') {
185
+ helpers.addUsage(event.usage);
186
+ if (event.model) helpers.setModel(event.model);
187
+ out.push({
188
+ type: 'claude/usage',
189
+ usage: {
190
+ input_tokens: Number(event.usage && event.usage.input_tokens || 0),
191
+ output_tokens: Number(event.usage && event.usage.output_tokens || 0),
192
+ },
193
+ });
194
+ out.push({
195
+ type: 'claude/result',
196
+ result: session.lastResult,
197
+ model: session.model,
198
+ isError: false,
199
+ });
200
+ return out;
201
+ }
202
+ if (type === 'turn.failed') {
203
+ out.push({
204
+ type: 'claude/error',
205
+ message: String((event.error && (event.error.message || event.error)) || 'codex turn failed').slice(0, 500),
206
+ });
207
+ return out;
208
+ }
209
+ if (type === 'error') {
210
+ const message = String(event.message || 'codex error');
211
+ // "Reconnecting..." is codex retrying the stream, not a failed turn.
212
+ if (/reconnect/i.test(message)) {
213
+ out.push({ type: 'claude/retry', message: message.slice(0, 500) });
214
+ return out;
215
+ }
216
+ out.push({ type: 'claude/error', message: message.slice(0, 500) });
217
+ return out;
218
+ }
219
+ return out; // unknown event types are ignored, never fatal
220
+ }
221
+
222
+ // ---------------------------------------------------------------------------
223
+ // app-server transport
224
+ // ---------------------------------------------------------------------------
225
+
226
+ function createAppServerCodexDriver(options = {}) {
227
+ const command = options.command || 'codex';
228
+ const log = typeof options.log === 'function' ? options.log : () => {};
229
+ const sessions = new Map();
230
+ const threads = new Map(); // threadId -> sessionId
231
+ // The CLI fallback runner: `cliSpawn` lets tests script fallback turns
232
+ // without `spawn`, which would itself force the CLI transport.
233
+ const cliRunner = createCliCodexRunner({ ...options, spawn: options.cliSpawn });
234
+ const appServerPersist = resolveAppServerPersistence(options);
235
+ // sessionId -> file for app-server records in the shared cli-sessions dir.
236
+ // Kept lazily like the CLI runner's disk index; records written by another
237
+ // process (a restart) become listable without loading their events.
238
+ let appServerDiskIndex = null;
239
+ let nextId = 1;
240
+ let nextPermissionId = 1;
241
+ let mode = 'app-server';
242
+ let bridge = null;
243
+ let bridgeSpecKey = null;
244
+ let bridgeFailures = 0;
245
+ let initializedGeneration = 0; // bridge generation that completed the handshake
246
+
247
+ function summary(session) {
248
+ return {
249
+ sessionId: session.sessionId,
250
+ projectSlug: session.projectSlug,
251
+ state: session.state,
252
+ model: session.model,
253
+ selectedModel: session.selectedModel,
254
+ aiProfileId: session.aiProfileId,
255
+ permissionMode: session.permissionMode,
256
+ turns: session.turns,
257
+ startedAt: session.startedAt,
258
+ endedAt: session.endedAt,
259
+ pendingPermission: session.pendingPermission,
260
+ };
261
+ }
262
+
263
+ function emit(session, event) {
264
+ session.outputBuffer.push(event);
265
+ for (const listener of session.listeners) {
266
+ try { listener(event); } catch { /* listener errors never break the turn */ }
267
+ }
268
+ }
269
+
270
+ function setState(session, state) {
271
+ session.state = state;
272
+ emit(session, { type: 'claude/state', state, turn: session.turns });
273
+ }
274
+
275
+ function emitDiag(session, event) {
276
+ const text = event.text != null ? event.text : event.message;
277
+ if (text === session.lastDiag) return;
278
+ session.lastDiag = String(text == null ? '' : text);
279
+ emit(session, event);
280
+ }
281
+
282
+ // ---- app-server session persistence (shared cli-sessions directory) -----
283
+ // Pure app-server sessions never touch the CLI runner, so without this
284
+ // store a restart wiped them from the sidebar. Records live beside the CLI
285
+ // ones but carry their own schema; the CLI runner ignores foreign schemas,
286
+ // and this driver only claims records it wrote itself.
287
+
288
+ function appServerRecordFile(sessionId) {
289
+ return path.join(appServerPersist.dir, `${safeFileStem(sessionId)}.json`);
290
+ }
291
+
292
+ function readAppServerRecord(file) {
293
+ try {
294
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
295
+ if (parsed && parsed.schema === APP_SERVER_PERSIST_SCHEMA
296
+ && parsed.transport === 'app-server' && typeof parsed.sessionId === 'string' && parsed.sessionId) return parsed;
297
+ } catch { /* corrupt or foreign records are skipped, never fatal */ }
298
+ return null;
299
+ }
300
+
301
+ function syncAppServerDiskIndex() {
302
+ if (!appServerPersist.enabled) return null;
303
+ if (!appServerDiskIndex) {
304
+ appServerDiskIndex = new Map();
305
+ } else {
306
+ appServerDiskIndex.clear();
307
+ }
308
+ let names = [];
309
+ try { names = fs.readdirSync(appServerPersist.dir).filter(name => name.endsWith('.json')); } catch { return appServerDiskIndex; }
310
+ for (const name of names) {
311
+ const file = path.join(appServerPersist.dir, name);
312
+ const record = readAppServerRecord(file);
313
+ if (record) appServerDiskIndex.set(record.sessionId, { file, summary: persistedAppServerSummary(record) });
314
+ }
315
+ return appServerDiskIndex;
316
+ }
317
+
318
+ function persistedAppServerSummary(record) {
319
+ return {
320
+ sessionId: record.sessionId,
321
+ projectSlug: record.projectSlug,
322
+ state: 'idle',
323
+ model: record.model || null,
324
+ selectedModel: record.selectedModel || null,
325
+ aiProfileId: record.aiProfileId || null,
326
+ permissionMode: record.permissionMode || 'default',
327
+ turns: Number(record.turns) || 0,
328
+ startedAt: record.startedAt || '',
329
+ endedAt: record.endedAt || null,
330
+ pendingPermission: null,
331
+ };
332
+ }
333
+
334
+ function persistAppServerSession(session) {
335
+ if (!appServerPersist.enabled) return;
336
+ try {
337
+ const file = appServerRecordFile(session.sessionId);
338
+ fs.mkdirSync(path.dirname(file), { recursive: true });
339
+ fs.writeFileSync(file, `${JSON.stringify({
340
+ schema: APP_SERVER_PERSIST_SCHEMA,
341
+ agentId: 'codex',
342
+ transport: 'app-server',
343
+ sessionId: session.sessionId,
344
+ projectSlug: session.projectSlug,
345
+ projectPath: session.projectPath,
346
+ state: session.state,
347
+ model: session.model,
348
+ selectedModel: session.selectedModel,
349
+ aiProfileId: session.aiProfileId,
350
+ permissionMode: session.permissionMode,
351
+ effort: session.effort || null,
352
+ title: session.title || null,
353
+ turns: session.turns,
354
+ startedAt: session.startedAt,
355
+ endedAt: session.endedAt,
356
+ // The app-server thread id: thread/resume needs it after a restart.
357
+ threadId: session.threadId || null,
358
+ lastResult: session.lastResult || '',
359
+ usage: { ...session.usage },
360
+ peakTurnTokens: session.peakTurnTokens || 0,
361
+ events: session.outputBuffer.slice(-MAX_APP_SERVER_PERSISTED_EVENTS),
362
+ updatedAt: new Date().toISOString(),
363
+ })}\n`, 'utf8');
364
+ if (appServerDiskIndex) appServerDiskIndex.set(session.sessionId, { file: appServerRecordFile(session.sessionId), summary: summary(session) });
365
+ } catch {
366
+ // Read-only home or full disk degrades to memory-only behavior.
367
+ appServerPersist.enabled = false;
368
+ appServerDiskIndex = null;
369
+ }
370
+ }
371
+
372
+ // Only ever removes OUR record: when a CLI mirror takes over a session the
373
+ // mirror id can equal the driver id, and the CLI runner will have replaced
374
+ // the file contents with its own schema by then.
375
+ function removeAppServerRecord(sessionId) {
376
+ if (appServerDiskIndex) appServerDiskIndex.delete(sessionId);
377
+ if (!appServerPersist.enabled) return;
378
+ try {
379
+ const file = appServerRecordFile(sessionId);
380
+ if (fs.existsSync(file) && readAppServerRecord(file)) fs.rmSync(file, { force: true });
381
+ } catch { /* already gone */ }
382
+ }
383
+
384
+ // Restore an app-server record into the live map (lazy, on first lookup).
385
+ // The restored session keeps its threadId so the next turn resumes the
386
+ // app-server conversation instead of starting a new thread.
387
+ function restoreAppServerSession(id) {
388
+ if (!appServerPersist.enabled) return null;
389
+ const index = syncAppServerDiskIndex();
390
+ const entry = index && index.get(id);
391
+ if (!entry) return null;
392
+ const record = readAppServerRecord(entry.file);
393
+ if (!record || record.sessionId !== id) { index.delete(id); return null; }
394
+ const session = {
395
+ sessionId: record.sessionId,
396
+ projectSlug: record.projectSlug,
397
+ projectPath: record.projectPath,
398
+ state: 'idle', // a restart killed any live turn
399
+ model: record.model || null,
400
+ selectedModel: record.selectedModel || null,
401
+ aiProfileId: record.aiProfileId || null,
402
+ permissionMode: record.permissionMode || 'default',
403
+ effort: record.effort || null,
404
+ title: record.title || null,
405
+ turns: Number(record.turns) || 0,
406
+ startedAt: record.startedAt || new Date().toISOString(),
407
+ endedAt: record.endedAt || null,
408
+ pendingPermission: null,
409
+ pendingApproval: null,
410
+ outputBuffer: Array.isArray(record.events) ? record.events.slice(-MAX_APP_SERVER_PERSISTED_EVENTS) : [],
411
+ listeners: new Set(),
412
+ threadId: record.threadId || null,
413
+ currentTurn: null,
414
+ lastResult: record.lastResult || '',
415
+ lastDiag: '',
416
+ usage: {
417
+ inputTokens: Number(record.usage && record.usage.inputTokens) || 0,
418
+ outputTokens: Number(record.usage && record.usage.outputTokens) || 0,
419
+ cachedTokens: Number(record.usage && record.usage.cachedTokens) || 0,
420
+ },
421
+ peakTurnTokens: Number(record.peakTurnTokens) || 0,
422
+ turnTokenUsage: null,
423
+ cliMirrorId: null,
424
+ pinnedCli: false,
425
+ turnPromise: Promise.resolve(),
426
+ };
427
+ sessions.set(id, session);
428
+ return session;
429
+ }
430
+
431
+ const runner = {
432
+ agentId: 'codex',
433
+
434
+ startChatSession({ slug, projectPath, aiProfile, permissionMode, effort, title }) {
435
+ if (!slug) throw new Error('slug required');
436
+ if (!projectPath) throw new Error('projectPath required');
437
+ const sessionId = nextDriverSessionId();
438
+ const session = {
439
+ sessionId,
440
+ projectSlug: slug,
441
+ projectPath,
442
+ state: 'idle',
443
+ model: modelForProfile(aiProfile),
444
+ selectedModel: aiProfile && (aiProfile.mainModel || aiProfile.models && aiProfile.models.default) || '',
445
+ aiProfileId: aiProfile && aiProfile.id,
446
+ permissionMode: permissionMode || 'default',
447
+ effort: effort || null,
448
+ title: title || null,
449
+ turns: 0,
450
+ startedAt: new Date().toISOString(),
451
+ endedAt: null,
452
+ pendingPermission: null,
453
+ pendingApproval: null,
454
+ outputBuffer: [],
455
+ listeners: new Set(),
456
+ threadId: null,
457
+ currentTurn: null,
458
+ lastResult: '',
459
+ lastDiag: '',
460
+ usage: { inputTokens: 0, outputTokens: 0, cachedTokens: 0 },
461
+ peakTurnTokens: 0,
462
+ turnTokenUsage: null,
463
+ cliMirrorId: null,
464
+ turnPromise: Promise.resolve(),
465
+ };
466
+ sessions.set(sessionId, session);
467
+ emit(session, {
468
+ type: 'claude/session-ready',
469
+ model: session.model,
470
+ aiProfileId: session.aiProfileId,
471
+ permissionMode: session.permissionMode,
472
+ message: 'codex session is ready. Send a message to start the first turn.',
473
+ });
474
+ // Write the record immediately so a session with no turns yet still
475
+ // shows up in the sidebar after a restart.
476
+ persistAppServerSession(session);
477
+ return { sessionId };
478
+ },
479
+
480
+ // Live driver sessions ∪ CLI-runner sessions (disk-restored + mirrors)
481
+ // ∪ this driver's persisted app-server records, deduped by id with memory
482
+ // winning. A mirror that a live driver session already fronts is hidden:
483
+ // its events flow through the owning session, so listing both would
484
+ // duplicate the logical session. After a restart the memory side is empty
485
+ // and the disk records surface.
486
+ listSessions(filter = {}) {
487
+ const byId = new Map();
488
+ const boundMirrors = new Set();
489
+ for (const session of sessions.values()) {
490
+ if (filter.projectSlug && session.projectSlug !== filter.projectSlug) continue;
491
+ byId.set(session.sessionId, summary(session));
492
+ if (session.cliMirrorId) boundMirrors.add(session.cliMirrorId);
493
+ }
494
+ const extraSources = [cliRunner.listSessions(filter)];
495
+ const appServerIndex = syncAppServerDiskIndex();
496
+ if (appServerIndex) {
497
+ extraSources.push([...appServerIndex.values()]
498
+ .map(entry => entry.summary)
499
+ .filter(item => !filter.projectSlug || item.projectSlug === filter.projectSlug));
500
+ }
501
+ for (const source of extraSources) {
502
+ for (const item of source) {
503
+ if (byId.has(item.sessionId) || boundMirrors.has(item.sessionId)) continue;
504
+ byId.set(item.sessionId, item);
505
+ }
506
+ }
507
+ return [...byId.values()];
508
+ },
509
+
510
+ getState(id) {
511
+ const session = sessions.get(id) || restoreAppServerSession(id);
512
+ if (!session) return cliRunner.getState(id); // disk-restored fallback session
513
+ return {
514
+ ...summary(session),
515
+ listenerCount: session.listeners.size,
516
+ bufferedEvents: session.outputBuffer.length,
517
+ };
518
+ },
519
+
520
+ getSession(id) { return sessions.get(id) || restoreAppServerSession(id) || cliRunner.getSession(id); },
521
+
522
+ getSessionTokenUsage(id) {
523
+ const session = sessions.get(id) || restoreAppServerSession(id);
524
+ if (!session) return cliRunner.getSessionTokenUsage(id);
525
+ // CLI-pinned sessions account usage on their mirror; it is the source
526
+ // of truth for both live and restored records.
527
+ if (session.cliMirrorId) return cliRunner.getSessionTokenUsage(session.cliMirrorId);
528
+ const { inputTokens, outputTokens } = session.usage;
529
+ const used = session.peakTurnTokens || 0;
530
+ return {
531
+ used,
532
+ inputTokens,
533
+ outputTokens,
534
+ hasUsage: Boolean(used || inputTokens || outputTokens),
535
+ };
536
+ },
537
+
538
+ subscribe(id, listener) {
539
+ const session = sessions.get(id) || restoreAppServerSession(id);
540
+ if (!session) return cliRunner.subscribe(id, listener); // replays the restored stream
541
+ for (const event of session.outputBuffer) listener(event);
542
+ session.listeners.add(listener);
543
+ return () => session.listeners.delete(listener);
544
+ },
545
+
546
+ async sendInput(id, text, profile, sendOptions = {}) {
547
+ // Lookup order: live memory, then this driver's persisted app-server
548
+ // records (restored with their threadId so the turn resumes the same
549
+ // conversation), then the CLI runner's store (persisted fallback
550
+ // mirrors, adopted under the same id and pinned to the CLI transport).
551
+ const session = sessions.get(id) || restoreAppServerSession(id) || adoptDiskSession(id);
552
+ if (!session) throw Object.assign(new Error('session not found'), { status: 404 });
553
+ if (ACTIVE_STATES.has(session.state)) {
554
+ throw Object.assign(new Error('session already has an active turn'), { status: 409 });
555
+ }
556
+ const value = String(text || '').trim();
557
+ const attachments = normalizeAttachments(sendOptions.attachments || []);
558
+ if (!value && !attachments.length) throw Object.assign(new Error('text or attachments are required'), { status: 400 });
559
+ if (profile) {
560
+ session.model = modelForProfile(profile);
561
+ session.selectedModel = profile.mainModel || profile.models && profile.models.default || '';
562
+ }
563
+ if (sendOptions.permissionMode) session.permissionMode = sendOptions.permissionMode;
564
+ if (sendOptions.effort) session.effort = sendOptions.effort;
565
+ session.lastPromptText = value;
566
+
567
+ if (mode === 'cli' || session.pinnedCli) return runCliTurn(session, value, profile, sendOptions, attachments, { emitPrompt: true });
568
+
569
+ try {
570
+ await ensureBridge(profile);
571
+ await ensureThread(session);
572
+ } catch (error) {
573
+ if (shouldFallbackToCli(error)) {
574
+ switchToCli(error);
575
+ return runCliTurn(session, value, profile, sendOptions, attachments, { emitPrompt: true });
576
+ }
577
+ emit(session, { type: 'claude/error', message: `codex app-server unavailable: ${error.message}`.slice(0, 500) });
578
+ setState(session, 'idle');
579
+ return { started: false };
580
+ }
581
+
582
+ emit(session, {
583
+ type: 'claude/user-prompt',
584
+ text: value,
585
+ attachments: (sendOptions.displayAttachments || []).map(({ id, name, mediaType, size, width, height, url }) => ({ id, name, mediaType, size, width, height, url })),
586
+ });
587
+ setState(session, 'running');
588
+ // Until the first app-server event lands the UI would show dead air
589
+ // (reasoning models think for tens of seconds first) — announce it.
590
+ setState(session, 'thinking');
591
+ let prepared = null;
592
+ try {
593
+ prepared = prepareImages(session.projectPath, attachments);
594
+ const input = [
595
+ { type: 'text', text: appendTextAttachments(value, attachments) },
596
+ ...prepared.imagePaths.map(imagePath => ({ type: 'image', url: imagePath })),
597
+ ];
598
+ const turn = await bridge.request('turn/start', {
599
+ threadId: session.threadId,
600
+ input,
601
+ ...(session.model ? { model: session.model } : {}),
602
+ ...(session.effort ? { effort: effortFor(session.effort) } : {}),
603
+ }, { timeoutMs: 30000 });
604
+ session.currentTurn = {
605
+ turnId: turn && turn.turn && turn.turn.id || `turn-${session.turns + 1}`,
606
+ announcedText: new Set(),
607
+ streamedText: new Set(),
608
+ announcedThinking: new Set(),
609
+ streamedThinking: new Set(),
610
+ sawEvent: false,
611
+ };
612
+ session.turnPromise = new Promise(resolve => { session.currentTurn.resolve = resolve; });
613
+ return { started: true };
614
+ } catch (error) {
615
+ if (prepared) prepared.cleanup();
616
+ if (shouldFallbackToCli(error)) {
617
+ switchToCli(error);
618
+ return runCliTurn(session, value, profile, sendOptions, attachments, { emitPrompt: false });
619
+ }
620
+ emit(session, { type: 'claude/error', message: `codex turn failed to start: ${error.message}`.slice(0, 500) });
621
+ session.currentTurn = null;
622
+ setState(session, 'idle');
623
+ return { started: false };
624
+ } finally {
625
+ // Images live only for the turn; the app-server reads them eagerly.
626
+ if (prepared && session.currentTurn) {
627
+ const turn = session.currentTurn;
628
+ const originalResolve = turn.resolve;
629
+ turn.resolve = () => { prepared.cleanup(); originalResolve(); };
630
+ }
631
+ }
632
+ },
633
+
634
+ resolvePermission(id, requestId, decision) {
635
+ const session = sessions.get(id);
636
+ if (!session) return cliRunner.resolvePermission(id, requestId, decision); // 501 from the CLI runner
637
+ if (!session.pendingApproval || session.pendingApproval.requestId !== requestId) {
638
+ if (session.cliMirrorId) return cliRunner.resolvePermission(session.cliMirrorId, requestId, decision);
639
+ throw Object.assign(new Error('permission request not found or already resolved'), { status: 409 });
640
+ }
641
+ const allow = decision && decision.allow === true;
642
+ const approval = session.pendingApproval;
643
+ session.pendingApproval = null;
644
+ session.pendingPermission = null;
645
+ const rpcDecision = approvalDecisionFor(approval.method, allow);
646
+ emit(session, {
647
+ type: 'claude/permission-resolved',
648
+ requestId,
649
+ allow,
650
+ decision: rpcDecision,
651
+ resolvedAt: new Date().toISOString(),
652
+ });
653
+ setState(session, 'running');
654
+ approval.resolve({ decision: rpcDecision });
655
+ return { ok: true, started: true, decision: rpcDecision };
656
+ },
657
+
658
+ updateSelection(id, profile, selectedModel) {
659
+ const session = sessions.get(id) || restoreAppServerSession(id);
660
+ if (!session) {
661
+ // Disk-restored fallback session: delegate so the persisted record
662
+ // carries the new selection into the next process.
663
+ if (cliRunner.getState(id)) return cliRunner.updateSelection(id, profile, selectedModel);
664
+ throw Object.assign(new Error('session not found'), { status: 404 });
665
+ }
666
+ if (ACTIVE_STATES.has(session.state)) {
667
+ throw Object.assign(new Error('session already has an active turn'), { status: 409 });
668
+ }
669
+ const fromAiProfileId = session.aiProfileId || null;
670
+ const fromModel = session.selectedModel || session.model || null;
671
+ session.aiProfileId = profile && profile.id || null;
672
+ session.selectedModel = selectedModel || (profile && (profile.mainModel || profile.models && profile.models.default)) || null;
673
+ session.model = modelForProfile(profile);
674
+ // For mirrored sessions the selection-changed event comes from the
675
+ // mirror (forwarded below); emitting our own would duplicate it.
676
+ if (!session.cliMirrorId) {
677
+ emit(session, {
678
+ type: 'claude/selection-changed',
679
+ fromAiProfileId,
680
+ toAiProfileId: session.aiProfileId,
681
+ fromModel,
682
+ toModel: session.selectedModel,
683
+ });
684
+ }
685
+ if (session.cliMirrorId) {
686
+ try { cliRunner.updateSelection(session.cliMirrorId, profile, selectedModel); } catch { /* mirror may be mid-turn */ }
687
+ } else {
688
+ persistAppServerSession(session);
689
+ }
690
+ return summary(session);
691
+ },
692
+
693
+ abort(id) {
694
+ const session = sessions.get(id);
695
+ if (!session) return cliRunner.abort(id); // disk-restored sessions restore as idle
696
+ session.abortedByUser = true;
697
+ denyPendingApproval(session, 'aborted');
698
+ const currentTurn = session.currentTurn;
699
+ session.currentTurn = null;
700
+ if (currentTurn && currentTurn.resolve) currentTurn.resolve();
701
+ if (mode === 'app-server' && currentTurn && bridge && bridge.state === 'ready' && session.threadId) {
702
+ // Preferred path: ask the app-server to interrupt the live turn. If
703
+ // that fails the bridge is recycled (killed and relaunched lazily).
704
+ bridge.request('turn/interrupt', { threadId: session.threadId, turnId: currentTurn.turnId }, { timeoutMs: 5000 })
705
+ .catch(() => recycleBridge('interrupt failed'));
706
+ }
707
+ if (session.cliMirrorId) cliRunner.abort(session.cliMirrorId);
708
+ if (!['idle', 'ended'].includes(session.state)) {
709
+ session.state = 'aborted';
710
+ emit(session, { type: 'claude/aborted' });
711
+ setState(session, 'idle');
712
+ }
713
+ if (!session.cliMirrorId) persistAppServerSession(session);
714
+ },
715
+
716
+ deleteSession(id) {
717
+ const session = sessions.get(id) || restoreAppServerSession(id);
718
+ if (!session) {
719
+ // Disk-only record from a previous process: ours or the CLI runner's.
720
+ const index = syncAppServerDiskIndex();
721
+ if (index && index.has(id)) { removeAppServerRecord(id); return true; }
722
+ return cliRunner.deleteSession(id);
723
+ }
724
+ if (ACTIVE_STATES.has(session.state)) runner.abort(id);
725
+ denyPendingApproval(session, 'deleted');
726
+ if (session.threadId) threads.delete(session.threadId);
727
+ const deleted = sessions.delete(id);
728
+ // The mirror and its persisted record must not outlive the driver
729
+ // session they front, otherwise the restart path would resurrect a
730
+ // session the user just deleted.
731
+ if (session.cliMirrorId) cliRunner.deleteSession(session.cliMirrorId);
732
+ removeAppServerRecord(id);
733
+ return deleted;
734
+ },
735
+
736
+ async listSupportedCommands() { return []; },
737
+
738
+ findClaudeExecutableForSdk() { return null; },
739
+
740
+ // Test seams.
741
+ get transportMode() { return mode; },
742
+ get bridgeState() { return bridge ? bridge.state : 'uncreated'; },
743
+ };
744
+
745
+ // -- bridge lifecycle ----------------------------------------------------
746
+
747
+ function bridgeSpecFor(profile) {
748
+ if (!isInjected(profile)) return { key: 'agent-owned', args: ['app-server'], env: {} };
749
+ const providerArgs = injectedProviderArgs(profile); // throws without a base URL
750
+ return {
751
+ key: `injected:${resolveOpenAIBaseUrl(profile)}`,
752
+ args: ['app-server', ...providerArgs],
753
+ env: { [PROVIDER_KEY_ENV]: profile.apiKey },
754
+ };
755
+ }
756
+
757
+ async function ensureBridge(profile) {
758
+ const spec = bridgeSpecFor(profile); // invalid profiles throw here (no fallback)
759
+ if (bridge && bridge.state === 'closed') { bridge = null; bridgeSpecKey = null; }
760
+ if (bridge && bridge.state === 'crashed') { recycleBridge('bridge exhausted its restart budget'); }
761
+ if (bridge && bridgeSpecKey !== spec.key) {
762
+ recycleBridge('provider spec changed');
763
+ }
764
+ if (!bridge) {
765
+ bridge = createAppServerBridge({
766
+ command,
767
+ args: spec.args,
768
+ env: spec.env,
769
+ // Windows npm shims/PATH commands need exact-executable resolution
770
+ // before spawn; without it the bridge ENOENTs on boot and the driver
771
+ // permanently falls back to per-turn CLI spawns.
772
+ resolveInvocation: resolveCliInvocation,
773
+ spawn: options.appServerSpawn,
774
+ onEvent: handleNotification,
775
+ onServerRequest: handleServerRequest,
776
+ onUnexpectedExit: handleBridgeCrash,
777
+ // A bridge that (re)starts a healthy child proves earlier crashes
778
+ // were transient: the failure counter must not outlive recovery.
779
+ onReady: () => { bridgeFailures = 0; },
780
+ idleTimeoutMs: options.idleTimeoutMs !== undefined ? options.idleTimeoutMs : 10 * 60 * 1000,
781
+ requestTimeoutMs: options.requestTimeoutMs !== undefined ? options.requestTimeoutMs : 120000,
782
+ restartBackoffMs: options.restartBackoffMs,
783
+ log,
784
+ });
785
+ bridgeSpecKey = spec.key;
786
+ }
787
+ // A crash-restarted bridge spawns a fresh child that has never seen the
788
+ // initialize handshake. start() resolves on the current child, THEN the
789
+ // generation comparison tells whether this child still needs the
790
+ // handshake (a brand-new bridge is generation 1 against 0 here).
791
+ if (bridge) {
792
+ await bridge.start();
793
+ if (initializedGeneration !== bridge.generation) {
794
+ try {
795
+ await bridge.request('initialize', {
796
+ clientInfo: { name: 'claude-ai-workbench', title: 'Claude AI Workbench', version: require('../../package.json').version },
797
+ }, { timeoutMs: options.initializeTimeoutMs || 20000 });
798
+ bridge.notify('initialized').catch(() => {}); // notification; failure surfaces later if real
799
+ } catch (error) {
800
+ // A crash-restarted child can race the generation bookkeeping and
801
+ // answer "Already initialized" — that IS a usable handshake.
802
+ if (!/already initialized/i.test(String(error && error.message))) {
803
+ error.phase = 'initialize';
804
+ await discardBridge(error);
805
+ throw error;
806
+ }
807
+ }
808
+ initializedGeneration = bridge.generation;
809
+ // A healthy handshake proves the previous failures were transient.
810
+ bridgeFailures = 0;
811
+ }
812
+ }
813
+ return bridge;
814
+ }
815
+
816
+ async function discardBridge(error) {
817
+ recycleBridge(`bridge discarded: ${error && error.message}`);
818
+ if (error && shouldFallbackToCli(error)) return;
819
+ bridgeFailures += 1;
820
+ if (bridgeFailures >= 2) switchToCli(error || new Error('bridge kept failing'));
821
+ }
822
+
823
+ // The bridge is going away (crash, recycle, transport switch): nothing it
824
+ // owed can still arrive. Unanswered approvals are denied so the UI never
825
+ // sticks on "pending", in-flight turns finish with an error, and the
826
+ // resulting state is persisted for the restart path.
827
+ function failoverSessions(reason) {
828
+ for (const session of sessions.values()) {
829
+ denyPendingApproval(session, reason);
830
+ const turn = session.currentTurn;
831
+ if (!turn) continue;
832
+ session.currentTurn = null;
833
+ session.turns += 1;
834
+ emit(session, { type: 'claude/error', message: String(reason).slice(0, 500) });
835
+ emit(session, { type: 'claude/result', result: '', model: session.model, isError: true });
836
+ if (!session.endedAt) session.state = 'idle';
837
+ emit(session, { type: 'claude/state', state: 'idle', turn: session.turns });
838
+ if (!session.cliMirrorId) persistAppServerSession(session);
839
+ if (turn.resolve) turn.resolve();
840
+ }
841
+ }
842
+
843
+ function recycleBridge(reason) {
844
+ log(`recycling codex app-server bridge: ${reason}`);
845
+ if (bridge) bridge.close().catch(() => {});
846
+ bridge = null;
847
+ bridgeSpecKey = null;
848
+ failoverSessions(`codex app-server exited: ${reason}`);
849
+ }
850
+
851
+ // The app-server died mid-flight: turn/completed will never arrive, so
852
+ // every live turn is finished with an error here (the bridge restarts
853
+ // itself and the next turn retries it, or falls back once failures
854
+ // accumulate).
855
+ function handleBridgeCrash(error) {
856
+ if (mode !== 'app-server') return;
857
+ failoverSessions(`codex app-server exited unexpectedly: ${error && error.message || 'crashed'}`);
858
+ bridgeFailures += 1;
859
+ if (bridgeFailures >= 2) switchToCli(error);
860
+ }
861
+
862
+ function switchToCli(error) {
863
+ if (mode === 'cli') return;
864
+ mode = 'cli';
865
+ recycleBridge(`falling back to codex CLI: ${error && error.message}`);
866
+ for (const session of sessions.values()) {
867
+ emitDiag(session, {
868
+ type: 'claude/stderr',
869
+ text: `codex app-server unavailable (${String(error && error.message || error).slice(0, 200)}); using codex exec fallback`,
870
+ });
871
+ }
872
+ }
873
+
874
+ function shouldFallbackToCli(error) {
875
+ if (!error) return false;
876
+ if (error.code === 'BRIDGE_SPAWN_FAILED') return true; // ENOENT / early exit
877
+ if (error.code === 'BRIDGE_CRASHED' || error.code === 'BRIDGE_CLOSED') return true;
878
+ if (error.code === 'BRIDGE_TIMEOUT' && error.phase === 'initialize') return true;
879
+ // A config.toml the installed app-server cannot parse hard-fails every
880
+ // thread/start with -32600; the CLI honors --ignore-user-config instead.
881
+ if (error.code === 'BRIDGE_RPC_ERROR' && isConfigLoadError(error.message)) return true;
882
+ return false;
883
+ }
884
+
885
+ function isConfigLoadError(message) {
886
+ return /failed to load configuration|error loading configuration/i.test(String(message || ''));
887
+ }
888
+
889
+ async function ensureThread(session) {
890
+ // A threadId from a live turn or a restored record always resumes first;
891
+ // the threads map (event routing) is (re)populated on success — after a
892
+ // restart it is empty even though the conversation lives on at the server.
893
+ if (session.threadId) {
894
+ try {
895
+ await bridge.request('thread/resume', { threadId: session.threadId }, { timeoutMs: 30000 });
896
+ threads.set(session.threadId, session.sessionId);
897
+ return;
898
+ } catch (error) {
899
+ if (shouldFallbackToCli(error)) throw error;
900
+ // The thread may have been rolled away; fall through to a fresh one.
901
+ threads.delete(session.threadId);
902
+ session.threadId = null;
903
+ log(`codex thread resume failed, starting a new thread: ${error.message}`);
904
+ }
905
+ }
906
+ const result = await bridge.request('thread/start', {
907
+ cwd: session.projectPath,
908
+ model: session.model || null,
909
+ sandbox: SANDBOX_FOR_PERMISSION[session.permissionMode] || 'read-only',
910
+ approvalPolicy: APPROVAL_POLICY_FOR_PERMISSION[session.permissionMode] || 'on-request',
911
+ }, { timeoutMs: 30000 });
912
+ const thread = result && result.thread;
913
+ if (!thread || !thread.id) throw new Error('codex app-server did not return a thread');
914
+ session.threadId = thread.id;
915
+ threads.set(thread.id, session.sessionId);
916
+ }
917
+
918
+ // -- app-server event mapping ---------------------------------------------
919
+
920
+ function handleNotification({ method, params }) {
921
+ const session = params && threads.get(params.threadId) && sessions.get(threads.get(params.threadId));
922
+ if (!session) return; // notifications for unknown threads are ignored
923
+ switch (method) {
924
+ case 'turn/completed': return onTurnCompleted(session, params);
925
+ case 'item/started': return onItemStarted(session, params);
926
+ case 'item/completed': return onItemCompleted(session, params);
927
+ case 'item/agentMessage/delta': return onAgentMessageDelta(session, params);
928
+ case 'item/reasoning/summaryTextDelta':
929
+ case 'item/reasoning/textDelta': return onReasoningDelta(session, params);
930
+ case 'thread/tokenUsage/updated': return onTokenUsageUpdated(session, params);
931
+ case 'error': return onTurnErrorNotification(session, params);
932
+ case 'warning':
933
+ case 'configWarning': {
934
+ const text = params && (params.message || params.summary);
935
+ if (text) emitDiag(session, { type: 'claude/stderr', text: String(text).slice(0, 2000) });
936
+ return;
937
+ }
938
+ default: return; // thread/started, thread/status/changed, mcpServer/*, account/*, ...
939
+ }
940
+ }
941
+
942
+ function markActivity(session) {
943
+ const turn = session.currentTurn;
944
+ if (!turn) return;
945
+ if (!turn.sawEvent) {
946
+ turn.sawEvent = true;
947
+ if (session.state === 'thinking') setState(session, 'running');
948
+ }
949
+ }
950
+
951
+ function onItemStarted(session, { item }) {
952
+ markActivity(session);
953
+ const turn = session.currentTurn;
954
+ if (!item) return;
955
+ if (item.type === 'agentMessage') {
956
+ if (turn) turn.announcedText.add(item.id); // deltas must not re-announce
957
+ emit(session, { type: 'claude/text-start', id: item.id });
958
+ } else if (item.type === 'reasoning') {
959
+ if (turn) turn.announcedThinking.add(item.id);
960
+ emit(session, { type: 'claude/thinking-start', id: item.id });
961
+ } else if (item.type === 'commandExecution') {
962
+ setState(session, 'tool-running');
963
+ emit(session, {
964
+ type: 'claude/tool-use',
965
+ id: item.id,
966
+ name: 'Bash',
967
+ input: { command: displayCommand(item.command) },
968
+ });
969
+ }
970
+ }
971
+
972
+ function onAgentMessageDelta(session, params) {
973
+ markActivity(session);
974
+ const turn = session.currentTurn;
975
+ if (!turn) return;
976
+ const id = params.itemId || `text-${session.turns}`;
977
+ if (!turn.announcedText.has(id)) {
978
+ turn.announcedText.add(id);
979
+ emit(session, { type: 'claude/text-start', id });
980
+ }
981
+ turn.streamedText.add(id);
982
+ // The whole point of the app-server transport: real token deltas.
983
+ emit(session, { type: 'claude/text-delta', id, text: String(params.delta || '') });
984
+ }
985
+
986
+ function onReasoningDelta(session, params) {
987
+ markActivity(session);
988
+ const turn = session.currentTurn;
989
+ if (!turn) return;
990
+ const id = params.itemId || `think-${session.turns}`;
991
+ if (!turn.announcedThinking.has(id)) {
992
+ turn.announcedThinking.add(id);
993
+ emit(session, { type: 'claude/thinking-start', id });
994
+ }
995
+ turn.streamedThinking.add(id);
996
+ emit(session, { type: 'claude/thinking-delta', id, text: String(params.delta || '') });
997
+ }
998
+
999
+ function onItemCompleted(session, { item }) {
1000
+ markActivity(session);
1001
+ const turn = session.currentTurn;
1002
+ if (!item) return;
1003
+ if (item.type === 'agentMessage') {
1004
+ if (item.text) session.lastResult = String(item.text);
1005
+ if (turn && !turn.streamedText.has(item.id)) {
1006
+ // No deltas arrived (e.g. a tool-less fast path); emit the full text.
1007
+ if (!turn.announcedText.has(item.id)) {
1008
+ turn.announcedText.add(item.id);
1009
+ emit(session, { type: 'claude/text-start', id: item.id });
1010
+ }
1011
+ turn.streamedText.add(item.id);
1012
+ emit(session, { type: 'claude/text-delta', id: item.id, text: String(item.text || '') });
1013
+ }
1014
+ } else if (item.type === 'reasoning') {
1015
+ const text = [...(item.summary || []), ...(item.content || [])].filter(Boolean).join('\n\n');
1016
+ if (text && turn && !turn.streamedThinking.has(item.id)) {
1017
+ if (!turn.announcedThinking.has(item.id)) {
1018
+ turn.announcedThinking.add(item.id);
1019
+ emit(session, { type: 'claude/thinking-start', id: item.id });
1020
+ }
1021
+ turn.streamedThinking.add(item.id);
1022
+ emit(session, { type: 'claude/thinking-delta', id: item.id, text });
1023
+ }
1024
+ } else if (item.type === 'commandExecution') {
1025
+ setState(session, 'running');
1026
+ emit(session, {
1027
+ type: 'claude/tool-result',
1028
+ id: item.id,
1029
+ output: `exit ${item.exitCode != null ? item.exitCode : '?'}: ${item.aggregatedOutput || displayCommand(item.command) || ''}`.slice(0, 4000),
1030
+ });
1031
+ } else if (item.type === 'fileChange') {
1032
+ const files = Array.isArray(item.changes)
1033
+ ? item.changes.map(change => change.path || '').filter(Boolean).join(', ')
1034
+ : '';
1035
+ emit(session, {
1036
+ type: 'claude/tool-use',
1037
+ id: item.id,
1038
+ name: 'Edit',
1039
+ input: { files, changes: item.changes || [] },
1040
+ });
1041
+ emit(session, {
1042
+ type: 'claude/tool-result',
1043
+ id: item.id,
1044
+ output: files.slice(0, 4000),
1045
+ changes: item.changes || [],
1046
+ });
1047
+ }
1048
+ // userMessage and unknown item types are ignored.
1049
+ }
1050
+
1051
+ function onTokenUsageUpdated(session, params) {
1052
+ const usage = params && params.tokenUsage;
1053
+ if (!usage) return;
1054
+ session.turnTokenUsage = usage; // the turn end emits the normalized event
1055
+ }
1056
+
1057
+ function onTurnErrorNotification(session, params) {
1058
+ const message = String(params && params.error && params.error.message || 'codex error');
1059
+ if (params && params.willRetry) {
1060
+ emit(session, { type: 'claude/retry', message: message.slice(0, 500) });
1061
+ return;
1062
+ }
1063
+ emitDiag(session, { type: 'claude/error', message: message.slice(0, 500) });
1064
+ session.turnHadError = true;
1065
+ }
1066
+
1067
+ function onTurnCompleted(session, params) {
1068
+ const turn = params && params.turn;
1069
+ const current = session.currentTurn;
1070
+ if (!current || (turn && turn.id && turn.id !== current.turnId)) return; // stale turn
1071
+ session.currentTurn = null;
1072
+ denyPendingApproval(session, 'turn completed'); // codex cancels unanswered approvals
1073
+ session.turns += 1;
1074
+ const usage = session.turnTokenUsage && session.turnTokenUsage.last;
1075
+ if (usage) {
1076
+ const input = Number(usage.inputTokens || 0);
1077
+ const output = Number(usage.outputTokens || 0);
1078
+ const cached = Number(usage.cachedInputTokens || 0);
1079
+ session.usage.inputTokens += input;
1080
+ session.usage.outputTokens += output;
1081
+ session.usage.cachedTokens += cached;
1082
+ session.peakTurnTokens = Math.max(session.peakTurnTokens || 0, input + output + cached);
1083
+ emit(session, { type: 'claude/usage', usage: { input_tokens: input, output_tokens: output } });
1084
+ }
1085
+ session.turnTokenUsage = null;
1086
+ const failed = turn && turn.status === 'failed';
1087
+ const interrupted = turn && turn.status === 'interrupted';
1088
+ if (failed) {
1089
+ const message = String(turn && turn.error && turn.error.message || 'codex turn failed').slice(0, 500);
1090
+ emitDiag(session, { type: 'claude/error', message });
1091
+ session.turnHadError = true;
1092
+ }
1093
+ emit(session, {
1094
+ type: 'claude/result',
1095
+ result: failed ? '' : session.lastResult,
1096
+ model: session.model,
1097
+ isError: Boolean(failed),
1098
+ });
1099
+ if (interrupted && !session.abortedByUser) emit(session, { type: 'claude/aborted' });
1100
+ session.abortedByUser = false;
1101
+ session.turnHadError = false;
1102
+ if (!session.endedAt) session.state = 'idle';
1103
+ emit(session, { type: 'claude/state', state: 'idle', turn: session.turns });
1104
+ // CLI-pinned sessions persist through their mirror; pure app-server ones
1105
+ // through this driver's own record store.
1106
+ if (!session.cliMirrorId) persistAppServerSession(session);
1107
+ // A completed turn proves the bridge is healthy again: transient crash
1108
+ // counters must not accumulate across self-healed bridges (P2-1).
1109
+ bridgeFailures = 0;
1110
+ if (current.resolve) current.resolve();
1111
+ }
1112
+
1113
+ // -- server-initiated requests (approvals) --------------------------------
1114
+
1115
+ async function handleServerRequest(method, params) {
1116
+ if (isApprovalMethod(method)) {
1117
+ const session = params && threads.get(params.threadId) && sessions.get(threads.get(params.threadId));
1118
+ if (!session) throw Object.assign(new Error('codex approval for an unknown thread'), { status: 404 });
1119
+ return await new Promise(resolve => {
1120
+ const requestId = `codex-perm-${nextPermissionId++}`;
1121
+ const described = describeApproval(method, params);
1122
+ session.pendingApproval = { requestId, method, params, resolve };
1123
+ session.pendingPermission = {
1124
+ requestId,
1125
+ status: 'pending',
1126
+ createdAt: new Date().toISOString(),
1127
+ summary: described.summary,
1128
+ };
1129
+ setState(session, 'tool-running');
1130
+ emit(session, { type: 'claude/permission-request', requestId, ...described, status: 'pending', createdAt: session.pendingPermission.createdAt });
1131
+ });
1132
+ }
1133
+ throw Object.assign(new Error(`codex app-server request ${method} is not supported`), { status: 501 });
1134
+ }
1135
+
1136
+ function isApprovalMethod(method) {
1137
+ return /requestApproval$/i.test(String(method)) || /^(applyPatchApproval|execCommandApproval)$/.test(String(method));
1138
+ }
1139
+
1140
+ function approvalDecisionFor(method, allow) {
1141
+ // Legacy v1 methods use approved/denied; the thread-item API uses accept/decline.
1142
+ if (/^(applyPatchApproval|execCommandApproval)$/.test(method)) return allow ? 'approved' : 'denied';
1143
+ return allow ? 'accept' : 'decline';
1144
+ }
1145
+
1146
+ function describeApproval(method, params) {
1147
+ const command = params && (params.command || '');
1148
+ const isLegacy = /^(applyPatchApproval|execCommandApproval)$/.test(method);
1149
+ const toolName = /file/i.test(method) ? 'Edit' : 'Bash';
1150
+ const title = isLegacy
1151
+ ? `Codex wants to run a command`
1152
+ : toolName === 'Edit' ? 'Codex wants to modify files' : 'Codex wants to run a command';
1153
+ const commandText = Array.isArray(command) ? command.join(' ') : String(command || '');
1154
+ return {
1155
+ toolName,
1156
+ title,
1157
+ description: toolName === 'Edit'
1158
+ ? `File changes requested (${(params && params.changes || []).length} files)`
1159
+ : commandText.slice(0, 200),
1160
+ promptPreview: JSON.stringify({ method, command: commandText, cwd: params && params.cwd || '' }).slice(0, 500),
1161
+ summary: {
1162
+ toolName,
1163
+ title,
1164
+ description: toolName === 'Edit' ? 'Codex file change approval' : commandText.slice(0, 200),
1165
+ promptPreview: commandText.slice(0, 500),
1166
+ },
1167
+ };
1168
+ }
1169
+
1170
+ function denyPendingApproval(session, reason) {
1171
+ if (!session.pendingApproval) return;
1172
+ const approval = session.pendingApproval;
1173
+ session.pendingApproval = null;
1174
+ session.pendingPermission = null;
1175
+ emit(session, {
1176
+ type: 'claude/permission-resolved',
1177
+ requestId: approval.requestId,
1178
+ allow: false,
1179
+ decision: approvalDecisionFor(approval.method, false),
1180
+ message: reason,
1181
+ resolvedAt: new Date().toISOString(),
1182
+ });
1183
+ approval.resolve({ decision: approvalDecisionFor(approval.method, false) });
1184
+ }
1185
+
1186
+ // -- CLI fallback plumbing ------------------------------------------------
1187
+
1188
+ // The CLI fallback runs inside a mirror session of the internal CLI runner;
1189
+ // its events are re-emitted on the owning app-server session. The mirror's
1190
+ // session-ready is suppressed while the driver session already announced
1191
+ // one, and (when the app-server already echoed the prompt) the mirror's
1192
+ // user-prompt is skipped to avoid duplicates.
1193
+ function attachMirror(session) {
1194
+ if (session.mirrorAttached) return;
1195
+ session.mirrorAttached = true;
1196
+ cliRunner.subscribe(session.cliMirrorId, event => {
1197
+ if (event.type === 'claude/session-ready'
1198
+ && session.outputBuffer.some(item => item.type === 'claude/session-ready')) return;
1199
+ if (event.type === 'claude/user-prompt' && session.skipNextMirrorPrompt) {
1200
+ session.skipNextMirrorPrompt = false;
1201
+ return;
1202
+ }
1203
+ // Keep the driver view in sync with the mirror it fronts: state events
1204
+ // carry the authoritative state/turn counters, usage stays on the mirror.
1205
+ if (event.type === 'claude/state') {
1206
+ session.state = event.state;
1207
+ if (typeof event.turn === 'number') session.turns = event.turn;
1208
+ }
1209
+ emit(session, event);
1210
+ });
1211
+ }
1212
+
1213
+ async function runCliTurn(session, value, profile, sendOptions, attachments, { emitPrompt }) {
1214
+ attachments = attachments || [];
1215
+ if (!session.cliMirrorId) {
1216
+ // From now on the mirror's CLI record is this session's source of
1217
+ // truth; this driver's app-server record must step aside (the CLI
1218
+ // runner may write its own record under the same file name).
1219
+ removeAppServerRecord(session.sessionId);
1220
+ const { sessionId: mirrorId } = cliRunner.startChatSession({
1221
+ slug: session.projectSlug,
1222
+ projectPath: session.projectPath,
1223
+ aiProfile: { id: session.aiProfileId, models: { default: session.selectedModel || '' } },
1224
+ permissionMode: session.permissionMode,
1225
+ effort: session.effort,
1226
+ title: session.title,
1227
+ });
1228
+ session.cliMirrorId = mirrorId;
1229
+ // An app-server thread (from live turns or a restored record) lives in
1230
+ // the same rollout store the CLI resumes, so the mirror inherits it and
1231
+ // `codex exec resume` continues the same conversation.
1232
+ if (session.threadId) {
1233
+ const mirror = cliRunner.getSession(mirrorId);
1234
+ if (mirror && !mirror.threadId) mirror.threadId = session.threadId;
1235
+ }
1236
+ session.skipNextMirrorPrompt = !emitPrompt;
1237
+ attachMirror(session);
1238
+ } else if (!emitPrompt) {
1239
+ session.skipNextMirrorPrompt = true;
1240
+ }
1241
+ const result = await cliRunner.sendInput(session.cliMirrorId, value, profile, {
1242
+ ...sendOptions,
1243
+ attachments: sendOptions.attachments,
1244
+ });
1245
+ const mirror = cliRunner.getSession(session.cliMirrorId);
1246
+ if (mirror) session.turnPromise = mirror.turnPromise;
1247
+ return result;
1248
+ }
1249
+
1250
+ // Adopt a session that only exists in the CLI runner's store (persisted by
1251
+ // a previous process whose app-server handshake fell back): the driver
1252
+ // session takes the SAME id so the sidebar handle stays stable, replays the
1253
+ // restored history through the mirror subscription, and pins every future
1254
+ // turn to the CLI transport so `codex resume <threadId>` keeps working.
1255
+ function adoptDiskSession(id) {
1256
+ const mirror = cliRunner.getSession(id); // lazily restores the record + events
1257
+ if (!mirror) return null;
1258
+ const session = {
1259
+ sessionId: id,
1260
+ projectSlug: mirror.projectSlug,
1261
+ projectPath: mirror.projectPath,
1262
+ state: 'idle',
1263
+ model: mirror.model || null,
1264
+ selectedModel: mirror.selectedModel || null,
1265
+ aiProfileId: mirror.aiProfileId || null,
1266
+ permissionMode: mirror.permissionMode || 'default',
1267
+ effort: mirror.effort || null,
1268
+ title: mirror.title || null,
1269
+ turns: Number(mirror.turns) || 0,
1270
+ startedAt: mirror.startedAt || new Date().toISOString(),
1271
+ endedAt: mirror.endedAt || null,
1272
+ pendingPermission: null,
1273
+ pendingApproval: null,
1274
+ outputBuffer: [],
1275
+ listeners: new Set(),
1276
+ threadId: null, // the CLI thread id lives on the mirror
1277
+ currentTurn: null,
1278
+ lastResult: mirror.lastResult || '',
1279
+ lastDiag: '',
1280
+ usage: {
1281
+ inputTokens: mirror.usage ? mirror.usage.inputTokens : 0,
1282
+ outputTokens: mirror.usage ? mirror.usage.outputTokens : 0,
1283
+ cachedTokens: mirror.usage ? mirror.usage.cachedTokens : 0,
1284
+ },
1285
+ peakTurnTokens: mirror.peakTurnTokens || 0,
1286
+ turnTokenUsage: null,
1287
+ cliMirrorId: id,
1288
+ mirrorAttached: false,
1289
+ pinnedCli: true,
1290
+ turnPromise: Promise.resolve(),
1291
+ };
1292
+ sessions.set(id, session);
1293
+ // The subscription replays the restored history into the driver session,
1294
+ // exactly as if the events had streamed live.
1295
+ attachMirror(session);
1296
+ return session;
1297
+ }
1298
+
1299
+ // Driver session ids must never collide with anything the persistence
1300
+ // stores know (live mirrors, CLI records or app-server records from
1301
+ // previous processes): a collision would shadow a restartable session
1302
+ // behind an empty one.
1303
+ function nextDriverSessionId() {
1304
+ const taken = new Set(sessions.keys());
1305
+ for (const item of cliRunner.listSessions()) taken.add(item.sessionId);
1306
+ const appServerIndex = syncAppServerDiskIndex();
1307
+ if (appServerIndex) for (const id of appServerIndex.keys()) taken.add(id);
1308
+ let id = `codex-${nextId++}`;
1309
+ while (taken.has(id)) id = `codex-${nextId++}`;
1310
+ return id;
1311
+ }
1312
+
1313
+ return runner;
1314
+ }
1315
+
1316
+ function modelForProfile(profile) {
1317
+ const model = resolveOpenAIModel(profile);
1318
+ return model && isSafeToken(model) ? model : null;
1319
+ }
1320
+
1321
+ // ---- app-server record persistence helpers (module level) -----------------
1322
+ // Records share the CLI runner's directory (~/.agent-terminal/cli-sessions)
1323
+ // but use their own schema so each store only ever claims its own files.
1324
+ const APP_SERVER_PERSIST_SCHEMA = 'codex-appserver-session/v1';
1325
+ const MAX_APP_SERVER_PERSISTED_EVENTS = 5000;
1326
+
1327
+ function safeFileStem(value) {
1328
+ return String(value).replace(/[^A-Za-z0-9._-]/g, '_');
1329
+ }
1330
+
1331
+ // Mirrors the CLI runner's resolution rules: explicit config always wins;
1332
+ // otherwise persistence is suppressed under the node:test runner so test
1333
+ // runs never touch the real session store. An injected bridge spawn does
1334
+ // NOT suppress persistence (it doubles the app-server child, not the CLI
1335
+ // subprocess the ephemeral rule was designed for).
1336
+ function resolveAppServerPersistence(options) {
1337
+ const configured = options.persistence && typeof options.persistence === 'object' ? options.persistence : {};
1338
+ const explicitlyConfigured = options.persistence != null;
1339
+ const ephemeralTestContext = Boolean(process.env.NODE_TEST_CONTEXT);
1340
+ const enabled = options.persistence !== null
1341
+ && configured.enabled !== false
1342
+ && (explicitlyConfigured || !ephemeralTestContext);
1343
+ const dir = path.resolve(configured.dir || path.join(os.homedir(), '.agent-terminal', 'cli-sessions'));
1344
+ return { enabled, dir };
1345
+ }
1346
+
1347
+ function effortFor(effort) {
1348
+ return ({ max: 'xhigh' })[effort] || effort;
1349
+ }
1350
+
1351
+ function codexCommandExists(command) {
1352
+ try {
1353
+ const invocation = resolveCliInvocation(command, ['app-server']);
1354
+ if (process.platform === 'win32') return path.isAbsolute(invocation.command);
1355
+ if (path.isAbsolute(command)) return fs.existsSync(command);
1356
+ const dirs = (process.env.PATH || '').split(path.delimiter);
1357
+ return dirs.some(dir => {
1358
+ try { fs.accessSync(path.join(dir, command)); return true; } catch { return false; }
1359
+ });
1360
+ } catch {
1361
+ return false;
1362
+ }
1363
+ }
1364
+
1365
+ function isSafeToken(value) {
1366
+ return /^[A-Za-z0-9._:/\[\]-]+$/.test(String(value));
1367
+ }
1368
+
1369
+ function isInjected(profile) { return Boolean(profile && profile.apiKey); }
1370
+
1371
+ // codex wraps shell commands as powershell.exe -Command '<cmd>' on Windows;
1372
+ // surface the inner command the user actually asked for, not the wrapper.
1373
+ function displayCommand(command) {
1374
+ const raw = String(command || '').trim();
1375
+ if (!raw) return '';
1376
+ const inner = raw.match(/-Command\s+'([\s\S]*)'\s*$/i) || raw.match(/-Command\s+"([\s\S]*)"\s*$/i);
1377
+ if (!inner) return raw;
1378
+ return inner[1].replace(/''/g, "'").trim() || raw;
1379
+ }
1380
+
1381
+ // Image materialization for the app-server transport (input items reference
1382
+ // local files; same on-disk discipline as the CLI runner: files exist only
1383
+ // for the duration of the turn).
1384
+ function prepareImages(projectPath, attachments = []) {
1385
+ const images = attachments.filter(item => item.mediaType.startsWith('image/'));
1386
+ if (!images.length) return { imagePaths: [], cleanup() {} };
1387
+ const root = path.join(path.resolve(projectPath), '.agent-terminal-attachments');
1388
+ fs.mkdirSync(root, { recursive: true });
1389
+ // Refuse a workspace symlink/junction that would write attachments elsewhere.
1390
+ if (fs.lstatSync(root).isSymbolicLink()) throw new Error('attachment directory must not be a symbolic link');
1391
+ const turnDir = path.join(root, crypto.randomUUID());
1392
+ fs.mkdirSync(turnDir);
1393
+ let cleaned = false;
1394
+ const cleanup = () => {
1395
+ if (cleaned) return;
1396
+ fs.rmSync(turnDir, { recursive: true, force: true });
1397
+ try { fs.rmdirSync(root); } catch (error) {
1398
+ if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(error.code)) throw error;
1399
+ }
1400
+ cleaned = true;
1401
+ };
1402
+ try {
1403
+ const imagePaths = images.map((item, index) => {
1404
+ const extension = { 'image/png': '.png', 'image/jpeg': '.jpg', 'image/webp': '.webp', 'image/gif': '.gif' }[item.mediaType];
1405
+ const file = path.join(turnDir, `${index}${extension}`);
1406
+ fs.writeFileSync(file, Buffer.from(item.data, 'base64'), { mode: 0o600 });
1407
+ return file;
1408
+ });
1409
+ return { imagePaths, cleanup };
1410
+ } catch (error) { cleanup(); throw error; }
1411
+ }
1412
+
1413
+ module.exports = { createCodexDriver, parseCodexLine, displayCommand, SANDBOX_FOR_PERMISSION };