glad-web 1.0.41 → 1.0.43
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/lib/codex/structured-session.js +304 -83
- package/lib/session/session-manager.js +1 -1
- package/lib/web/codex.js +38 -7
- package/lib/web/core.js +2 -1
- package/package.json +1 -1
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
const { EventEmitter } = require('events');
|
|
2
2
|
const { spawn } = require('child_process');
|
|
3
|
-
const
|
|
3
|
+
const net = require('net');
|
|
4
4
|
const crypto = require('crypto');
|
|
5
|
+
const WebSocket = require('ws');
|
|
5
6
|
const PTYManager = require('../session/pty-manager');
|
|
6
7
|
|
|
7
8
|
const PERMISSION_MODES = new Set(['untrusted', 'on-request', 'never']);
|
|
@@ -9,6 +10,10 @@ const SANDBOX_MODES = new Set(['read-only', 'workspace-write', 'danger-full-acce
|
|
|
9
10
|
const FALLBACK_EFFORTS = ['minimal', 'low', 'medium', 'high', 'xhigh', 'ultra'];
|
|
10
11
|
const DEFAULT_ABORT_GRACE_MS = 5000;
|
|
11
12
|
const PROCESS_SHUTDOWN_TIMEOUT_MS = 2000;
|
|
13
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 30000;
|
|
14
|
+
const RESUME_REQUEST_TIMEOUT_MS = 60000;
|
|
15
|
+
const RESUME_HISTORY_TURN_LIMIT = 50;
|
|
16
|
+
const APP_SERVER_CONNECT_TIMEOUT_MS = 10000;
|
|
12
17
|
|
|
13
18
|
function turnKey(threadId, turnId) {
|
|
14
19
|
return `${String(threadId || '')}\n${String(turnId || '')}`;
|
|
@@ -56,7 +61,7 @@ function toTimestampMs(value) {
|
|
|
56
61
|
}
|
|
57
62
|
|
|
58
63
|
function appServerSpawnOptions({ cwd, env, platform = process.platform }) {
|
|
59
|
-
const options = { cwd, env, stdio: ['
|
|
64
|
+
const options = { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] };
|
|
60
65
|
|
|
61
66
|
// Globally installed npm CLIs expose a .cmd shim on Windows. child_process.spawn
|
|
62
67
|
// does not resolve that shim without a shell, causing `spawn codex ENOENT`.
|
|
@@ -72,6 +77,50 @@ function appServerSpawnOptions({ cwd, env, platform = process.platform }) {
|
|
|
72
77
|
return options;
|
|
73
78
|
}
|
|
74
79
|
|
|
80
|
+
function reserveLoopbackPort() {
|
|
81
|
+
return new Promise((resolve, reject) => {
|
|
82
|
+
const server = net.createServer();
|
|
83
|
+
server.unref();
|
|
84
|
+
server.once('error', reject);
|
|
85
|
+
server.listen(0, '127.0.0.1', () => {
|
|
86
|
+
const address = server.address();
|
|
87
|
+
const port = typeof address === 'object' && address ? address.port : 0;
|
|
88
|
+
server.close(error => {
|
|
89
|
+
if (error) reject(error);
|
|
90
|
+
else if (!port) reject(new Error('Unable to reserve a loopback port for Codex app-server'));
|
|
91
|
+
else resolve(port);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function connectAppServerWebSocket(url, timeoutMs = APP_SERVER_CONNECT_TIMEOUT_MS) {
|
|
98
|
+
const deadline = Date.now() + timeoutMs;
|
|
99
|
+
let lastError = new Error('Codex app-server WebSocket did not become ready');
|
|
100
|
+
while (Date.now() < deadline) {
|
|
101
|
+
try {
|
|
102
|
+
return await new Promise((resolve, reject) => {
|
|
103
|
+
const socket = new WebSocket(url);
|
|
104
|
+
const onOpen = () => {
|
|
105
|
+
socket.off('error', onError);
|
|
106
|
+
resolve(socket);
|
|
107
|
+
};
|
|
108
|
+
const onError = error => {
|
|
109
|
+
socket.off('open', onOpen);
|
|
110
|
+
socket.terminate();
|
|
111
|
+
reject(error);
|
|
112
|
+
};
|
|
113
|
+
socket.once('open', onOpen);
|
|
114
|
+
socket.once('error', onError);
|
|
115
|
+
});
|
|
116
|
+
} catch (error) {
|
|
117
|
+
lastError = error;
|
|
118
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
throw lastError;
|
|
122
|
+
}
|
|
123
|
+
|
|
75
124
|
function forceKillProcessTree(child, options = {}) {
|
|
76
125
|
const platform = options.platform || process.platform;
|
|
77
126
|
const killGroup = options.killGroup || process.kill;
|
|
@@ -264,9 +313,13 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
264
313
|
this.requestId = 0;
|
|
265
314
|
this.pendingRequests = new Map();
|
|
266
315
|
this.process = null;
|
|
316
|
+
this.rpcSocket = null;
|
|
267
317
|
this.processReady = null;
|
|
268
318
|
this.processShutdown = null;
|
|
269
319
|
this.needsThreadResume = false;
|
|
320
|
+
this.resuming = false;
|
|
321
|
+
this.resumePromise = null;
|
|
322
|
+
this.resumeTarget = null;
|
|
270
323
|
this.aborting = false;
|
|
271
324
|
this.abortTargets = new Map();
|
|
272
325
|
this.abortTimer = null;
|
|
@@ -352,13 +405,13 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
352
405
|
effectivePermissionMode: this.effectivePermissionMode, effectiveSandboxMode: this.effectiveSandboxMode,
|
|
353
406
|
model: this.model, effort: this.effort,
|
|
354
407
|
status: this.status, threadId: this.threadId, presentation: this.presentation,
|
|
355
|
-
aborting: this.aborting,
|
|
356
|
-
canAbort: this.presentation === 'structured' && this.status !== 'idle' && !this.aborting,
|
|
408
|
+
aborting: this.aborting, resuming: this.resuming,
|
|
409
|
+
canAbort: this.presentation === 'structured' && (this.status !== 'idle' || this.resuming) && !this.aborting,
|
|
357
410
|
canCompact: this.presentation === 'structured' && this.status === 'idle' && !this.compacting
|
|
358
|
-
&& !this.aborting && Boolean(this.threadId),
|
|
411
|
+
&& !this.aborting && !this.resuming && Boolean(this.threadId),
|
|
359
412
|
compacting: this.compacting,
|
|
360
413
|
canSwitchToTerminal: this.presentation === 'structured' && this.status === 'idle'
|
|
361
|
-
&& !this.aborting && Boolean(this.threadId),
|
|
414
|
+
&& !this.aborting && !this.resuming && Boolean(this.threadId),
|
|
362
415
|
canSwitchToStructured: this.presentation === 'terminal',
|
|
363
416
|
pendingPermissionCount: this.pendingPermissions.size, activeSubagentCount, models: this.models };
|
|
364
417
|
}
|
|
@@ -427,23 +480,51 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
427
480
|
async ensureProcess() {
|
|
428
481
|
if (this.processShutdown) await this.processShutdown;
|
|
429
482
|
if (this.processReady) return this.processReady;
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
this.
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
483
|
+
let startup;
|
|
484
|
+
startup = (async () => {
|
|
485
|
+
const port = await reserveLoopbackPort();
|
|
486
|
+
return new Promise((resolve, reject) => {
|
|
487
|
+
const listenUrl = `ws://127.0.0.1:${port}`;
|
|
488
|
+
// ARM64 Docker 中的大响应可能让非阻塞 stdio pipe 返回 EAGAIN。
|
|
489
|
+
// loopback WebSocket 保持连接只在容器内部可见,同时避开该传输缺陷。
|
|
490
|
+
const child = spawn(this.tool.command, ['app-server', '--listen', listenUrl], appServerSpawnOptions({
|
|
491
|
+
cwd: this.workingDir,
|
|
492
|
+
env: { ...process.env }
|
|
493
|
+
}));
|
|
494
|
+
this.process = child;
|
|
495
|
+
const fail = error => {
|
|
496
|
+
if (this.process === child) void this.disconnectProcess(true, error);
|
|
497
|
+
if (this.processReady === startup) this.processReady = null;
|
|
498
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
499
|
+
};
|
|
500
|
+
const transportFailed = error => {
|
|
501
|
+
if (this.process !== child) return;
|
|
502
|
+
const failure = error instanceof Error ? error : new Error(String(error || 'Codex app-server transport closed'));
|
|
503
|
+
this.logger.debugInfo?.(`[codex-app-server] transport failed: ${failure.message}`);
|
|
504
|
+
this.needsThreadResume = Boolean(this.threadId || this.resumeTarget);
|
|
505
|
+
if (this.running && this.presentation === 'structured') {
|
|
506
|
+
const activeTurn = Boolean(this.currentTurnId || this.status === 'running' || this.status === 'waiting_approval');
|
|
507
|
+
this.clearAbortState(false);
|
|
508
|
+
this.compacting = false;
|
|
509
|
+
this.append({ kind: 'event', level: 'error', text: `Codex app-server connection failed: ${failure.message}` });
|
|
510
|
+
this.emitEvent({ type: 'runtime-disconnected', activeTurn, turnId: this.currentTurnId || null });
|
|
511
|
+
if (this.status !== 'idle') this.setStatus('idle');
|
|
512
|
+
else this.emitControlState();
|
|
513
|
+
}
|
|
514
|
+
void this.disconnectProcess(true, failure);
|
|
515
|
+
};
|
|
516
|
+
child.once('error', transportFailed);
|
|
517
|
+
child.once('exit', code => {
|
|
518
|
+
if (this.process !== child) return;
|
|
444
519
|
this.process = null;
|
|
445
520
|
this.processReady = null;
|
|
446
|
-
|
|
521
|
+
const socket = this.rpcSocket;
|
|
522
|
+
this.rpcSocket = null;
|
|
523
|
+
socket?.terminate();
|
|
524
|
+
for (const request of this.pendingRequests.values()) {
|
|
525
|
+
clearTimeout(request.timer);
|
|
526
|
+
request.reject(new Error(`Codex app-server exited (${code})`));
|
|
527
|
+
}
|
|
447
528
|
this.pendingRequests.clear();
|
|
448
529
|
if (this.running && this.presentation === 'structured') {
|
|
449
530
|
const activeTurn = Boolean(this.currentTurnId || this.status === 'running' || this.status === 'waiting_approval');
|
|
@@ -451,48 +532,102 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
451
532
|
this.clearAbortState(false);
|
|
452
533
|
this.compacting = false;
|
|
453
534
|
this.append({ kind: 'event', level: 'error', text: 'Codex app-server exited.' });
|
|
454
|
-
this.emitEvent({
|
|
455
|
-
type: 'runtime-disconnected',
|
|
456
|
-
activeTurn,
|
|
457
|
-
turnId: this.currentTurnId || null
|
|
458
|
-
});
|
|
535
|
+
this.emitEvent({ type: 'runtime-disconnected', activeTurn, turnId: this.currentTurnId || null });
|
|
459
536
|
this.setStatus('idle');
|
|
460
537
|
}
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
538
|
+
});
|
|
539
|
+
const logOutput = data => this.logger.debugInfo?.(`[codex-app-server] ${String(data).trim()}`);
|
|
540
|
+
child.stdout.on('data', logOutput);
|
|
541
|
+
child.stderr.on('data', logOutput);
|
|
542
|
+
connectAppServerWebSocket(listenUrl).then(socket => {
|
|
543
|
+
if (this.process !== child) {
|
|
544
|
+
socket.terminate();
|
|
545
|
+
throw new Error('Codex app-server stopped while connecting');
|
|
546
|
+
}
|
|
547
|
+
this.rpcSocket = socket;
|
|
548
|
+
socket.on('message', data => this.handleRpcLine(String(data)));
|
|
549
|
+
socket.once('error', transportFailed);
|
|
550
|
+
socket.once('close', (code, reason) => {
|
|
551
|
+
transportFailed(new Error(`Codex app-server WebSocket closed (${code}${reason?.length ? `: ${String(reason)}` : ''})`));
|
|
552
|
+
});
|
|
553
|
+
return this.request('initialize', {
|
|
554
|
+
clientInfo: { name: 'glad-web', title: 'Glad', version: '1.0' },
|
|
555
|
+
capabilities: { experimentalApi: true }
|
|
556
|
+
}, { fatalOnTimeout: true });
|
|
557
|
+
}).then(async () => {
|
|
558
|
+
this.notify('initialized', {});
|
|
468
559
|
try { await this.refreshConfigDefaults(); } catch (error) { this.logger.debugInfo?.(`[codex-app-server] config/read failed: ${error.message}`); }
|
|
469
560
|
try { await this.refreshModels(); } catch (error) { this.logger.debugInfo?.(`[codex-app-server] model/list failed: ${error.message}`); }
|
|
470
561
|
resolve();
|
|
471
562
|
}).catch(fail);
|
|
472
|
-
|
|
473
|
-
|
|
563
|
+
});
|
|
564
|
+
})();
|
|
565
|
+
this.processReady = startup;
|
|
566
|
+
try {
|
|
567
|
+
return await startup;
|
|
568
|
+
} catch (error) {
|
|
569
|
+
if (this.processReady === startup) this.processReady = null;
|
|
570
|
+
throw error;
|
|
571
|
+
}
|
|
474
572
|
}
|
|
475
573
|
|
|
476
|
-
request(method, params) {
|
|
477
|
-
if (!this.process ||
|
|
574
|
+
request(method, params, options = {}) {
|
|
575
|
+
if (!this.process || this.rpcSocket?.readyState !== WebSocket.OPEN) {
|
|
576
|
+
return Promise.reject(new Error('Codex app-server is not connected'));
|
|
577
|
+
}
|
|
578
|
+
const child = this.process;
|
|
579
|
+
const socket = this.rpcSocket;
|
|
580
|
+
const timeoutMs = Number(options.timeoutMs) > 0 ? Number(options.timeoutMs) : DEFAULT_REQUEST_TIMEOUT_MS;
|
|
581
|
+
const fatalOnTimeout = Boolean(options.fatalOnTimeout);
|
|
478
582
|
const id = ++this.requestId;
|
|
479
583
|
return new Promise((resolve, reject) => {
|
|
480
|
-
const
|
|
584
|
+
const fail = error => {
|
|
585
|
+
const pending = this.pendingRequests.get(id);
|
|
586
|
+
if (!pending) return;
|
|
587
|
+
clearTimeout(pending.timer);
|
|
588
|
+
this.pendingRequests.delete(id);
|
|
589
|
+
reject(error);
|
|
590
|
+
};
|
|
591
|
+
const timer = setTimeout(() => {
|
|
592
|
+
const error = new Error(`${method} timed out after ${Math.max(1, Math.round(timeoutMs / 1000))} seconds`);
|
|
593
|
+
fail(error);
|
|
594
|
+
// 生命周期请求超时后连接状态未知,必须启动新的 app-server。
|
|
595
|
+
if (fatalOnTimeout && this.process === child) void this.disconnectProcess(true, error);
|
|
596
|
+
}, timeoutMs);
|
|
481
597
|
this.pendingRequests.set(id, { resolve, reject, timer });
|
|
482
|
-
|
|
598
|
+
try {
|
|
599
|
+
socket.send(JSON.stringify({ jsonrpc: '2.0', id, method, params }), error => {
|
|
600
|
+
if (!error) return;
|
|
601
|
+
fail(error);
|
|
602
|
+
if (this.process === child) void this.disconnectProcess(true, error);
|
|
603
|
+
});
|
|
604
|
+
} catch (error) {
|
|
605
|
+
fail(error);
|
|
606
|
+
if (this.process === child) void this.disconnectProcess(true, error);
|
|
607
|
+
}
|
|
483
608
|
});
|
|
484
609
|
}
|
|
485
610
|
|
|
486
611
|
notify(method, params) {
|
|
487
|
-
|
|
488
|
-
this.process.stdin.write(JSON.stringify({ jsonrpc: '2.0', method, params }) + '\n');
|
|
489
|
-
return true;
|
|
612
|
+
return this.sendRpcMessage({ jsonrpc: '2.0', method, params });
|
|
490
613
|
}
|
|
491
614
|
|
|
492
615
|
respond(id, result) {
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
616
|
+
return this.sendRpcMessage({ jsonrpc: '2.0', id, result });
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
sendRpcMessage(message) {
|
|
620
|
+
if (!this.process || this.rpcSocket?.readyState !== WebSocket.OPEN) return false;
|
|
621
|
+
const child = this.process;
|
|
622
|
+
try {
|
|
623
|
+
this.rpcSocket.send(JSON.stringify(message), error => {
|
|
624
|
+
if (error && this.process === child) void this.disconnectProcess(true, error);
|
|
625
|
+
});
|
|
626
|
+
return true;
|
|
627
|
+
} catch (error) {
|
|
628
|
+
if (this.process === child) void this.disconnectProcess(true, error);
|
|
629
|
+
return false;
|
|
630
|
+
}
|
|
496
631
|
}
|
|
497
632
|
|
|
498
633
|
handleRpcLine(line) {
|
|
@@ -834,6 +969,37 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
834
969
|
return config;
|
|
835
970
|
}
|
|
836
971
|
|
|
972
|
+
async readRecentThread(thread, limit = RESUME_HISTORY_TURN_LIMIT, options = {}) {
|
|
973
|
+
const summary = thread && typeof thread === 'object' ? thread : {};
|
|
974
|
+
const embeddedTurns = Array.isArray(summary.turns) ? summary.turns.slice(-limit) : [];
|
|
975
|
+
try {
|
|
976
|
+
const newestFirst = [];
|
|
977
|
+
let cursor = null;
|
|
978
|
+
do {
|
|
979
|
+
const result = await this.request('thread/turns/list', {
|
|
980
|
+
threadId: summary.id,
|
|
981
|
+
cursor,
|
|
982
|
+
limit: Math.min(100, limit - newestFirst.length),
|
|
983
|
+
sortDirection: 'desc',
|
|
984
|
+
itemsView: 'full'
|
|
985
|
+
}, options);
|
|
986
|
+
newestFirst.push(...(Array.isArray(result?.data) ? result.data : []));
|
|
987
|
+
cursor = result?.nextCursor || null;
|
|
988
|
+
} while (cursor && newestFirst.length < limit);
|
|
989
|
+
return {
|
|
990
|
+
...summary,
|
|
991
|
+
// app-server 默认从新到旧返回,页面仍按时间顺序展示。
|
|
992
|
+
turns: newestFirst.slice().reverse(),
|
|
993
|
+
historyNextCursor: cursor
|
|
994
|
+
};
|
|
995
|
+
} catch (error) {
|
|
996
|
+
this.logger.debugInfo?.(`[codex-app-server] paginated history unavailable for ${summary.id || 'unknown'}: ${error.message}`);
|
|
997
|
+
const unsupported = /method.*not found|unsupported|experimental/i.test(String(error.message || ''));
|
|
998
|
+
if (options.fatalOnTimeout && !unsupported) throw error;
|
|
999
|
+
return { ...summary, turns: embeddedTurns, historyNextCursor: null };
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
|
|
837
1003
|
async listResumeThreads() {
|
|
838
1004
|
await this.ensureProcess();
|
|
839
1005
|
const result = await this.request('thread/list', {
|
|
@@ -849,8 +1015,8 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
849
1015
|
for (const item of threads) {
|
|
850
1016
|
let questions = [];
|
|
851
1017
|
try {
|
|
852
|
-
const history = await this.
|
|
853
|
-
questions = recentUserQuestions(history
|
|
1018
|
+
const history = await this.readRecentThread(item, 8);
|
|
1019
|
+
questions = recentUserQuestions(history);
|
|
854
1020
|
} catch (error) {
|
|
855
1021
|
this.logger.debugInfo?.(`[codex-app-server] unable to read resume preview for ${item.id}: ${error.message}`);
|
|
856
1022
|
}
|
|
@@ -892,20 +1058,24 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
892
1058
|
const threads = (result?.data || []).filter(item => !item.parentThreadId);
|
|
893
1059
|
const histories = await Promise.all(threads.map(async item => {
|
|
894
1060
|
try {
|
|
895
|
-
const history = await this.
|
|
1061
|
+
const history = await this.readRecentThread(item, 200);
|
|
896
1062
|
const fallbackTimestamp = toTimestampMs(item.updatedAt || item.createdAt);
|
|
897
|
-
return
|
|
898
|
-
|
|
1063
|
+
return {
|
|
1064
|
+
prompts: userPromptsFromThread(history || { id: item.id, turns: [] }, fallbackTimestamp)
|
|
1065
|
+
.map(prompt => ({ ...prompt, threadId: prompt.threadId || item.id })),
|
|
1066
|
+
capped: Boolean(history.historyNextCursor)
|
|
1067
|
+
};
|
|
899
1068
|
} catch (error) {
|
|
900
1069
|
this.logger.debugInfo?.(`[codex-app-server] unable to read prompt history for ${item.id}: ${error.message}`);
|
|
901
|
-
return [];
|
|
1070
|
+
return { prompts: [], capped: false };
|
|
902
1071
|
}
|
|
903
1072
|
}));
|
|
904
|
-
prompts.push(...histories.
|
|
1073
|
+
prompts.push(...histories.flatMap(history => history.prompts));
|
|
1074
|
+
if (histories.some(history => history.capped)) capped = true;
|
|
905
1075
|
cursor = result?.nextCursor || null;
|
|
906
1076
|
pageCount += 1;
|
|
907
1077
|
if (prompts.length >= 200 || pageCount >= 5) {
|
|
908
|
-
capped = Boolean(cursor) || prompts.length > 200;
|
|
1078
|
+
capped = capped || Boolean(cursor) || prompts.length > 200;
|
|
909
1079
|
break;
|
|
910
1080
|
}
|
|
911
1081
|
} while (cursor);
|
|
@@ -1028,7 +1198,10 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
1028
1198
|
|
|
1029
1199
|
async resumeThreadAfterProcessRestart() {
|
|
1030
1200
|
if (!this.threadId || !this.needsThreadResume) return false;
|
|
1031
|
-
const result = await this.request('thread/resume', this.threadResumeParams(this.threadId)
|
|
1201
|
+
const result = await this.request('thread/resume', this.threadResumeParams(this.threadId), {
|
|
1202
|
+
timeoutMs: RESUME_REQUEST_TIMEOUT_MS,
|
|
1203
|
+
fatalOnTimeout: true
|
|
1204
|
+
});
|
|
1032
1205
|
this.needsThreadResume = false;
|
|
1033
1206
|
this.model = result.model || result.thread?.model || this.model;
|
|
1034
1207
|
this.effort = result.reasoningEffort || result.thread?.reasoningEffort || this.effort;
|
|
@@ -1051,7 +1224,7 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
1051
1224
|
const images = (Array.isArray(attachments) ? attachments : [])
|
|
1052
1225
|
.filter(item => item && typeof item.path === 'string' && item.path);
|
|
1053
1226
|
if ((!prompt && images.length === 0) || this.presentation !== 'structured'
|
|
1054
|
-
|| this.status !== 'idle' || this.aborting) return false;
|
|
1227
|
+
|| this.status !== 'idle' || this.aborting || this.resuming) return false;
|
|
1055
1228
|
this.hasUnreadCompletion = false;
|
|
1056
1229
|
this.promptHistoryCache = null;
|
|
1057
1230
|
this.append({
|
|
@@ -1105,7 +1278,8 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
1105
1278
|
}
|
|
1106
1279
|
|
|
1107
1280
|
async compactContext() {
|
|
1108
|
-
if (!this.threadId || this.presentation !== 'structured' || this.status !== 'idle'
|
|
1281
|
+
if (!this.threadId || this.presentation !== 'structured' || this.status !== 'idle'
|
|
1282
|
+
|| this.aborting || this.resuming) return false;
|
|
1109
1283
|
await this.ensureProcess();
|
|
1110
1284
|
this.compacting = true;
|
|
1111
1285
|
this.setStatus('running');
|
|
@@ -1156,8 +1330,17 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
1156
1330
|
}
|
|
1157
1331
|
|
|
1158
1332
|
abort(reason = 'Aborted by user') {
|
|
1159
|
-
if (this.presentation !== 'structured' || this.status === 'idle') return false;
|
|
1333
|
+
if (this.presentation !== 'structured' || (this.status === 'idle' && !this.resuming)) return false;
|
|
1160
1334
|
if (this.aborting) return true;
|
|
1335
|
+
if (this.resuming) {
|
|
1336
|
+
this.aborting = true;
|
|
1337
|
+
this.needsThreadResume = Boolean(this.threadId || this.resumeTarget);
|
|
1338
|
+
this.emitControlState();
|
|
1339
|
+
this.append({ kind: 'event', level: 'info', text: reason });
|
|
1340
|
+
const error = new Error('Codex resume aborted by user');
|
|
1341
|
+
void this.disconnectProcess(true, error);
|
|
1342
|
+
return true;
|
|
1343
|
+
}
|
|
1161
1344
|
for (const pending of this.pendingPermissions.values()) {
|
|
1162
1345
|
const response = pending.method === 'item/permissions/requestApproval'
|
|
1163
1346
|
? { permissions: {}, scope: 'turn' }
|
|
@@ -1232,9 +1415,27 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
1232
1415
|
return true;
|
|
1233
1416
|
}
|
|
1234
1417
|
|
|
1235
|
-
|
|
1418
|
+
resume(threadId = null) {
|
|
1236
1419
|
const target = String(threadId || this.threadId || '').trim();
|
|
1237
1420
|
if (!target || this.presentation !== 'structured' || this.status !== 'idle' || this.aborting) return false;
|
|
1421
|
+
if (this.resumePromise) return target === this.resumeTarget ? this.resumePromise : false;
|
|
1422
|
+
this.resuming = true;
|
|
1423
|
+
this.resumeTarget = target;
|
|
1424
|
+
this.emitControlState();
|
|
1425
|
+
let tracked;
|
|
1426
|
+
tracked = this.performResume(target).finally(() => {
|
|
1427
|
+
if (this.resumePromise !== tracked) return;
|
|
1428
|
+
this.resumePromise = null;
|
|
1429
|
+
this.resumeTarget = null;
|
|
1430
|
+
this.resuming = false;
|
|
1431
|
+
this.clearAbortState(false);
|
|
1432
|
+
this.emitControlState();
|
|
1433
|
+
});
|
|
1434
|
+
this.resumePromise = tracked;
|
|
1435
|
+
return tracked;
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
async performResume(target) {
|
|
1238
1439
|
await this.ensureProcess();
|
|
1239
1440
|
const selectedModel = this.model;
|
|
1240
1441
|
const selectedEffort = this.effort;
|
|
@@ -1243,7 +1444,10 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
1243
1444
|
const params = this.threadResumeParams(target);
|
|
1244
1445
|
this.deferredWarnings = [];
|
|
1245
1446
|
try {
|
|
1246
|
-
const result = await this.request('thread/resume', params
|
|
1447
|
+
const result = await this.request('thread/resume', params, {
|
|
1448
|
+
timeoutMs: RESUME_REQUEST_TIMEOUT_MS,
|
|
1449
|
+
fatalOnTimeout: true
|
|
1450
|
+
});
|
|
1247
1451
|
this.threadId = result.thread?.id || target;
|
|
1248
1452
|
this.needsThreadResume = false;
|
|
1249
1453
|
this.hasModelOverride = resumeWithModelOverride;
|
|
@@ -1252,8 +1456,10 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
1252
1456
|
this.effort = result.reasoningEffort || result.thread?.reasoningEffort || selectedEffort;
|
|
1253
1457
|
this.effectivePermissionMode = result.approvalPolicy || this.effectivePermissionMode;
|
|
1254
1458
|
this.effectiveSandboxMode = sandboxModeFromPolicy(result.sandbox) || this.effectiveSandboxMode;
|
|
1255
|
-
const history = await this.
|
|
1256
|
-
|
|
1459
|
+
const history = await this.readRecentThread({ ...(result.thread || {}), id: this.threadId },
|
|
1460
|
+
RESUME_HISTORY_TURN_LIMIT, { timeoutMs: RESUME_REQUEST_TIMEOUT_MS, fatalOnTimeout: true });
|
|
1461
|
+
const historyNote = history.historyNextCursor ? ' · showing the latest 50 turns' : '';
|
|
1462
|
+
this.restoreThreadHistory(history, `Resumed Codex thread ${this.threadId}${historyNote}`, {
|
|
1257
1463
|
preserveModel: resumeWithModelOverride,
|
|
1258
1464
|
preserveEffort: resumeWithEffortOverride
|
|
1259
1465
|
});
|
|
@@ -1263,9 +1469,14 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
1263
1469
|
this.promptHistoryCache = null;
|
|
1264
1470
|
return true;
|
|
1265
1471
|
} catch (error) {
|
|
1472
|
+
const aborted = this.aborting || /resume aborted/i.test(error.message);
|
|
1266
1473
|
const warnings = this.deferredWarnings || [];
|
|
1267
1474
|
this.deferredWarnings = null;
|
|
1268
1475
|
for (const warning of warnings) this.append(warning);
|
|
1476
|
+
this.needsThreadResume = Boolean(this.threadId || target);
|
|
1477
|
+
await this.disconnectProcess(true, error);
|
|
1478
|
+
this.append({ kind: 'event', level: aborted ? 'info' : 'error',
|
|
1479
|
+
text: aborted ? 'Codex conversation recovery stopped.' : `Unable to resume Codex conversation: ${error.message}` });
|
|
1269
1480
|
throw error;
|
|
1270
1481
|
}
|
|
1271
1482
|
}
|
|
@@ -1281,24 +1492,24 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
1281
1492
|
this.providerItemContexts.clear();
|
|
1282
1493
|
try {
|
|
1283
1494
|
for (const turn of thread?.turns || []) {
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1495
|
+
const status = turn.status === 'failed' ? 'failed' : turn.status === 'interrupted' ? 'cancelled' : 'completed';
|
|
1496
|
+
const startedAt = Number(turn.startedAt || turn.createdAt || 0);
|
|
1497
|
+
const completedAt = Number(turn.completedAt || turn.updatedAt || 0);
|
|
1498
|
+
const toMilliseconds = value => value > 0 && value < 100000000000 ? value * 1000 : value;
|
|
1499
|
+
const startedAtMs = toMilliseconds(startedAt);
|
|
1500
|
+
const completedAtMs = toMilliseconds(completedAt);
|
|
1501
|
+
this.append({ kind: 'turn-start', turnId: turn.id, ...(startedAtMs ? { createdAt: startedAtMs } : {}) });
|
|
1502
|
+
for (const item of turn.items || []) {
|
|
1503
|
+
this.applyProviderItem({ ...item, turnId: turn.id }, status === 'failed' ? 'failed' : 'completed', {
|
|
1504
|
+
threadId: this.threadId,
|
|
1505
|
+
startedAtMs,
|
|
1506
|
+
completedAtMs
|
|
1507
|
+
});
|
|
1508
|
+
}
|
|
1509
|
+
const durationMs = Number(turn.durationMs || turn.duration_ms || 0)
|
|
1510
|
+
|| (startedAtMs && completedAtMs && completedAtMs >= startedAtMs ? completedAtMs - startedAtMs : null);
|
|
1511
|
+
this.append({ kind: 'turn-end', turnId: turn.id, status, durationMs,
|
|
1512
|
+
...(completedAtMs ? { createdAt: completedAtMs } : {}) });
|
|
1302
1513
|
}
|
|
1303
1514
|
if (eventText) this.append({ kind: 'event', level: 'info', text: eventText });
|
|
1304
1515
|
} finally {
|
|
@@ -1310,7 +1521,8 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
1310
1521
|
|
|
1311
1522
|
async forkFrom(threadId) {
|
|
1312
1523
|
const sourceThreadId = String(threadId || '').trim();
|
|
1313
|
-
if (!sourceThreadId || this.presentation !== 'structured' || this.status !== 'idle'
|
|
1524
|
+
if (!sourceThreadId || this.presentation !== 'structured' || this.status !== 'idle'
|
|
1525
|
+
|| this.aborting || this.resuming) return false;
|
|
1314
1526
|
await this.ensureProcess();
|
|
1315
1527
|
const params = { threadId: sourceThreadId, cwd: this.workingDir, ephemeral: false, threadSource: null };
|
|
1316
1528
|
if (this.hasModelOverride) params.model = this.model;
|
|
@@ -1331,7 +1543,8 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
1331
1543
|
}
|
|
1332
1544
|
|
|
1333
1545
|
async switchToTerminal() {
|
|
1334
|
-
if (this.presentation !== 'structured' || this.status !== 'idle' || this.aborting
|
|
1546
|
+
if (this.presentation !== 'structured' || this.status !== 'idle' || this.aborting
|
|
1547
|
+
|| this.resuming || !this.threadId) throw new Error('Codex must be idle before switching to Terminal.');
|
|
1335
1548
|
await this.disconnectProcess();
|
|
1336
1549
|
this.terminalOutput = '';
|
|
1337
1550
|
const terminal = new PTYManager(this.tool, this.workingDir, { append() {} }, { silent: true });
|
|
@@ -1362,12 +1575,16 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
1362
1575
|
return this.resume();
|
|
1363
1576
|
}
|
|
1364
1577
|
|
|
1365
|
-
async disconnectProcess(force = false) {
|
|
1578
|
+
async disconnectProcess(force = false, reason = null) {
|
|
1366
1579
|
if (!this.process) return this.processShutdown || undefined;
|
|
1367
1580
|
const child = this.process;
|
|
1368
1581
|
this.process = null;
|
|
1369
1582
|
this.processReady = null;
|
|
1370
|
-
|
|
1583
|
+
const socket = this.rpcSocket;
|
|
1584
|
+
this.rpcSocket = null;
|
|
1585
|
+
socket?.terminate();
|
|
1586
|
+
const disconnectError = reason instanceof Error ? reason : new Error('Codex app-server disconnected');
|
|
1587
|
+
for (const request of this.pendingRequests.values()) { clearTimeout(request.timer); request.reject(disconnectError); }
|
|
1371
1588
|
this.pendingRequests.clear();
|
|
1372
1589
|
const shutdown = typeof child.once === 'function'
|
|
1373
1590
|
? new Promise(resolve => {
|
|
@@ -1394,6 +1611,8 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
1394
1611
|
async kill() {
|
|
1395
1612
|
this.running = false;
|
|
1396
1613
|
this.clearAbortState(false);
|
|
1614
|
+
this.resuming = false;
|
|
1615
|
+
this.resumeTarget = null;
|
|
1397
1616
|
this.terminalSession?.kill();
|
|
1398
1617
|
this.terminalSession = null;
|
|
1399
1618
|
await this.disconnectProcess(true);
|
|
@@ -1404,3 +1623,5 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
1404
1623
|
module.exports = CodexStructuredSession;
|
|
1405
1624
|
module.exports.appServerSpawnOptions = appServerSpawnOptions;
|
|
1406
1625
|
module.exports.forceKillProcessTree = forceKillProcessTree;
|
|
1626
|
+
module.exports.reserveLoopbackPort = reserveLoopbackPort;
|
|
1627
|
+
module.exports.connectAppServerWebSocket = connectAppServerWebSocket;
|
|
@@ -409,7 +409,7 @@ class SessionManager extends EventEmitter {
|
|
|
409
409
|
async forkCodex(id, threadId) {
|
|
410
410
|
const session = this.get(id);
|
|
411
411
|
if (!session || session.kind !== 'codex-structured') return null;
|
|
412
|
-
if (session.presentation !== 'structured' || session.status !== 'idle') {
|
|
412
|
+
if (session.presentation !== 'structured' || session.status !== 'idle' || session.resuming) {
|
|
413
413
|
const error = new Error('Codex must be idle in chat mode before forking');
|
|
414
414
|
error.statusCode = 409;
|
|
415
415
|
throw error;
|
package/lib/web/codex.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
function codexText(text) { return escapeHtml(text || '').replace(/\n/g, '<br>'); }
|
|
2
2
|
function codexReadyForInput() {
|
|
3
|
-
return codexState.presentation === 'structured' && codexState.status === 'idle'
|
|
3
|
+
return codexState.presentation === 'structured' && codexState.status === 'idle'
|
|
4
|
+
&& !codexState.aborting && !codexState.resuming && !codexResumeInFlight;
|
|
4
5
|
}
|
|
5
6
|
function codexJson(value) {
|
|
6
7
|
if (typeof value === 'string') return value;
|
|
@@ -473,6 +474,7 @@
|
|
|
473
474
|
const subagents = Number(codexState.activeSubagentCount || 0) || 0;
|
|
474
475
|
const parts = [];
|
|
475
476
|
if (codexState.aborting) parts.push('<span class="claude-state-pill warn">Stopping Codex…</span>');
|
|
477
|
+
else if (codexState.resuming || codexResumeInFlight) parts.push('<span class="claude-state-pill">Resuming conversation…</span>');
|
|
476
478
|
if (pending || codexState.status === 'waiting_approval') parts.push(`<button type="button" class="claude-state-pill warn codex-approval-jump" onclick="jumpToCodexApproval()" title="Jump to pending approval" aria-label="Jump to pending approval">${pending || 1} approval${pending === 1 ? '' : 's'}<span aria-hidden="true">↓</span></button>`);
|
|
477
479
|
if (subagents) parts.push(`<span class="claude-state-pill">${subagents} subagent${subagents === 1 ? '' : 's'} running</span>`);
|
|
478
480
|
el.innerHTML = parts.join('');
|
|
@@ -614,7 +616,10 @@
|
|
|
614
616
|
function respondCodexPermission(id, decision) { if (currentSocket?.readyState === 1) currentSocket.send(JSON.stringify({ type: 'codex-permission', id, decision })); }
|
|
615
617
|
async function toggleCodexResumePanel() {
|
|
616
618
|
if (!codexReadyForInput()) return;
|
|
617
|
-
|
|
619
|
+
const panel = document.getElementById('codex-resume-panel');
|
|
620
|
+
const retryAfterFailure = panel.dataset.resumeError === 'true';
|
|
621
|
+
codexResumePanelOpen = retryAfterFailure ? true : !codexResumePanelOpen;
|
|
622
|
+
delete panel.dataset.resumeError;
|
|
618
623
|
codexModelPanelOpen = false;
|
|
619
624
|
codexForkPanelOpen = false;
|
|
620
625
|
codexPromptPanelOpen = false;
|
|
@@ -623,7 +628,6 @@
|
|
|
623
628
|
document.getElementById('codex-fork-panel').classList.remove('active');
|
|
624
629
|
document.getElementById('codex-prompt-panel').classList.remove('active');
|
|
625
630
|
document.getElementById('codex-skill-panel').classList.remove('active');
|
|
626
|
-
const panel = document.getElementById('codex-resume-panel');
|
|
627
631
|
panel.classList.toggle('active', codexResumePanelOpen);
|
|
628
632
|
updateTerminalControlsHeight();
|
|
629
633
|
if (!codexResumePanelOpen) return;
|
|
@@ -647,6 +651,7 @@
|
|
|
647
651
|
await loadCodexThreadPanel(panel, 'fork');
|
|
648
652
|
}
|
|
649
653
|
async function loadCodexThreadPanel(panel, action) {
|
|
654
|
+
delete panel.dataset.resumeError;
|
|
650
655
|
panel.innerHTML = '<div class="claude-resume-meta" style="padding:12px;">Loading sessions...</div>';
|
|
651
656
|
try {
|
|
652
657
|
const res = await fetchWithTimeout(`/api/sessions/${activeSessionId}/codex-resume-threads`, {}, 30000);
|
|
@@ -956,11 +961,37 @@
|
|
|
956
961
|
applyCodexState({});
|
|
957
962
|
}
|
|
958
963
|
async function selectCodexResumeThread(threadId) {
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
964
|
+
if (codexResumeInFlight || codexState.resuming) return;
|
|
965
|
+
codexResumeInFlight = true;
|
|
966
|
+
const panel = document.getElementById('codex-resume-panel');
|
|
967
|
+
panel.innerHTML = '<div class="claude-resume-meta" style="padding:12px;">Resuming conversation…</div>';
|
|
968
|
+
applyCodexState({ resuming: true, canAbort: true });
|
|
963
969
|
updateTerminalControlsHeight();
|
|
970
|
+
let failure = '';
|
|
971
|
+
try {
|
|
972
|
+
const res = await fetchWithTimeout(`/api/sessions/${activeSessionId}/codex-resume`, {
|
|
973
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ threadId })
|
|
974
|
+
}, 70000);
|
|
975
|
+
const data = await res.json();
|
|
976
|
+
if (!res.ok || !data.success) throw new Error(data.error || 'Unable to resume Codex thread');
|
|
977
|
+
codexResumePanelOpen = false;
|
|
978
|
+
panel.classList.remove('active');
|
|
979
|
+
} catch (error) {
|
|
980
|
+
failure = /resume aborted/i.test(error.message || '')
|
|
981
|
+
? 'Conversation recovery stopped.'
|
|
982
|
+
: (error.message || 'Unable to resume Codex thread');
|
|
983
|
+
} finally {
|
|
984
|
+
codexResumeInFlight = false;
|
|
985
|
+
// 本地 in-flight 也参与控件状态,清除后必须主动重绘。
|
|
986
|
+
applyCodexState({ resuming: false, canAbort: false });
|
|
987
|
+
if (failure) {
|
|
988
|
+
codexResumePanelOpen = true;
|
|
989
|
+
panel.classList.add('active');
|
|
990
|
+
panel.dataset.resumeError = 'true';
|
|
991
|
+
panel.innerHTML = `<div class="claude-resume-meta" style="padding:12px;color:#ff6b61;">${escapeHtml(failure)}</div>`;
|
|
992
|
+
}
|
|
993
|
+
updateTerminalControlsHeight();
|
|
994
|
+
}
|
|
964
995
|
}
|
|
965
996
|
async function selectCodexForkThread(threadId) {
|
|
966
997
|
const panel = document.getElementById('codex-fork-panel');
|
package/lib/web/core.js
CHANGED
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
permissionMode: 'default', sandboxMode: 'default',
|
|
36
36
|
effectivePermissionMode: null, effectiveSandboxMode: null,
|
|
37
37
|
model: null, effort: null, status: 'idle', threadId: null,
|
|
38
|
-
presentation: 'structured', models: [], aborting: false,
|
|
38
|
+
presentation: 'structured', models: [], aborting: false, resuming: false,
|
|
39
39
|
canAbort: false, canCompact: false, compacting: false,
|
|
40
40
|
canSwitchToTerminal: false, canSwitchToStructured: false
|
|
41
41
|
};
|
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
let codexModelPanelOpen = false;
|
|
47
47
|
let codexModelCandidate = null;
|
|
48
48
|
let codexResumePanelOpen = false;
|
|
49
|
+
let codexResumeInFlight = false;
|
|
49
50
|
let codexForkPanelOpen = false;
|
|
50
51
|
let codexPromptPanelOpen = false;
|
|
51
52
|
let codexPromptItems = [];
|