@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.
- package/LICENSE +201 -201
- package/README.md +156 -156
- package/apps/agent-terminal/app.css +652 -613
- package/apps/agent-terminal/app.js +143 -67
- package/apps/agent-terminal/conversation-composer.js +44 -0
- package/apps/agent-terminal/index.html +172 -170
- package/apps/agent-terminal/panel-host-proof.html +152 -152
- package/apps/agent-terminal/panel-proof-content.html +41 -41
- package/apps/agent-terminal/right-workspace-dock.css +189 -159
- package/apps/agent-terminal/right-workspace-dock.js +652 -521
- package/apps/agent-terminal/user-question-card.js +68 -0
- package/apps/ai-profile-settings-host/index.html +25 -25
- package/apps/ai-profile-settings-host/page.css +1 -1
- package/apps/ai-profile-settings-host/page.js +113 -113
- package/apps/minimal-test-host/README.md +15 -15
- package/apps/minimal-test-host/host.css +2 -2
- package/apps/minimal-test-host/host.js +35 -35
- package/apps/minimal-test-host/index.html +58 -58
- package/apps/minimal-test-host/optional-mock/fault-injection.js +15 -15
- package/docs/sdk/FILE_LAYOUT.md +31 -31
- package/docs/sdk/INTEGRATION.md +126 -126
- package/docs/sdk/README.md +21 -21
- package/docs/sdk/SESSION_EVENTS.md +15 -13
- package/docs/sdk/openapi.yaml +16 -1
- package/package.json +1 -1
- package/packages/client/README.md +29 -29
- package/packages/client/index.d.ts +122 -122
- package/packages/client/index.js +260 -260
- package/packages/client/package.json +2 -2
- package/packages/contracts/README.md +9 -9
- package/packages/contracts/index.d.ts +519 -517
- package/packages/contracts/index.js +312 -312
- package/packages/contracts/package.json +1 -1
- package/packages/core/README.md +8 -8
- package/packages/core/index.d.ts +76 -76
- package/packages/core/index.js +222 -222
- package/packages/core/package.json +2 -2
- package/packages/server/README.md +17 -17
- package/packages/server/bin/agent-terminal-server.js +42 -42
- package/packages/server/bin/claude-workbench.js +225 -225
- package/packages/server/index.d.ts +171 -171
- package/packages/server/index.js +230 -230
- package/packages/server/lib/agent-discovery.js +133 -133
- package/packages/server/lib/agent-registry.js +143 -143
- package/packages/server/lib/agent-spawn-env.js +17 -17
- package/packages/server/lib/attachment-store.js +78 -78
- package/packages/server/lib/bridges/app-server-bridge.js +373 -373
- package/packages/server/lib/bridges/opencode-server-bridge.js +506 -506
- package/packages/server/lib/capture-settings-store.js +35 -35
- package/packages/server/lib/claude-history-recovery.js +87 -87
- package/packages/server/lib/cli-session-recovery.js +28 -28
- package/packages/server/lib/context-resolver.js +55 -55
- package/packages/server/lib/conversation-capture.js +51 -51
- package/packages/server/lib/delegation-phase-store.js +357 -357
- package/packages/server/lib/delegation-store.js +474 -468
- package/packages/server/lib/diagnostics.js +32 -32
- package/packages/server/lib/drivers/cli-json-runner.js +974 -974
- package/packages/server/lib/drivers/codex-driver.js +1461 -1442
- package/packages/server/lib/drivers/opencode-driver.js +1554 -1554
- package/packages/server/lib/drivers/zcode-driver.js +1250 -1250
- package/packages/server/lib/drivers/zcode-profile-runtime.js +71 -71
- package/packages/server/lib/event-stream.js +138 -138
- package/packages/server/lib/folder-picker.js +623 -623
- package/packages/server/lib/http-handler.js +339 -339
- package/packages/server/lib/json-file.js +26 -26
- package/packages/server/lib/knowledge-context.js +33 -33
- package/packages/server/lib/model-detection.js +295 -295
- package/packages/server/lib/parent-context.js +44 -0
- package/packages/server/lib/profile-store.js +283 -283
- package/packages/server/lib/project-store.js +102 -102
- package/packages/server/lib/runtime-loader.js +120 -120
- package/packages/server/lib/session-archive-store.js +53 -53
- package/packages/server/lib/session-history.js +612 -612
- package/packages/server/lib/system-doctor.js +125 -125
- package/packages/server/lib/ui-preferences-store.js +48 -48
- package/packages/server/lib/user-questions.js +67 -0
- package/packages/server/lib/workbench-service.js +1696 -1675
- package/packages/server/package.json +2 -2
- package/packages/ui/README.md +30 -30
- package/packages/ui/ai-profile-settings.d.ts +45 -45
- package/packages/ui/ai-profile-settings.js +283 -283
- package/packages/ui/claude-terminal.d.ts +31 -31
- package/packages/ui/claude-terminal.js +343 -343
- package/packages/ui/index.d.ts +18 -18
- package/packages/ui/index.js +51 -51
- package/packages/ui/package.json +2 -2
- package/packages/ui/theme.css +13 -13
- package/src/assets/claude-prompts.json +21 -21
- package/src/backend/http/ai-profile-routes.js +61 -61
- package/src/backend/http/claude-routes.js +170 -170
- package/src/backend/http/node-helpers.js +45 -45
- package/src/backend/index.js +21 -21
- package/src/backend/lib/ai-adapter.js +144 -144
- package/src/backend/lib/ai-profile-store.js +259 -259
- package/src/backend/lib/ai-vendor-presets.js +83 -83
- package/src/backend/lib/ai-workspace.js +140 -140
- package/src/backend/lib/automation-config.js +253 -253
- package/src/backend/lib/claude-cli-runner.js +2408 -2392
- package/src/backend/lib/data-dir.js +169 -169
- package/src/backend/lib/llm-client.js +176 -176
- package/src/backend/lib/model-context-windows.js +82 -82
- package/src/backend/lib/native-task-control.js +217 -217
- package/src/backend/lib/native-task-history-index.js +475 -475
- package/src/backend/lib/native-task-history-reader.js +526 -526
- package/src/backend/lib/native-task-messages.js +457 -457
- package/src/backend/lib/native-task-registry.js +325 -325
- package/src/backend/lib/native-task-runtime.js +242 -242
- package/src/backend/lib/native-task-trace.js +440 -440
- package/src/backend/lib/native-task-transcript.js +393 -393
- package/src/backend/lib/prompt-registry.js +166 -166
- package/src/backend/lib/runtime-config.js +121 -121
- package/src/backend/lib/sdk-session-stream.js +143 -143
- package/src/backend/lib/sdk-turn-stream.js +16 -16
- package/src/backend/lib/store-scope.js +131 -131
- package/src/backend/lib/task-identity.js +104 -104
- package/src/backend/lib/task-state-projection.js +58 -58
- package/src/index.js +7 -7
- package/src/sdk-wave-a.ts +1 -1
- package/src/sdk-wave-b.ts +1 -1
- package/src/sdk-wave-c.ts +1 -1
- package/src/sdk-wave-d.ts +1 -1
- 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 {
|
|
11
|
-
const {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
const
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
//
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
||
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
//
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
//
|
|
86
|
-
|
|
87
|
-
if (isInjected(profile)) args.push(
|
|
88
|
-
if (
|
|
89
|
-
if (
|
|
90
|
-
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
args.push('-');
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
[`model_providers.${PROVIDER_ID}.
|
|
117
|
-
[`model_providers.${PROVIDER_ID}.
|
|
118
|
-
[`model_providers.${PROVIDER_ID}.
|
|
119
|
-
[`model_providers.${PROVIDER_ID}.
|
|
120
|
-
[`model_providers.${PROVIDER_ID}.
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
if (type === '
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
out.push({ type: 'claude/text-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
out.push({ type: 'claude/thinking-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
//
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
//
|
|
230
|
-
//
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
const
|
|
235
|
-
const
|
|
236
|
-
const
|
|
237
|
-
|
|
238
|
-
//
|
|
239
|
-
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
//
|
|
243
|
-
//
|
|
244
|
-
|
|
245
|
-
let
|
|
246
|
-
let
|
|
247
|
-
let
|
|
248
|
-
let
|
|
249
|
-
let
|
|
250
|
-
let
|
|
251
|
-
let
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
session
|
|
274
|
-
|
|
275
|
-
//
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
//
|
|
296
|
-
//
|
|
297
|
-
//
|
|
298
|
-
//
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
if (!
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
const
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
//
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
//
|
|
390
|
-
// the
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
if (
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
//
|
|
402
|
-
//
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
const
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
//
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
record.
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
if (!
|
|
462
|
-
|
|
463
|
-
const
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
//
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
//
|
|
508
|
-
//
|
|
509
|
-
//
|
|
510
|
-
//
|
|
511
|
-
//
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
const
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
const
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
.
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
byId.
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
return
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
//
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
const
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
session.
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
//
|
|
575
|
-
//
|
|
576
|
-
//
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
if (
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
const
|
|
584
|
-
|
|
585
|
-
if (
|
|
586
|
-
|
|
587
|
-
session.
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
if (sendOptions.
|
|
591
|
-
session.
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
await
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
//
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
...(session.
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
if (
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
session.
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
const
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
if (!session
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
const
|
|
669
|
-
session.pendingApproval
|
|
670
|
-
|
|
671
|
-
const
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
session.
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
const
|
|
725
|
-
session.
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
}
|
|
790
|
-
if (
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
session
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
if (
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
if (error.code === '
|
|
909
|
-
return
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
return
|
|
914
|
-
}
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
case '
|
|
956
|
-
case 'item/
|
|
957
|
-
case '
|
|
958
|
-
case '
|
|
959
|
-
case '
|
|
960
|
-
case '
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
}
|
|
1008
|
-
turn.
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
}
|
|
1022
|
-
turn.
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
turn.
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
turn.
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
? item.
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
session.
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
type: 'claude/
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
emit(session, { type: 'claude/
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
if (!session.
|
|
1135
|
-
|
|
1136
|
-
//
|
|
1137
|
-
|
|
1138
|
-
if (
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
};
|
|
1157
|
-
setState(session, '
|
|
1158
|
-
emit(session, { type: 'claude/permission-request',
|
|
1159
|
-
});
|
|
1160
|
-
}
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
return
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
}
|
|
1256
|
-
session
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
if (!
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
//
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
const
|
|
1354
|
-
const
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
}
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
}
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
}
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
function
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const crypto = require('crypto');
|
|
7
|
+
const { writeJson } = require('../json-file');
|
|
8
|
+
const { createCliJsonRunner, resolveCliInvocation } = require('./cli-json-runner');
|
|
9
|
+
const { createAppServerBridge } = require('../bridges/app-server-bridge');
|
|
10
|
+
const { 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 };
|