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.
@@ -1,7 +1,8 @@
1
1
  const { EventEmitter } = require('events');
2
2
  const { spawn } = require('child_process');
3
- const readline = require('readline');
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: ['pipe', 'pipe', 'pipe'] };
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
- this.processReady = new Promise((resolve, reject) => {
431
- const child = spawn(this.tool.command, ['app-server', '--listen', 'stdio://'], appServerSpawnOptions({
432
- cwd: this.workingDir,
433
- env: { ...process.env }
434
- }));
435
- this.process = child;
436
- const fail = error => {
437
- this.processReady = null;
438
- this.process = null;
439
- reject(error instanceof Error ? error : new Error(String(error)));
440
- };
441
- child.once('error', fail);
442
- child.once('exit', code => {
443
- if (this.process === child) {
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
- for (const request of this.pendingRequests.values()) request.reject(new Error(`Codex app-server exited (${code})`));
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
- child.stderr.on('data', data => this.logger.debugInfo?.(`[codex-app-server] ${String(data).trim()}`));
464
- const lines = readline.createInterface({ input: child.stdout });
465
- lines.on('line', line => this.handleRpcLine(line));
466
- this.request('initialize', { clientInfo: { name: 'glad-web', title: 'Glad', version: '1.0' }, capabilities: { experimentalApi: true } })
467
- .then(async () => {
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
- return this.processReady;
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 || !this.process.stdin?.writable) return Promise.reject(new Error('Codex app-server is not connected'));
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 timer = setTimeout(() => { this.pendingRequests.delete(id); reject(new Error(`${method} timed out`)); }, 30000);
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
- this.process.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n');
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
- if (!this.process || !this.process.stdin?.writable) return false;
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
- if (!this.process || !this.process.stdin?.writable) return false;
494
- this.process.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, result }) + '\n');
495
- return true;
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.request('thread/read', { threadId: item.id, includeTurns: true });
853
- questions = recentUserQuestions(history?.thread);
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.request('thread/read', { threadId: item.id, includeTurns: true });
1061
+ const history = await this.readRecentThread(item, 200);
896
1062
  const fallbackTimestamp = toTimestampMs(item.updatedAt || item.createdAt);
897
- return userPromptsFromThread(history?.thread || { id: item.id, turns: [] }, fallbackTimestamp)
898
- .map(prompt => ({ ...prompt, threadId: prompt.threadId || item.id }));
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.flat());
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' || this.aborting) return false;
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
- async resume(threadId = null) {
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.request('thread/read', { threadId: this.threadId, includeTurns: true });
1256
- this.restoreThreadHistory(history?.thread, `Resumed Codex thread ${this.threadId}`, {
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
- const status = turn.status === 'failed' ? 'failed' : turn.status === 'interrupted' ? 'cancelled' : 'completed';
1285
- const startedAt = Number(turn.startedAt || turn.createdAt || 0);
1286
- const completedAt = Number(turn.completedAt || turn.updatedAt || 0);
1287
- const toMilliseconds = value => value > 0 && value < 100000000000 ? value * 1000 : value;
1288
- const startedAtMs = toMilliseconds(startedAt);
1289
- const completedAtMs = toMilliseconds(completedAt);
1290
- this.append({ kind: 'turn-start', turnId: turn.id, ...(startedAtMs ? { createdAt: startedAtMs } : {}) });
1291
- for (const item of turn.items || []) {
1292
- this.applyProviderItem({ ...item, turnId: turn.id }, status === 'failed' ? 'failed' : 'completed', {
1293
- threadId: this.threadId,
1294
- startedAtMs,
1295
- completedAtMs
1296
- });
1297
- }
1298
- const durationMs = Number(turn.durationMs || turn.duration_ms || 0)
1299
- || (startedAtMs && completedAtMs && completedAtMs >= startedAtMs ? completedAtMs - startedAtMs : null);
1300
- this.append({ kind: 'turn-end', turnId: turn.id, status, durationMs,
1301
- ...(completedAtMs ? { createdAt: completedAtMs } : {}) });
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' || this.aborting) return false;
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 || !this.threadId) throw new Error('Codex must be idle before switching to Terminal.');
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
- for (const request of this.pendingRequests.values()) { clearTimeout(request.timer); request.reject(new Error('Codex app-server disconnected')); }
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' && !codexState.aborting;
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
- codexResumePanelOpen = !codexResumePanelOpen;
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
- const res = await fetchWithTimeout(`/api/sessions/${activeSessionId}/codex-resume`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ threadId }) }, 30000);
960
- if (!res.ok) { alert((await res.json()).error || 'Unable to resume Codex thread'); return; }
961
- codexResumePanelOpen = false;
962
- document.getElementById('codex-resume-panel').classList.remove('active');
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 = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "glad-web",
3
- "version": "1.0.41",
3
+ "version": "1.0.43",
4
4
  "description": "Glad transforms terminal-based AI coding tools into a polished, mobile-friendly local Web interface.",
5
5
  "bin": {
6
6
  "glad": "bin/cli.js"