glad-web 1.0.40 → 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,12 +1,23 @@
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']);
8
9
  const SANDBOX_MODES = new Set(['read-only', 'workspace-write', 'danger-full-access']);
9
10
  const FALLBACK_EFFORTS = ['minimal', 'low', 'medium', 'high', 'xhigh', 'ultra'];
11
+ const DEFAULT_ABORT_GRACE_MS = 5000;
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;
17
+
18
+ function turnKey(threadId, turnId) {
19
+ return `${String(threadId || '')}\n${String(turnId || '')}`;
20
+ }
10
21
 
11
22
  function normalizePermissionMode(value) {
12
23
  const mode = String(value || 'default');
@@ -50,18 +61,88 @@ function toTimestampMs(value) {
50
61
  }
51
62
 
52
63
  function appServerSpawnOptions({ cwd, env, platform = process.platform }) {
53
- const options = { cwd, env, stdio: ['pipe', 'pipe', 'pipe'] };
64
+ const options = { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] };
54
65
 
55
66
  // Globally installed npm CLIs expose a .cmd shim on Windows. child_process.spawn
56
67
  // does not resolve that shim without a shell, causing `spawn codex ENOENT`.
57
68
  if (platform === 'win32') {
58
69
  options.shell = true;
59
70
  options.windowsHide = true;
71
+ } else {
72
+ // Keep the npm wrapper, native Codex binary, and MCP children in one group
73
+ // so a forced abort can stop the complete app-server process tree.
74
+ options.detached = true;
60
75
  }
61
76
 
62
77
  return options;
63
78
  }
64
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
+
124
+ function forceKillProcessTree(child, options = {}) {
125
+ const platform = options.platform || process.platform;
126
+ const killGroup = options.killGroup || process.kill;
127
+ const spawnProcess = options.spawnProcess || spawn;
128
+ if (!child?.pid) return false;
129
+ if (platform !== 'win32') {
130
+ try {
131
+ killGroup(-child.pid, 'SIGKILL');
132
+ return true;
133
+ } catch (_error) {
134
+ try { return child.kill('SIGKILL'); } catch (_) { return false; }
135
+ }
136
+ }
137
+ const killer = spawnProcess('taskkill', ['/pid', String(child.pid), '/t', '/f'], {
138
+ stdio: 'ignore', windowsHide: true
139
+ });
140
+ killer.once('error', () => {
141
+ try { child.kill('SIGKILL'); } catch (_) { /* process already exited */ }
142
+ });
143
+ return true;
144
+ }
145
+
65
146
  function textFromInputItems(content) {
66
147
  return (Array.isArray(content) ? content : [])
67
148
  .filter(item => item && item.type === 'text')
@@ -232,7 +313,19 @@ class CodexStructuredSession extends EventEmitter {
232
313
  this.requestId = 0;
233
314
  this.pendingRequests = new Map();
234
315
  this.process = null;
316
+ this.rpcSocket = null;
235
317
  this.processReady = null;
318
+ this.processShutdown = null;
319
+ this.needsThreadResume = false;
320
+ this.resuming = false;
321
+ this.resumePromise = null;
322
+ this.resumeTarget = null;
323
+ this.aborting = false;
324
+ this.abortTargets = new Map();
325
+ this.abortTimer = null;
326
+ this.abortGraceMs = Number(options.abortGraceMs) > 0
327
+ ? Number(options.abortGraceMs) : DEFAULT_ABORT_GRACE_MS;
328
+ this.forceKillProcessTree = options.forceKillProcessTree || forceKillProcessTree;
236
329
  this.terminalSession = null;
237
330
  this.terminalOutput = '';
238
331
  this.hasUnreadCompletion = false;
@@ -312,10 +405,13 @@ class CodexStructuredSession extends EventEmitter {
312
405
  effectivePermissionMode: this.effectivePermissionMode, effectiveSandboxMode: this.effectiveSandboxMode,
313
406
  model: this.model, effort: this.effort,
314
407
  status: this.status, threadId: this.threadId, presentation: this.presentation,
315
- canAbort: this.presentation === 'structured' && this.status !== 'idle',
316
- canCompact: this.presentation === 'structured' && this.status === 'idle' && !this.compacting && Boolean(this.threadId),
408
+ aborting: this.aborting, resuming: this.resuming,
409
+ canAbort: this.presentation === 'structured' && (this.status !== 'idle' || this.resuming) && !this.aborting,
410
+ canCompact: this.presentation === 'structured' && this.status === 'idle' && !this.compacting
411
+ && !this.aborting && !this.resuming && Boolean(this.threadId),
317
412
  compacting: this.compacting,
318
- canSwitchToTerminal: this.presentation === 'structured' && this.status === 'idle' && Boolean(this.threadId),
413
+ canSwitchToTerminal: this.presentation === 'structured' && this.status === 'idle'
414
+ && !this.aborting && !this.resuming && Boolean(this.threadId),
319
415
  canSwitchToStructured: this.presentation === 'terminal',
320
416
  pendingPermissionCount: this.pendingPermissions.size, activeSubagentCount, models: this.models };
321
417
  }
@@ -362,72 +458,176 @@ class CodexStructuredSession extends EventEmitter {
362
458
  return completed;
363
459
  }
364
460
 
461
+ clearAbortState(emit = true) {
462
+ const changed = this.aborting;
463
+ if (this.abortTimer) clearTimeout(this.abortTimer);
464
+ this.abortTimer = null;
465
+ this.abortTargets.clear();
466
+ this.aborting = false;
467
+ if (emit && changed) this.emitControlState();
468
+ }
469
+
470
+ finishAbortIfComplete() {
471
+ if (!this.aborting || this.abortTargets.size) return false;
472
+ this.clearAbortState(false);
473
+ const hasActiveTurn = this.currentTurnId
474
+ || Array.from(this.threadTurns.values()).some(turn => turn?.status === 'running');
475
+ if (!hasActiveTurn && this.status !== 'idle') this.setStatus('idle');
476
+ else this.emitControlState();
477
+ return true;
478
+ }
479
+
365
480
  async ensureProcess() {
481
+ if (this.processShutdown) await this.processShutdown;
366
482
  if (this.processReady) return this.processReady;
367
- this.processReady = new Promise((resolve, reject) => {
368
- const child = spawn(this.tool.command, ['app-server', '--listen', 'stdio://'], appServerSpawnOptions({
369
- cwd: this.workingDir,
370
- env: { ...process.env }
371
- }));
372
- this.process = child;
373
- const fail = error => {
374
- this.processReady = null;
375
- this.process = null;
376
- reject(error instanceof Error ? error : new Error(String(error)));
377
- };
378
- child.once('error', fail);
379
- child.once('exit', code => {
380
- 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;
381
519
  this.process = null;
382
520
  this.processReady = null;
383
- 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
+ }
384
528
  this.pendingRequests.clear();
385
529
  if (this.running && this.presentation === 'structured') {
386
530
  const activeTurn = Boolean(this.currentTurnId || this.status === 'running' || this.status === 'waiting_approval');
531
+ this.needsThreadResume = Boolean(this.threadId);
532
+ this.clearAbortState(false);
387
533
  this.compacting = false;
388
534
  this.append({ kind: 'event', level: 'error', text: 'Codex app-server exited.' });
389
- this.emitEvent({
390
- type: 'runtime-disconnected',
391
- activeTurn,
392
- turnId: this.currentTurnId || null
393
- });
535
+ this.emitEvent({ type: 'runtime-disconnected', activeTurn, turnId: this.currentTurnId || null });
394
536
  this.setStatus('idle');
395
537
  }
396
- }
397
- });
398
- child.stderr.on('data', data => this.logger.debugInfo?.(`[codex-app-server] ${String(data).trim()}`));
399
- const lines = readline.createInterface({ input: child.stdout });
400
- lines.on('line', line => this.handleRpcLine(line));
401
- this.request('initialize', { clientInfo: { name: 'glad-web', title: 'Glad', version: '1.0' }, capabilities: { experimentalApi: true } })
402
- .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', {});
403
559
  try { await this.refreshConfigDefaults(); } catch (error) { this.logger.debugInfo?.(`[codex-app-server] config/read failed: ${error.message}`); }
404
560
  try { await this.refreshModels(); } catch (error) { this.logger.debugInfo?.(`[codex-app-server] model/list failed: ${error.message}`); }
405
561
  resolve();
406
562
  }).catch(fail);
407
- });
408
- 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
+ }
409
572
  }
410
573
 
411
- request(method, params) {
412
- 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);
413
582
  const id = ++this.requestId;
414
583
  return new Promise((resolve, reject) => {
415
- 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);
416
597
  this.pendingRequests.set(id, { resolve, reject, timer });
417
- 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
+ }
418
608
  });
419
609
  }
420
610
 
421
611
  notify(method, params) {
422
- if (!this.process || !this.process.stdin?.writable) return false;
423
- this.process.stdin.write(JSON.stringify({ jsonrpc: '2.0', method, params }) + '\n');
424
- return true;
612
+ return this.sendRpcMessage({ jsonrpc: '2.0', method, params });
425
613
  }
426
614
 
427
615
  respond(id, result) {
428
- if (!this.process || !this.process.stdin?.writable) return false;
429
- this.process.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, result }) + '\n');
430
- 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
+ }
431
631
  }
432
632
 
433
633
  handleRpcLine(line) {
@@ -514,8 +714,16 @@ class CodexStructuredSession extends EventEmitter {
514
714
  const durationMs = Number(params.turn?.durationMs || 0)
515
715
  || (startedAtMs ? Math.max(0, completedAtMs - startedAtMs) : null);
516
716
  const context = this.turnContexts.get(String(completedTurnId || ''));
517
- this.append({ kind: 'turn-end', threadId, turnId: completedTurnId, status: turnStatus,
518
- durationMs, createdAt: completedAtMs, ...(context ? { context } : {}) });
717
+ const existingTurnEnd = this.messages.find(item => item.kind === 'turn-end'
718
+ && item.threadId === threadId && item.turnId === completedTurnId);
719
+ if (existingTurnEnd) {
720
+ this.patch(existingTurnEnd.id, { status: turnStatus, durationMs,
721
+ createdAt: completedAtMs, ...(context ? { context } : {}) });
722
+ } else {
723
+ this.append({ kind: 'turn-end', threadId, turnId: completedTurnId, status: turnStatus,
724
+ durationMs, createdAt: completedAtMs, ...(context ? { context } : {}) });
725
+ }
726
+ this.abortTargets.delete(turnKey(threadId, completedTurnId));
519
727
  const observedNow = Date.now();
520
728
  const observedCompletedAtMs = Math.abs(observedNow - completedAtMs) < 5000
521
729
  ? Math.max(completedAtMs, observedNow) : completedAtMs;
@@ -543,11 +751,12 @@ class CodexStructuredSession extends EventEmitter {
543
751
  if (params.turn?.status === 'failed' || params.turn?.error) {
544
752
  this.append({ kind: 'event', level: 'error', text: params.turn?.error?.message || 'Codex turn failed.' });
545
753
  }
546
- this.setStatus('idle');
754
+ this.setStatus(this.aborting && this.abortTargets.size ? 'running' : 'idle');
547
755
  this.hasUnreadCompletion = true;
548
756
  } else {
549
757
  this.emitControlState();
550
758
  }
759
+ this.finishAbortIfComplete();
551
760
  return;
552
761
  }
553
762
  if (method === 'thread/compacted') {
@@ -568,7 +777,7 @@ class CodexStructuredSession extends EventEmitter {
568
777
  const status = params.status?.type || params.status;
569
778
  if (!threadId || threadId === this.threadId) {
570
779
  if (status === 'idle' && !this.currentTurnId) { this.compacting = false; this.setStatus('idle'); }
571
- if (status === 'active') this.setStatus('running');
780
+ if (status === 'active' && !this.aborting) this.setStatus('running');
572
781
  }
573
782
  return;
574
783
  }
@@ -760,6 +969,37 @@ class CodexStructuredSession extends EventEmitter {
760
969
  return config;
761
970
  }
762
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
+
763
1003
  async listResumeThreads() {
764
1004
  await this.ensureProcess();
765
1005
  const result = await this.request('thread/list', {
@@ -775,8 +1015,8 @@ class CodexStructuredSession extends EventEmitter {
775
1015
  for (const item of threads) {
776
1016
  let questions = [];
777
1017
  try {
778
- const history = await this.request('thread/read', { threadId: item.id, includeTurns: true });
779
- questions = recentUserQuestions(history?.thread);
1018
+ const history = await this.readRecentThread(item, 8);
1019
+ questions = recentUserQuestions(history);
780
1020
  } catch (error) {
781
1021
  this.logger.debugInfo?.(`[codex-app-server] unable to read resume preview for ${item.id}: ${error.message}`);
782
1022
  }
@@ -818,20 +1058,24 @@ class CodexStructuredSession extends EventEmitter {
818
1058
  const threads = (result?.data || []).filter(item => !item.parentThreadId);
819
1059
  const histories = await Promise.all(threads.map(async item => {
820
1060
  try {
821
- const history = await this.request('thread/read', { threadId: item.id, includeTurns: true });
1061
+ const history = await this.readRecentThread(item, 200);
822
1062
  const fallbackTimestamp = toTimestampMs(item.updatedAt || item.createdAt);
823
- return userPromptsFromThread(history?.thread || { id: item.id, turns: [] }, fallbackTimestamp)
824
- .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
+ };
825
1068
  } catch (error) {
826
1069
  this.logger.debugInfo?.(`[codex-app-server] unable to read prompt history for ${item.id}: ${error.message}`);
827
- return [];
1070
+ return { prompts: [], capped: false };
828
1071
  }
829
1072
  }));
830
- prompts.push(...histories.flat());
1073
+ prompts.push(...histories.flatMap(history => history.prompts));
1074
+ if (histories.some(history => history.capped)) capped = true;
831
1075
  cursor = result?.nextCursor || null;
832
1076
  pageCount += 1;
833
1077
  if (prompts.length >= 200 || pageCount >= 5) {
834
- capped = Boolean(cursor) || prompts.length > 200;
1078
+ capped = capped || Boolean(cursor) || prompts.length > 200;
835
1079
  break;
836
1080
  }
837
1081
  } while (cursor);
@@ -952,11 +1196,35 @@ class CodexStructuredSession extends EventEmitter {
952
1196
  return this.getControlState();
953
1197
  }
954
1198
 
1199
+ async resumeThreadAfterProcessRestart() {
1200
+ if (!this.threadId || !this.needsThreadResume) return false;
1201
+ const result = await this.request('thread/resume', this.threadResumeParams(this.threadId), {
1202
+ timeoutMs: RESUME_REQUEST_TIMEOUT_MS,
1203
+ fatalOnTimeout: true
1204
+ });
1205
+ this.needsThreadResume = false;
1206
+ this.model = result.model || result.thread?.model || this.model;
1207
+ this.effort = result.reasoningEffort || result.thread?.reasoningEffort || this.effort;
1208
+ this.effectivePermissionMode = result.approvalPolicy || this.effectivePermissionMode;
1209
+ this.effectiveSandboxMode = sandboxModeFromPolicy(result.sandbox) || this.effectiveSandboxMode;
1210
+ return true;
1211
+ }
1212
+
1213
+ threadResumeParams(threadId) {
1214
+ const params = { threadId, cwd: this.workingDir };
1215
+ if (this.permissionMode) params.approvalPolicy = this.permissionMode;
1216
+ if (this.sandboxMode) params.sandbox = this.sandboxMode;
1217
+ if (this.hasModelOverride && this.model) params.model = this.model;
1218
+ if (this.hasEffortOverride && this.effort) params.config = { model_reasoning_effort: this.effort };
1219
+ return params;
1220
+ }
1221
+
955
1222
  async sendUserMessage(text, attachments = [], skills = []) {
956
1223
  const prompt = String(text || '').trim();
957
1224
  const images = (Array.isArray(attachments) ? attachments : [])
958
1225
  .filter(item => item && typeof item.path === 'string' && item.path);
959
- if ((!prompt && images.length === 0) || this.presentation !== 'structured' || this.status !== 'idle') return false;
1226
+ if ((!prompt && images.length === 0) || this.presentation !== 'structured'
1227
+ || this.status !== 'idle' || this.aborting || this.resuming) return false;
960
1228
  this.hasUnreadCompletion = false;
961
1229
  this.promptHistoryCache = null;
962
1230
  this.append({
@@ -967,8 +1235,10 @@ class CodexStructuredSession extends EventEmitter {
967
1235
  name: String(item?.name || ''), path: String(item?.path || '')
968
1236
  })).filter(item => item.name && item.path)
969
1237
  });
1238
+ this.setStatus('running');
970
1239
  try {
971
1240
  await this.ensureProcess();
1241
+ await this.resumeThreadAfterProcessRestart();
972
1242
  if (!this.threadId) {
973
1243
  const params = { cwd: this.workingDir };
974
1244
  if (this.hasModelOverride) params.model = this.model;
@@ -976,13 +1246,13 @@ class CodexStructuredSession extends EventEmitter {
976
1246
  if (this.sandboxMode) params.sandbox = this.sandboxMode;
977
1247
  const started = await this.request('thread/start', params);
978
1248
  this.threadId = started.thread?.id;
1249
+ this.needsThreadResume = false;
979
1250
  this.model = started.model || this.model;
980
1251
  this.effort = started.reasoningEffort || this.effort;
981
1252
  this.effectivePermissionMode = started.approvalPolicy || this.effectivePermissionMode;
982
1253
  this.effectiveSandboxMode = sandboxModeFromPolicy(started.sandbox) || this.effectiveSandboxMode;
983
1254
  this.emitEvent({ type: 'state', state: this.getControlState() });
984
1255
  }
985
- this.setStatus('running');
986
1256
  const input = [];
987
1257
  input.push(...await this.resolveSkillInputs(skills));
988
1258
  if (prompt) input.push({ type: 'text', text: prompt });
@@ -1008,7 +1278,8 @@ class CodexStructuredSession extends EventEmitter {
1008
1278
  }
1009
1279
 
1010
1280
  async compactContext() {
1011
- if (!this.threadId || this.presentation !== 'structured' || this.status !== 'idle') return false;
1281
+ if (!this.threadId || this.presentation !== 'structured' || this.status !== 'idle'
1282
+ || this.aborting || this.resuming) return false;
1012
1283
  await this.ensureProcess();
1013
1284
  this.compacting = true;
1014
1285
  this.setStatus('running');
@@ -1059,7 +1330,17 @@ class CodexStructuredSession extends EventEmitter {
1059
1330
  }
1060
1331
 
1061
1332
  abort(reason = 'Aborted by user') {
1062
- if (this.presentation !== 'structured' || this.status === 'idle') return false;
1333
+ if (this.presentation !== 'structured' || (this.status === 'idle' && !this.resuming)) return false;
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
+ }
1063
1344
  for (const pending of this.pendingPermissions.values()) {
1064
1345
  const response = pending.method === 'item/permissions/requestApproval'
1065
1346
  ? { permissions: {}, scope: 'turn' }
@@ -1077,40 +1358,108 @@ class CodexStructuredSession extends EventEmitter {
1077
1358
  && !targets.some(target => target.threadId === this.threadId && target.turnId === this.currentTurnId)) {
1078
1359
  targets.push({ threadId: this.threadId, turnId: this.currentTurnId });
1079
1360
  }
1361
+ this.aborting = true;
1362
+ this.abortTargets = new Map(targets.map(target => [turnKey(target.threadId, target.turnId), target]));
1363
+ this.emitControlState();
1080
1364
  for (const target of targets) {
1081
1365
  this.request('turn/interrupt', target).catch(error => {
1082
1366
  this.logger.debugInfo?.(`[codex-app-server] turn/interrupt failed for ${target.threadId}/${target.turnId}: ${error.message}`);
1083
1367
  });
1084
1368
  }
1085
1369
  this.append({ kind: 'event', level: 'info', text: reason });
1370
+ this.abortTimer = setTimeout(() => this.forceAbortAfterTimeout(), this.abortGraceMs);
1371
+ this.abortTimer.unref?.();
1086
1372
  return true;
1087
1373
  }
1088
1374
 
1089
- async resume(threadId = null) {
1375
+ forceAbortAfterTimeout() {
1376
+ if (!this.aborting) return false;
1377
+ const targets = Array.from(this.abortTargets.values());
1378
+ const completedAtMs = Date.now();
1379
+ const timeoutSeconds = Math.max(1, Math.round(this.abortGraceMs / 1000));
1380
+ this.append({ kind: 'event', level: 'warning',
1381
+ text: `Codex did not stop within ${timeoutSeconds} seconds. Stopping its app-server.` });
1382
+
1383
+ for (const target of targets) {
1384
+ const tracked = this.threadTurns.get(target.threadId);
1385
+ const startedAtMs = Number(tracked?.startedAt || 0)
1386
+ || (target.threadId === this.threadId ? Number(this.currentTurnStartedAt || 0) : 0);
1387
+ const existingTurnEnd = this.messages.find(item => item.kind === 'turn-end'
1388
+ && item.threadId === target.threadId && item.turnId === target.turnId);
1389
+ if (!existingTurnEnd) {
1390
+ this.append({ kind: 'turn-end', threadId: target.threadId, turnId: target.turnId,
1391
+ status: 'cancelled', durationMs: startedAtMs ? Math.max(0, completedAtMs - startedAtMs) : null,
1392
+ createdAt: completedAtMs });
1393
+ }
1394
+ }
1395
+ for (const item of this.messages.filter(message => message.kind === 'tool'
1396
+ && ['running', 'inProgress'].includes(message.toolStatus))) {
1397
+ const startedAtMs = Number(item.startedAtMs || item.createdAt || 0);
1398
+ this.patch(item.id, { toolStatus: 'cancelled', completedAtMs,
1399
+ ...(startedAtMs ? { durationMs: Math.max(1, completedAtMs - startedAtMs) } : {}) });
1400
+ }
1401
+
1402
+ this.needsThreadResume = Boolean(this.threadId);
1403
+ this.currentTurnId = null;
1404
+ this.currentTurnStartedAt = null;
1405
+ this.threadTurns.clear();
1406
+ this.pendingPermissions.clear();
1407
+ this.compacting = false;
1408
+ this.hasUnreadCompletion = true;
1409
+ this.clearAbortState(false);
1410
+ void this.disconnectProcess(true);
1411
+ if (this.status !== 'idle') this.setStatus('idle');
1412
+ else this.emitControlState();
1413
+ this.append({ kind: 'event', level: 'info',
1414
+ text: 'Codex app-server stopped. It will restart before the next message.' });
1415
+ return true;
1416
+ }
1417
+
1418
+ resume(threadId = null) {
1090
1419
  const target = String(threadId || this.threadId || '').trim();
1091
- if (!target || this.presentation !== 'structured' || this.status !== 'idle') return false;
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) {
1092
1439
  await this.ensureProcess();
1093
1440
  const selectedModel = this.model;
1094
1441
  const selectedEffort = this.effort;
1095
1442
  const resumeWithModelOverride = Boolean(this.hasModelOverride && selectedModel);
1096
1443
  const resumeWithEffortOverride = Boolean(this.hasEffortOverride && selectedEffort);
1097
- const params = { threadId: target, cwd: this.workingDir };
1098
- if (this.permissionMode) params.approvalPolicy = this.permissionMode;
1099
- if (this.sandboxMode) params.sandbox = this.sandboxMode;
1100
- if (resumeWithModelOverride) params.model = selectedModel;
1101
- if (resumeWithEffortOverride) params.config = { model_reasoning_effort: selectedEffort };
1444
+ const params = this.threadResumeParams(target);
1102
1445
  this.deferredWarnings = [];
1103
1446
  try {
1104
- 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
+ });
1105
1451
  this.threadId = result.thread?.id || target;
1452
+ this.needsThreadResume = false;
1106
1453
  this.hasModelOverride = resumeWithModelOverride;
1107
1454
  this.hasEffortOverride = resumeWithEffortOverride;
1108
1455
  this.model = result.model || result.thread?.model || selectedModel;
1109
1456
  this.effort = result.reasoningEffort || result.thread?.reasoningEffort || selectedEffort;
1110
1457
  this.effectivePermissionMode = result.approvalPolicy || this.effectivePermissionMode;
1111
1458
  this.effectiveSandboxMode = sandboxModeFromPolicy(result.sandbox) || this.effectiveSandboxMode;
1112
- const history = await this.request('thread/read', { threadId: this.threadId, includeTurns: true });
1113
- 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}`, {
1114
1463
  preserveModel: resumeWithModelOverride,
1115
1464
  preserveEffort: resumeWithEffortOverride
1116
1465
  });
@@ -1120,9 +1469,14 @@ class CodexStructuredSession extends EventEmitter {
1120
1469
  this.promptHistoryCache = null;
1121
1470
  return true;
1122
1471
  } catch (error) {
1472
+ const aborted = this.aborting || /resume aborted/i.test(error.message);
1123
1473
  const warnings = this.deferredWarnings || [];
1124
1474
  this.deferredWarnings = null;
1125
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}` });
1126
1480
  throw error;
1127
1481
  }
1128
1482
  }
@@ -1138,24 +1492,24 @@ class CodexStructuredSession extends EventEmitter {
1138
1492
  this.providerItemContexts.clear();
1139
1493
  try {
1140
1494
  for (const turn of thread?.turns || []) {
1141
- const status = turn.status === 'failed' ? 'failed' : turn.status === 'interrupted' ? 'cancelled' : 'completed';
1142
- const startedAt = Number(turn.startedAt || turn.createdAt || 0);
1143
- const completedAt = Number(turn.completedAt || turn.updatedAt || 0);
1144
- const toMilliseconds = value => value > 0 && value < 100000000000 ? value * 1000 : value;
1145
- const startedAtMs = toMilliseconds(startedAt);
1146
- const completedAtMs = toMilliseconds(completedAt);
1147
- this.append({ kind: 'turn-start', turnId: turn.id, ...(startedAtMs ? { createdAt: startedAtMs } : {}) });
1148
- for (const item of turn.items || []) {
1149
- this.applyProviderItem({ ...item, turnId: turn.id }, status === 'failed' ? 'failed' : 'completed', {
1150
- threadId: this.threadId,
1151
- startedAtMs,
1152
- completedAtMs
1153
- });
1154
- }
1155
- const durationMs = Number(turn.durationMs || turn.duration_ms || 0)
1156
- || (startedAtMs && completedAtMs && completedAtMs >= startedAtMs ? completedAtMs - startedAtMs : null);
1157
- this.append({ kind: 'turn-end', turnId: turn.id, status, durationMs,
1158
- ...(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 } : {}) });
1159
1513
  }
1160
1514
  if (eventText) this.append({ kind: 'event', level: 'info', text: eventText });
1161
1515
  } finally {
@@ -1167,7 +1521,8 @@ class CodexStructuredSession extends EventEmitter {
1167
1521
 
1168
1522
  async forkFrom(threadId) {
1169
1523
  const sourceThreadId = String(threadId || '').trim();
1170
- if (!sourceThreadId || this.presentation !== 'structured' || this.status !== 'idle') return false;
1524
+ if (!sourceThreadId || this.presentation !== 'structured' || this.status !== 'idle'
1525
+ || this.aborting || this.resuming) return false;
1171
1526
  await this.ensureProcess();
1172
1527
  const params = { threadId: sourceThreadId, cwd: this.workingDir, ephemeral: false, threadSource: null };
1173
1528
  if (this.hasModelOverride) params.model = this.model;
@@ -1177,6 +1532,7 @@ class CodexStructuredSession extends EventEmitter {
1177
1532
  const forkedThread = result?.thread;
1178
1533
  if (!forkedThread?.id) throw new Error('Codex did not return a forked thread');
1179
1534
  this.threadId = forkedThread.id;
1535
+ this.needsThreadResume = false;
1180
1536
  this.model = result.model || forkedThread.model || this.model;
1181
1537
  this.effort = result.reasoningEffort || forkedThread.reasoningEffort || forkedThread.reasoning_effort || this.effort;
1182
1538
  this.effectivePermissionMode = result.approvalPolicy || this.effectivePermissionMode;
@@ -1187,7 +1543,8 @@ class CodexStructuredSession extends EventEmitter {
1187
1543
  }
1188
1544
 
1189
1545
  async switchToTerminal() {
1190
- if (this.presentation !== 'structured' || this.status !== 'idle' || !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.');
1191
1548
  await this.disconnectProcess();
1192
1549
  this.terminalOutput = '';
1193
1550
  const terminal = new PTYManager(this.tool, this.workingDir, { append() {} }, { silent: true });
@@ -1218,19 +1575,53 @@ class CodexStructuredSession extends EventEmitter {
1218
1575
  return this.resume();
1219
1576
  }
1220
1577
 
1221
- async disconnectProcess() {
1222
- if (!this.process) return;
1578
+ async disconnectProcess(force = false, reason = null) {
1579
+ if (!this.process) return this.processShutdown || undefined;
1223
1580
  const child = this.process;
1224
1581
  this.process = null;
1225
1582
  this.processReady = null;
1226
- 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); }
1227
1588
  this.pendingRequests.clear();
1228
- child.kill();
1589
+ const shutdown = typeof child.once === 'function'
1590
+ ? new Promise(resolve => {
1591
+ let timer;
1592
+ const finish = () => { clearTimeout(timer); resolve(); };
1593
+ child.once('exit', finish);
1594
+ timer = setTimeout(() => {
1595
+ this.logger.debugInfo?.('[codex-app-server] process did not exit within 2 seconds');
1596
+ finish();
1597
+ }, PROCESS_SHUTDOWN_TIMEOUT_MS);
1598
+ })
1599
+ : Promise.resolve();
1600
+ this.processShutdown = shutdown;
1601
+ if (force) this.forceKillProcessTree(child);
1602
+ else child.kill();
1603
+ try {
1604
+ await shutdown;
1605
+ } finally {
1606
+ if (this.processShutdown === shutdown) this.processShutdown = null;
1607
+ }
1229
1608
  }
1230
1609
 
1231
1610
  markCompletionRead() { this.hasUnreadCompletion = false; this.completionReadInputSeq = this.inputSeq; }
1232
- kill() { this.running = false; this.terminalSession?.kill(); this.terminalSession = null; void this.disconnectProcess(); this.emit('exit'); }
1611
+ async kill() {
1612
+ this.running = false;
1613
+ this.clearAbortState(false);
1614
+ this.resuming = false;
1615
+ this.resumeTarget = null;
1616
+ this.terminalSession?.kill();
1617
+ this.terminalSession = null;
1618
+ await this.disconnectProcess(true);
1619
+ this.emit('exit');
1620
+ }
1233
1621
  }
1234
1622
 
1235
1623
  module.exports = CodexStructuredSession;
1236
1624
  module.exports.appServerSpawnOptions = appServerSpawnOptions;
1625
+ module.exports.forceKillProcessTree = forceKillProcessTree;
1626
+ module.exports.reserveLoopbackPort = reserveLoopbackPort;
1627
+ module.exports.connectAppServerWebSocket = connectAppServerWebSocket;
@@ -276,8 +276,8 @@ async function webCommand(options) {
276
276
  });
277
277
 
278
278
  // API: Delete/Kill session
279
- app.delete('/api/sessions/:id', (req, res) => {
280
- sessionManager.kill(req.params.id);
279
+ app.delete('/api/sessions/:id', async (req, res) => {
280
+ await sessionManager.kill(req.params.id);
281
281
  res.json({ success: true });
282
282
  });
283
283
 
@@ -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;
@@ -610,7 +610,7 @@ class SessionManager extends EventEmitter {
610
610
  return true;
611
611
  }
612
612
 
613
- kill(id) {
613
+ async kill(id) {
614
614
  const session = this.get(id);
615
615
  if (!session) return false;
616
616
  clearTimeout(session.completionTimer);
@@ -620,7 +620,7 @@ class SessionManager extends EventEmitter {
620
620
  this.logSessionDiagnostics('session-deleted', session, {}, { compact: true });
621
621
  this.disposeSessionHistory(session);
622
622
  if (['claude-structured', 'codex-structured'].includes(session.kind)) {
623
- session.ptyManager.kill();
623
+ await session.ptyManager.kill();
624
624
  return true;
625
625
  }
626
626
  session.ptyManager.kill();
@@ -1,4 +1,5 @@
1
1
  const { spawn } = require('child_process');
2
+ const { chmodSync, statSync } = require('fs');
2
3
 
3
4
  const MAX_OUTPUT_BYTES = 64 * 1024 * 1024;
4
5
  const DEFAULT_TIMEOUT_MS = 45000;
@@ -25,6 +26,22 @@ function resolveCcusageBinary(platform = process.platform, arch = process.arch)
25
26
  }
26
27
  }
27
28
 
29
+ function ensureCcusageBinaryExecutable(binaryPath, options = {}) {
30
+ const platform = options.platform || process.platform;
31
+ if (platform === 'win32') return binaryPath;
32
+
33
+ const statPath = options.statPath || statSync;
34
+ const chmodPath = options.chmodPath || chmodSync;
35
+ try {
36
+ // ccusage's platform packages can be installed without execute bits. Its JS
37
+ // wrapper repairs them too, but this runner intentionally spawns the native binary.
38
+ if ((statPath(binaryPath).mode & 0o111) === 0) chmodPath(binaryPath, 0o755);
39
+ return binaryPath;
40
+ } catch (error) {
41
+ throw new Error(`ccusage native binary is not executable: ${error.message}`);
42
+ }
43
+ }
44
+
28
45
  function reportArgs(timezone) {
29
46
  return [
30
47
  'daily',
@@ -38,7 +55,8 @@ function reportArgs(timezone) {
38
55
 
39
56
  class CcusageRunner {
40
57
  constructor(options = {}) {
41
- this.binaryPath = options.binaryPath || resolveCcusageBinary();
58
+ this.binaryPath = options.binaryPath
59
+ || ensureCcusageBinaryExecutable(resolveCcusageBinary());
42
60
  this.timeoutMs = options.timeoutMs || DEFAULT_TIMEOUT_MS;
43
61
  this.spawnProcess = options.spawnProcess || spawn;
44
62
  }
@@ -102,4 +120,9 @@ class CcusageRunner {
102
120
  }
103
121
  }
104
122
 
105
- module.exports = { CcusageRunner, reportArgs, resolveCcusageBinary };
123
+ module.exports = {
124
+ CcusageRunner,
125
+ ensureCcusageBinaryExecutable,
126
+ reportArgs,
127
+ resolveCcusageBinary
128
+ };
package/lib/web/codex.js CHANGED
@@ -1,4 +1,8 @@
1
1
  function codexText(text) { return escapeHtml(text || '').replace(/\n/g, '<br>'); }
2
+ function codexReadyForInput() {
3
+ return codexState.presentation === 'structured' && codexState.status === 'idle'
4
+ && !codexState.aborting && !codexState.resuming && !codexResumeInFlight;
5
+ }
2
6
  function codexJson(value) {
3
7
  if (typeof value === 'string') return value;
4
8
  try { return JSON.stringify(value || {}, null, 2); } catch (_) { return String(value || ''); }
@@ -324,7 +328,7 @@
324
328
  i += 1;
325
329
  }
326
330
  for (const request of codexPendingPermissions) if (!usedPermissions.has(request.id)) parts.push(renderCodexPermission(request));
327
- const skillBubble = selectedCodexSkill && codexState.status === 'idle'
331
+ const skillBubble = selectedCodexSkill && codexReadyForInput()
328
332
  ? `<div class="codex-skill-bubble" role="status" aria-label="Selected skill: ${escapeHtml(selectedCodexSkill.name)}"><span class="codex-skill-bubble-name">Skill · ${escapeHtml(selectedCodexSkill.name)}</span><button type="button" class="codex-skill-bubble-close" onclick="clearCodexSkillSelection()" title="Remove selected skill" aria-label="Remove selected skill">×</button></div>`
329
333
  : '';
330
334
  const working = `<div class="codex-working-indicator" role="status" aria-label="Codex is working" title="Codex is working"${codexState.status === 'running' ? '' : ' style="display:none"'}></div>`;
@@ -443,13 +447,18 @@
443
447
  const modelButton = document.getElementById('codex-model-btn');
444
448
  if (modelButton) modelButton.textContent = 'Model';
445
449
  const abort = document.getElementById('codex-abort-btn');
446
- if (abort) abort.disabled = !codexState.canAbort;
450
+ if (abort) {
451
+ abort.disabled = !codexState.canAbort;
452
+ abort.textContent = codexState.aborting ? 'Aborting…' : 'Abort';
453
+ }
447
454
  const compact = document.getElementById('codex-compact-btn');
448
455
  if (compact) { compact.disabled = !codexState.canCompact; compact.textContent = codexState.compacting ? 'Compacting' : 'Compact'; }
449
456
  const skills = document.getElementById('codex-skills-btn');
450
- if (skills) { skills.disabled = !(codexState.presentation === 'structured' && codexState.status === 'idle'); skills.classList.toggle('primary', Boolean(selectedCodexSkill)); }
457
+ if (skills) { skills.disabled = !codexReadyForInput(); skills.classList.toggle('primary', Boolean(selectedCodexSkill)); }
458
+ const resume = document.getElementById('codex-resume-btn');
459
+ if (resume) resume.disabled = !codexReadyForInput();
451
460
  const fork = document.getElementById('codex-fork-btn');
452
- if (fork) fork.disabled = !(codexState.presentation === 'structured' && codexState.status === 'idle');
461
+ if (fork) fork.disabled = !codexReadyForInput();
453
462
  const terminal = document.getElementById('codex-terminal-switch');
454
463
  if (terminal) { terminal.textContent = codexState.presentation === 'terminal' ? 'CHAT' : 'TERM'; terminal.disabled = codexState.presentation === 'structured' && !codexState.canSwitchToTerminal; terminal.title = codexState.presentation === 'terminal' ? 'Return to Codex chat' : 'Switch to Codex terminal'; }
455
464
  renderCodexStateBar();
@@ -464,6 +473,8 @@
464
473
  const pending = Number(codexState.pendingPermissionCount || codexPendingPermissions.filter(item => item.status === 'pending').length) || 0;
465
474
  const subagents = Number(codexState.activeSubagentCount || 0) || 0;
466
475
  const parts = [];
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>');
467
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>`);
468
479
  if (subagents) parts.push(`<span class="claude-state-pill">${subagents} subagent${subagents === 1 ? '' : 's'} running</span>`);
469
480
  el.innerHTML = parts.join('');
@@ -589,7 +600,12 @@
589
600
  applyCodexState({ permissionMode, sandboxMode });
590
601
  sendCodexSettings({ permissionMode, sandboxMode });
591
602
  }
592
- function abortCodexSession() { if (currentSocket?.readyState === 1) currentSocket.send(JSON.stringify({ type: 'codex-abort' })); }
603
+ function abortCodexSession() {
604
+ if (!codexState.canAbort || codexState.aborting || currentSocket?.readyState !== 1) return false;
605
+ currentSocket.send(JSON.stringify({ type: 'codex-abort' }));
606
+ applyCodexState({ aborting: true, canAbort: false });
607
+ return true;
608
+ }
593
609
  function requestCodexStatus() { if (currentSocket?.readyState === 1) currentSocket.send(JSON.stringify({ type: 'codex-status' })); }
594
610
  function compactCodexContext() {
595
611
  if (!codexState.canCompact || currentSocket?.readyState !== 1) return false;
@@ -599,7 +615,11 @@
599
615
  }
600
616
  function respondCodexPermission(id, decision) { if (currentSocket?.readyState === 1) currentSocket.send(JSON.stringify({ type: 'codex-permission', id, decision })); }
601
617
  async function toggleCodexResumePanel() {
602
- codexResumePanelOpen = !codexResumePanelOpen;
618
+ if (!codexReadyForInput()) return;
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;
603
623
  codexModelPanelOpen = false;
604
624
  codexForkPanelOpen = false;
605
625
  codexPromptPanelOpen = false;
@@ -608,14 +628,13 @@
608
628
  document.getElementById('codex-fork-panel').classList.remove('active');
609
629
  document.getElementById('codex-prompt-panel').classList.remove('active');
610
630
  document.getElementById('codex-skill-panel').classList.remove('active');
611
- const panel = document.getElementById('codex-resume-panel');
612
631
  panel.classList.toggle('active', codexResumePanelOpen);
613
632
  updateTerminalControlsHeight();
614
633
  if (!codexResumePanelOpen) return;
615
634
  await loadCodexThreadPanel(panel, 'resume');
616
635
  }
617
636
  async function toggleCodexForkPanel() {
618
- if (!(codexState.presentation === 'structured' && codexState.status === 'idle')) return;
637
+ if (!codexReadyForInput()) return;
619
638
  codexForkPanelOpen = !codexForkPanelOpen;
620
639
  codexModelPanelOpen = false;
621
640
  codexResumePanelOpen = false;
@@ -632,6 +651,7 @@
632
651
  await loadCodexThreadPanel(panel, 'fork');
633
652
  }
634
653
  async function loadCodexThreadPanel(panel, action) {
654
+ delete panel.dataset.resumeError;
635
655
  panel.innerHTML = '<div class="claude-resume-meta" style="padding:12px;">Loading sessions...</div>';
636
656
  try {
637
657
  const res = await fetchWithTimeout(`/api/sessions/${activeSessionId}/codex-resume-threads`, {}, 30000);
@@ -889,7 +909,7 @@
889
909
  }
890
910
 
891
911
  async function toggleCodexSkillPanel() {
892
- if (!(codexState.presentation === 'structured' && codexState.status === 'idle')) return;
912
+ if (!codexReadyForInput()) return;
893
913
  codexSkillPanelOpen = !codexSkillPanelOpen;
894
914
  codexModelPanelOpen = false;
895
915
  codexResumePanelOpen = false;
@@ -941,11 +961,37 @@
941
961
  applyCodexState({});
942
962
  }
943
963
  async function selectCodexResumeThread(threadId) {
944
- const res = await fetchWithTimeout(`/api/sessions/${activeSessionId}/codex-resume`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ threadId }) }, 30000);
945
- if (!res.ok) { alert((await res.json()).error || 'Unable to resume Codex thread'); return; }
946
- codexResumePanelOpen = false;
947
- 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 });
948
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
+ }
949
995
  }
950
996
  async function selectCodexForkThread(threadId) {
951
997
  const panel = document.getElementById('codex-fork-panel');
@@ -206,6 +206,7 @@
206
206
  return;
207
207
  }
208
208
  if ((val || readyImageAttachments.length) && isCodexSession() && codexState.presentation === 'structured') {
209
+ if (!codexReadyForInput()) return;
209
210
  if (currentSocket && currentSocket.readyState === 1) {
210
211
  currentSocket.send(JSON.stringify({
211
212
  type: 'codex-input',
package/lib/web/core.js CHANGED
@@ -30,12 +30,23 @@
30
30
  let claudeResumeItemsLoaded = false;
31
31
  let claudeRenderFrame = null;
32
32
  let claudeApprovalJumpIndex = 0;
33
+ function createDefaultCodexState() {
34
+ return {
35
+ permissionMode: 'default', sandboxMode: 'default',
36
+ effectivePermissionMode: null, effectiveSandboxMode: null,
37
+ model: null, effort: null, status: 'idle', threadId: null,
38
+ presentation: 'structured', models: [], aborting: false, resuming: false,
39
+ canAbort: false, canCompact: false, compacting: false,
40
+ canSwitchToTerminal: false, canSwitchToStructured: false
41
+ };
42
+ }
33
43
  let codexMessages = [];
34
44
  let codexPendingPermissions = [];
35
- let codexState = { permissionMode: 'default', sandboxMode: 'default', effectivePermissionMode: null, effectiveSandboxMode: null, model: null, effort: null, status: 'idle', threadId: null, presentation: 'structured', models: [], canAbort: false, canCompact: false, compacting: false, canSwitchToTerminal: false, canSwitchToStructured: false };
45
+ let codexState = createDefaultCodexState();
36
46
  let codexModelPanelOpen = false;
37
47
  let codexModelCandidate = null;
38
48
  let codexResumePanelOpen = false;
49
+ let codexResumeInFlight = false;
39
50
  let codexForkPanelOpen = false;
40
51
  let codexPromptPanelOpen = false;
41
52
  let codexPromptItems = [];
@@ -226,14 +237,14 @@
226
237
  : '';
227
238
  html += `<div class="session-card">
228
239
  <div class="session-info">
229
- <h3>${escapeHtml(s.name)}${s.hasUnreadCompletion ? '<span class="completion-dot" title="Completed"></span>' : ''}${timerBadge} <button class="icon-btn" onclick="renameSession('${s.id}', decodePathValue('${encodedName}'), event)"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg></button></h3>
240
+ <h3>${escapeHtml(s.name)}${s.hasUnreadCompletion ? '<span class="completion-dot" title="Completed"></span>' : ''}${timerBadge} <button type="button" class="icon-btn session-edit-btn" title="Rename session" aria-label="Rename session" onclick="renameSession('${s.id}', decodePathValue('${encodedName}'), event)"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg></button></h3>
230
241
  <p>${escapeHtml(s.tool)}</p>
231
242
  <p>${new Date(s.startTime).toLocaleTimeString()}</p>
232
243
  </div>
233
244
  <div class="session-actions">
234
245
  ${renderServerChanSessionAction(s)}
235
246
  <button class="btn-join" onclick="joinSession('${s.id}', decodePathValue('${encodedName}'), '${escapeHtml(s.toolKey || '')}')">Connect</button>
236
- <button class="icon-btn btn-delete" onclick="deleteSession('${s.id}', event)"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg></button>
247
+ <button type="button" class="icon-btn btn-delete session-delete-btn" title="Delete session" aria-label="Delete session" onclick="deleteSession('${s.id}', event)"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg></button>
237
248
  </div>
238
249
  <div class="session-dir-row">
239
250
  <button class="copy-dir-btn" title="Copy directory" onclick="copySessionDirectory(decodePathValue('${encodedDir}'), event)">
@@ -155,7 +155,7 @@
155
155
  document.getElementById('codex-prompt-panel').classList.remove('active');
156
156
  document.getElementById('codex-skill-panel').classList.remove('active');
157
157
  document.getElementById('codex-control-rail').scrollLeft = 0;
158
- codexState = { permissionMode: 'default', sandboxMode: 'default', effectivePermissionMode: null, effectiveSandboxMode: null, model: null, effort: null, status: 'idle', threadId: null, presentation: 'structured', models: [], canAbort: false, canCompact: false, compacting: false, canSwitchToTerminal: false, canSwitchToStructured: false };
158
+ codexState = createDefaultCodexState();
159
159
  setClaudeModeEnabled(false);
160
160
  applyCodexState(codexState);
161
161
  installCodexLazyDetailHandler();
@@ -11,8 +11,7 @@
11
11
  .header-action-btn.icon-only { width: 36px; padding: 0; }
12
12
  .header-action-btn:active { background: #0062cc; transform: scale(.97); }
13
13
  .btn-retry { background: #333; color: #fff; border: none; padding: 8px 16px; border-radius: 20px; margin-top: 10px; cursor: pointer; }
14
- .session-card { background: var(--card-bg); border-radius: 12px; padding: 12px 16px 8px; margin-bottom: 12px; display: grid; grid-template-columns: minmax(0, 1fr) auto; column-gap: 10px; row-gap: 0; align-items: center; transition: transform 0.1s; position: relative; }
15
- .session-card:active { transform: scale(0.98); }
14
+ .session-card { background: var(--card-bg); border-radius: 12px; padding: 12px 16px 8px; margin-bottom: 12px; display: grid; grid-template-columns: minmax(0, 1fr) auto; column-gap: 10px; row-gap: 0; align-items: center; position: relative; }
16
15
  .completion-dot { width: 9px; height: 9px; border-radius: 50%; background: #ff3b30; flex-shrink: 0; }
17
16
  .session-info { flex: 1; min-width: 0; }
18
17
  .session-info h3 { margin: 0 0 4px 0; font-size: 17px; display: flex; align-items: center; gap: 8px; }
@@ -29,6 +28,11 @@
29
28
  .btn-join { background: rgba(255,255,255,0.1); border: none; color: var(--primary); padding: 8px 14px; border-radius: 18px; font-weight: 600; font-size: 14px; cursor: pointer; }
30
29
  .icon-btn { color: var(--text-dim); background: none; border: none; padding: 4px; display: flex; align-items: center; justify-content: center; cursor: pointer; }
31
30
  .icon-btn:active { color: var(--text); }
31
+ .session-edit-btn, .session-delete-btn { position: relative; }
32
+ .session-edit-btn::before, .session-delete-btn::before { content: ""; position: absolute; }
33
+ .session-edit-btn::before { inset: -9px; }
34
+ .session-delete-btn::before { inset: -8px; }
35
+ .session-edit-btn svg, .session-delete-btn svg { pointer-events: none; }
32
36
  .btn-delete { color: #ff3b30; }
33
37
  #modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.8); z-index: 10000; display: none; align-items: center; justify-content: center; padding: 20px; }
34
38
  #tool-modal { background: var(--card-bg); width: 100%; max-width: 400px; border-radius: 16px; padding: 20px; box-shadow: 0 20px 40px rgba(0,0,0,0.4); }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "glad-web",
3
- "version": "1.0.40",
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"