@sanqianx/claude-ai-workbench 0.13.2 → 0.13.3

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