@sanqianx/claude-ai-workbench 0.13.2 → 0.13.4

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