glad-web 1.0.40 → 1.0.41

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.
@@ -7,6 +7,12 @@ const PTYManager = require('../session/pty-manager');
7
7
  const PERMISSION_MODES = new Set(['untrusted', 'on-request', 'never']);
8
8
  const SANDBOX_MODES = new Set(['read-only', 'workspace-write', 'danger-full-access']);
9
9
  const FALLBACK_EFFORTS = ['minimal', 'low', 'medium', 'high', 'xhigh', 'ultra'];
10
+ const DEFAULT_ABORT_GRACE_MS = 5000;
11
+ const PROCESS_SHUTDOWN_TIMEOUT_MS = 2000;
12
+
13
+ function turnKey(threadId, turnId) {
14
+ return `${String(threadId || '')}\n${String(turnId || '')}`;
15
+ }
10
16
 
11
17
  function normalizePermissionMode(value) {
12
18
  const mode = String(value || 'default');
@@ -57,11 +63,37 @@ function appServerSpawnOptions({ cwd, env, platform = process.platform }) {
57
63
  if (platform === 'win32') {
58
64
  options.shell = true;
59
65
  options.windowsHide = true;
66
+ } else {
67
+ // Keep the npm wrapper, native Codex binary, and MCP children in one group
68
+ // so a forced abort can stop the complete app-server process tree.
69
+ options.detached = true;
60
70
  }
61
71
 
62
72
  return options;
63
73
  }
64
74
 
75
+ function forceKillProcessTree(child, options = {}) {
76
+ const platform = options.platform || process.platform;
77
+ const killGroup = options.killGroup || process.kill;
78
+ const spawnProcess = options.spawnProcess || spawn;
79
+ if (!child?.pid) return false;
80
+ if (platform !== 'win32') {
81
+ try {
82
+ killGroup(-child.pid, 'SIGKILL');
83
+ return true;
84
+ } catch (_error) {
85
+ try { return child.kill('SIGKILL'); } catch (_) { return false; }
86
+ }
87
+ }
88
+ const killer = spawnProcess('taskkill', ['/pid', String(child.pid), '/t', '/f'], {
89
+ stdio: 'ignore', windowsHide: true
90
+ });
91
+ killer.once('error', () => {
92
+ try { child.kill('SIGKILL'); } catch (_) { /* process already exited */ }
93
+ });
94
+ return true;
95
+ }
96
+
65
97
  function textFromInputItems(content) {
66
98
  return (Array.isArray(content) ? content : [])
67
99
  .filter(item => item && item.type === 'text')
@@ -233,6 +265,14 @@ class CodexStructuredSession extends EventEmitter {
233
265
  this.pendingRequests = new Map();
234
266
  this.process = null;
235
267
  this.processReady = null;
268
+ this.processShutdown = null;
269
+ this.needsThreadResume = false;
270
+ this.aborting = false;
271
+ this.abortTargets = new Map();
272
+ this.abortTimer = null;
273
+ this.abortGraceMs = Number(options.abortGraceMs) > 0
274
+ ? Number(options.abortGraceMs) : DEFAULT_ABORT_GRACE_MS;
275
+ this.forceKillProcessTree = options.forceKillProcessTree || forceKillProcessTree;
236
276
  this.terminalSession = null;
237
277
  this.terminalOutput = '';
238
278
  this.hasUnreadCompletion = false;
@@ -312,10 +352,13 @@ class CodexStructuredSession extends EventEmitter {
312
352
  effectivePermissionMode: this.effectivePermissionMode, effectiveSandboxMode: this.effectiveSandboxMode,
313
353
  model: this.model, effort: this.effort,
314
354
  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),
355
+ aborting: this.aborting,
356
+ canAbort: this.presentation === 'structured' && this.status !== 'idle' && !this.aborting,
357
+ canCompact: this.presentation === 'structured' && this.status === 'idle' && !this.compacting
358
+ && !this.aborting && Boolean(this.threadId),
317
359
  compacting: this.compacting,
318
- canSwitchToTerminal: this.presentation === 'structured' && this.status === 'idle' && Boolean(this.threadId),
360
+ canSwitchToTerminal: this.presentation === 'structured' && this.status === 'idle'
361
+ && !this.aborting && Boolean(this.threadId),
319
362
  canSwitchToStructured: this.presentation === 'terminal',
320
363
  pendingPermissionCount: this.pendingPermissions.size, activeSubagentCount, models: this.models };
321
364
  }
@@ -362,7 +405,27 @@ class CodexStructuredSession extends EventEmitter {
362
405
  return completed;
363
406
  }
364
407
 
408
+ clearAbortState(emit = true) {
409
+ const changed = this.aborting;
410
+ if (this.abortTimer) clearTimeout(this.abortTimer);
411
+ this.abortTimer = null;
412
+ this.abortTargets.clear();
413
+ this.aborting = false;
414
+ if (emit && changed) this.emitControlState();
415
+ }
416
+
417
+ finishAbortIfComplete() {
418
+ if (!this.aborting || this.abortTargets.size) return false;
419
+ this.clearAbortState(false);
420
+ const hasActiveTurn = this.currentTurnId
421
+ || Array.from(this.threadTurns.values()).some(turn => turn?.status === 'running');
422
+ if (!hasActiveTurn && this.status !== 'idle') this.setStatus('idle');
423
+ else this.emitControlState();
424
+ return true;
425
+ }
426
+
365
427
  async ensureProcess() {
428
+ if (this.processShutdown) await this.processShutdown;
366
429
  if (this.processReady) return this.processReady;
367
430
  this.processReady = new Promise((resolve, reject) => {
368
431
  const child = spawn(this.tool.command, ['app-server', '--listen', 'stdio://'], appServerSpawnOptions({
@@ -384,6 +447,8 @@ class CodexStructuredSession extends EventEmitter {
384
447
  this.pendingRequests.clear();
385
448
  if (this.running && this.presentation === 'structured') {
386
449
  const activeTurn = Boolean(this.currentTurnId || this.status === 'running' || this.status === 'waiting_approval');
450
+ this.needsThreadResume = Boolean(this.threadId);
451
+ this.clearAbortState(false);
387
452
  this.compacting = false;
388
453
  this.append({ kind: 'event', level: 'error', text: 'Codex app-server exited.' });
389
454
  this.emitEvent({
@@ -514,8 +579,16 @@ class CodexStructuredSession extends EventEmitter {
514
579
  const durationMs = Number(params.turn?.durationMs || 0)
515
580
  || (startedAtMs ? Math.max(0, completedAtMs - startedAtMs) : null);
516
581
  const context = this.turnContexts.get(String(completedTurnId || ''));
517
- this.append({ kind: 'turn-end', threadId, turnId: completedTurnId, status: turnStatus,
518
- durationMs, createdAt: completedAtMs, ...(context ? { context } : {}) });
582
+ const existingTurnEnd = this.messages.find(item => item.kind === 'turn-end'
583
+ && item.threadId === threadId && item.turnId === completedTurnId);
584
+ if (existingTurnEnd) {
585
+ this.patch(existingTurnEnd.id, { status: turnStatus, durationMs,
586
+ createdAt: completedAtMs, ...(context ? { context } : {}) });
587
+ } else {
588
+ this.append({ kind: 'turn-end', threadId, turnId: completedTurnId, status: turnStatus,
589
+ durationMs, createdAt: completedAtMs, ...(context ? { context } : {}) });
590
+ }
591
+ this.abortTargets.delete(turnKey(threadId, completedTurnId));
519
592
  const observedNow = Date.now();
520
593
  const observedCompletedAtMs = Math.abs(observedNow - completedAtMs) < 5000
521
594
  ? Math.max(completedAtMs, observedNow) : completedAtMs;
@@ -543,11 +616,12 @@ class CodexStructuredSession extends EventEmitter {
543
616
  if (params.turn?.status === 'failed' || params.turn?.error) {
544
617
  this.append({ kind: 'event', level: 'error', text: params.turn?.error?.message || 'Codex turn failed.' });
545
618
  }
546
- this.setStatus('idle');
619
+ this.setStatus(this.aborting && this.abortTargets.size ? 'running' : 'idle');
547
620
  this.hasUnreadCompletion = true;
548
621
  } else {
549
622
  this.emitControlState();
550
623
  }
624
+ this.finishAbortIfComplete();
551
625
  return;
552
626
  }
553
627
  if (method === 'thread/compacted') {
@@ -568,7 +642,7 @@ class CodexStructuredSession extends EventEmitter {
568
642
  const status = params.status?.type || params.status;
569
643
  if (!threadId || threadId === this.threadId) {
570
644
  if (status === 'idle' && !this.currentTurnId) { this.compacting = false; this.setStatus('idle'); }
571
- if (status === 'active') this.setStatus('running');
645
+ if (status === 'active' && !this.aborting) this.setStatus('running');
572
646
  }
573
647
  return;
574
648
  }
@@ -952,11 +1026,32 @@ class CodexStructuredSession extends EventEmitter {
952
1026
  return this.getControlState();
953
1027
  }
954
1028
 
1029
+ async resumeThreadAfterProcessRestart() {
1030
+ if (!this.threadId || !this.needsThreadResume) return false;
1031
+ const result = await this.request('thread/resume', this.threadResumeParams(this.threadId));
1032
+ this.needsThreadResume = false;
1033
+ this.model = result.model || result.thread?.model || this.model;
1034
+ this.effort = result.reasoningEffort || result.thread?.reasoningEffort || this.effort;
1035
+ this.effectivePermissionMode = result.approvalPolicy || this.effectivePermissionMode;
1036
+ this.effectiveSandboxMode = sandboxModeFromPolicy(result.sandbox) || this.effectiveSandboxMode;
1037
+ return true;
1038
+ }
1039
+
1040
+ threadResumeParams(threadId) {
1041
+ const params = { threadId, cwd: this.workingDir };
1042
+ if (this.permissionMode) params.approvalPolicy = this.permissionMode;
1043
+ if (this.sandboxMode) params.sandbox = this.sandboxMode;
1044
+ if (this.hasModelOverride && this.model) params.model = this.model;
1045
+ if (this.hasEffortOverride && this.effort) params.config = { model_reasoning_effort: this.effort };
1046
+ return params;
1047
+ }
1048
+
955
1049
  async sendUserMessage(text, attachments = [], skills = []) {
956
1050
  const prompt = String(text || '').trim();
957
1051
  const images = (Array.isArray(attachments) ? attachments : [])
958
1052
  .filter(item => item && typeof item.path === 'string' && item.path);
959
- if ((!prompt && images.length === 0) || this.presentation !== 'structured' || this.status !== 'idle') return false;
1053
+ if ((!prompt && images.length === 0) || this.presentation !== 'structured'
1054
+ || this.status !== 'idle' || this.aborting) return false;
960
1055
  this.hasUnreadCompletion = false;
961
1056
  this.promptHistoryCache = null;
962
1057
  this.append({
@@ -967,8 +1062,10 @@ class CodexStructuredSession extends EventEmitter {
967
1062
  name: String(item?.name || ''), path: String(item?.path || '')
968
1063
  })).filter(item => item.name && item.path)
969
1064
  });
1065
+ this.setStatus('running');
970
1066
  try {
971
1067
  await this.ensureProcess();
1068
+ await this.resumeThreadAfterProcessRestart();
972
1069
  if (!this.threadId) {
973
1070
  const params = { cwd: this.workingDir };
974
1071
  if (this.hasModelOverride) params.model = this.model;
@@ -976,13 +1073,13 @@ class CodexStructuredSession extends EventEmitter {
976
1073
  if (this.sandboxMode) params.sandbox = this.sandboxMode;
977
1074
  const started = await this.request('thread/start', params);
978
1075
  this.threadId = started.thread?.id;
1076
+ this.needsThreadResume = false;
979
1077
  this.model = started.model || this.model;
980
1078
  this.effort = started.reasoningEffort || this.effort;
981
1079
  this.effectivePermissionMode = started.approvalPolicy || this.effectivePermissionMode;
982
1080
  this.effectiveSandboxMode = sandboxModeFromPolicy(started.sandbox) || this.effectiveSandboxMode;
983
1081
  this.emitEvent({ type: 'state', state: this.getControlState() });
984
1082
  }
985
- this.setStatus('running');
986
1083
  const input = [];
987
1084
  input.push(...await this.resolveSkillInputs(skills));
988
1085
  if (prompt) input.push({ type: 'text', text: prompt });
@@ -1008,7 +1105,7 @@ class CodexStructuredSession extends EventEmitter {
1008
1105
  }
1009
1106
 
1010
1107
  async compactContext() {
1011
- if (!this.threadId || this.presentation !== 'structured' || this.status !== 'idle') return false;
1108
+ if (!this.threadId || this.presentation !== 'structured' || this.status !== 'idle' || this.aborting) return false;
1012
1109
  await this.ensureProcess();
1013
1110
  this.compacting = true;
1014
1111
  this.setStatus('running');
@@ -1060,6 +1157,7 @@ class CodexStructuredSession extends EventEmitter {
1060
1157
 
1061
1158
  abort(reason = 'Aborted by user') {
1062
1159
  if (this.presentation !== 'structured' || this.status === 'idle') return false;
1160
+ if (this.aborting) return true;
1063
1161
  for (const pending of this.pendingPermissions.values()) {
1064
1162
  const response = pending.method === 'item/permissions/requestApproval'
1065
1163
  ? { permissions: {}, scope: 'turn' }
@@ -1077,32 +1175,77 @@ class CodexStructuredSession extends EventEmitter {
1077
1175
  && !targets.some(target => target.threadId === this.threadId && target.turnId === this.currentTurnId)) {
1078
1176
  targets.push({ threadId: this.threadId, turnId: this.currentTurnId });
1079
1177
  }
1178
+ this.aborting = true;
1179
+ this.abortTargets = new Map(targets.map(target => [turnKey(target.threadId, target.turnId), target]));
1180
+ this.emitControlState();
1080
1181
  for (const target of targets) {
1081
1182
  this.request('turn/interrupt', target).catch(error => {
1082
1183
  this.logger.debugInfo?.(`[codex-app-server] turn/interrupt failed for ${target.threadId}/${target.turnId}: ${error.message}`);
1083
1184
  });
1084
1185
  }
1085
1186
  this.append({ kind: 'event', level: 'info', text: reason });
1187
+ this.abortTimer = setTimeout(() => this.forceAbortAfterTimeout(), this.abortGraceMs);
1188
+ this.abortTimer.unref?.();
1189
+ return true;
1190
+ }
1191
+
1192
+ forceAbortAfterTimeout() {
1193
+ if (!this.aborting) return false;
1194
+ const targets = Array.from(this.abortTargets.values());
1195
+ const completedAtMs = Date.now();
1196
+ const timeoutSeconds = Math.max(1, Math.round(this.abortGraceMs / 1000));
1197
+ this.append({ kind: 'event', level: 'warning',
1198
+ text: `Codex did not stop within ${timeoutSeconds} seconds. Stopping its app-server.` });
1199
+
1200
+ for (const target of targets) {
1201
+ const tracked = this.threadTurns.get(target.threadId);
1202
+ const startedAtMs = Number(tracked?.startedAt || 0)
1203
+ || (target.threadId === this.threadId ? Number(this.currentTurnStartedAt || 0) : 0);
1204
+ const existingTurnEnd = this.messages.find(item => item.kind === 'turn-end'
1205
+ && item.threadId === target.threadId && item.turnId === target.turnId);
1206
+ if (!existingTurnEnd) {
1207
+ this.append({ kind: 'turn-end', threadId: target.threadId, turnId: target.turnId,
1208
+ status: 'cancelled', durationMs: startedAtMs ? Math.max(0, completedAtMs - startedAtMs) : null,
1209
+ createdAt: completedAtMs });
1210
+ }
1211
+ }
1212
+ for (const item of this.messages.filter(message => message.kind === 'tool'
1213
+ && ['running', 'inProgress'].includes(message.toolStatus))) {
1214
+ const startedAtMs = Number(item.startedAtMs || item.createdAt || 0);
1215
+ this.patch(item.id, { toolStatus: 'cancelled', completedAtMs,
1216
+ ...(startedAtMs ? { durationMs: Math.max(1, completedAtMs - startedAtMs) } : {}) });
1217
+ }
1218
+
1219
+ this.needsThreadResume = Boolean(this.threadId);
1220
+ this.currentTurnId = null;
1221
+ this.currentTurnStartedAt = null;
1222
+ this.threadTurns.clear();
1223
+ this.pendingPermissions.clear();
1224
+ this.compacting = false;
1225
+ this.hasUnreadCompletion = true;
1226
+ this.clearAbortState(false);
1227
+ void this.disconnectProcess(true);
1228
+ if (this.status !== 'idle') this.setStatus('idle');
1229
+ else this.emitControlState();
1230
+ this.append({ kind: 'event', level: 'info',
1231
+ text: 'Codex app-server stopped. It will restart before the next message.' });
1086
1232
  return true;
1087
1233
  }
1088
1234
 
1089
1235
  async resume(threadId = null) {
1090
1236
  const target = String(threadId || this.threadId || '').trim();
1091
- if (!target || this.presentation !== 'structured' || this.status !== 'idle') return false;
1237
+ if (!target || this.presentation !== 'structured' || this.status !== 'idle' || this.aborting) return false;
1092
1238
  await this.ensureProcess();
1093
1239
  const selectedModel = this.model;
1094
1240
  const selectedEffort = this.effort;
1095
1241
  const resumeWithModelOverride = Boolean(this.hasModelOverride && selectedModel);
1096
1242
  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 };
1243
+ const params = this.threadResumeParams(target);
1102
1244
  this.deferredWarnings = [];
1103
1245
  try {
1104
1246
  const result = await this.request('thread/resume', params);
1105
1247
  this.threadId = result.thread?.id || target;
1248
+ this.needsThreadResume = false;
1106
1249
  this.hasModelOverride = resumeWithModelOverride;
1107
1250
  this.hasEffortOverride = resumeWithEffortOverride;
1108
1251
  this.model = result.model || result.thread?.model || selectedModel;
@@ -1167,7 +1310,7 @@ class CodexStructuredSession extends EventEmitter {
1167
1310
 
1168
1311
  async forkFrom(threadId) {
1169
1312
  const sourceThreadId = String(threadId || '').trim();
1170
- if (!sourceThreadId || this.presentation !== 'structured' || this.status !== 'idle') return false;
1313
+ if (!sourceThreadId || this.presentation !== 'structured' || this.status !== 'idle' || this.aborting) return false;
1171
1314
  await this.ensureProcess();
1172
1315
  const params = { threadId: sourceThreadId, cwd: this.workingDir, ephemeral: false, threadSource: null };
1173
1316
  if (this.hasModelOverride) params.model = this.model;
@@ -1177,6 +1320,7 @@ class CodexStructuredSession extends EventEmitter {
1177
1320
  const forkedThread = result?.thread;
1178
1321
  if (!forkedThread?.id) throw new Error('Codex did not return a forked thread');
1179
1322
  this.threadId = forkedThread.id;
1323
+ this.needsThreadResume = false;
1180
1324
  this.model = result.model || forkedThread.model || this.model;
1181
1325
  this.effort = result.reasoningEffort || forkedThread.reasoningEffort || forkedThread.reasoning_effort || this.effort;
1182
1326
  this.effectivePermissionMode = result.approvalPolicy || this.effectivePermissionMode;
@@ -1187,7 +1331,7 @@ class CodexStructuredSession extends EventEmitter {
1187
1331
  }
1188
1332
 
1189
1333
  async switchToTerminal() {
1190
- if (this.presentation !== 'structured' || this.status !== 'idle' || !this.threadId) throw new Error('Codex must be idle before switching to Terminal.');
1334
+ if (this.presentation !== 'structured' || this.status !== 'idle' || this.aborting || !this.threadId) throw new Error('Codex must be idle before switching to Terminal.');
1191
1335
  await this.disconnectProcess();
1192
1336
  this.terminalOutput = '';
1193
1337
  const terminal = new PTYManager(this.tool, this.workingDir, { append() {} }, { silent: true });
@@ -1218,19 +1362,45 @@ class CodexStructuredSession extends EventEmitter {
1218
1362
  return this.resume();
1219
1363
  }
1220
1364
 
1221
- async disconnectProcess() {
1222
- if (!this.process) return;
1365
+ async disconnectProcess(force = false) {
1366
+ if (!this.process) return this.processShutdown || undefined;
1223
1367
  const child = this.process;
1224
1368
  this.process = null;
1225
1369
  this.processReady = null;
1226
1370
  for (const request of this.pendingRequests.values()) { clearTimeout(request.timer); request.reject(new Error('Codex app-server disconnected')); }
1227
1371
  this.pendingRequests.clear();
1228
- child.kill();
1372
+ const shutdown = typeof child.once === 'function'
1373
+ ? new Promise(resolve => {
1374
+ let timer;
1375
+ const finish = () => { clearTimeout(timer); resolve(); };
1376
+ child.once('exit', finish);
1377
+ timer = setTimeout(() => {
1378
+ this.logger.debugInfo?.('[codex-app-server] process did not exit within 2 seconds');
1379
+ finish();
1380
+ }, PROCESS_SHUTDOWN_TIMEOUT_MS);
1381
+ })
1382
+ : Promise.resolve();
1383
+ this.processShutdown = shutdown;
1384
+ if (force) this.forceKillProcessTree(child);
1385
+ else child.kill();
1386
+ try {
1387
+ await shutdown;
1388
+ } finally {
1389
+ if (this.processShutdown === shutdown) this.processShutdown = null;
1390
+ }
1229
1391
  }
1230
1392
 
1231
1393
  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'); }
1394
+ async kill() {
1395
+ this.running = false;
1396
+ this.clearAbortState(false);
1397
+ this.terminalSession?.kill();
1398
+ this.terminalSession = null;
1399
+ await this.disconnectProcess(true);
1400
+ this.emit('exit');
1401
+ }
1233
1402
  }
1234
1403
 
1235
1404
  module.exports = CodexStructuredSession;
1236
1405
  module.exports.appServerSpawnOptions = appServerSpawnOptions;
1406
+ module.exports.forceKillProcessTree = forceKillProcessTree;
@@ -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
 
@@ -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,7 @@
1
1
  function codexText(text) { return escapeHtml(text || '').replace(/\n/g, '<br>'); }
2
+ function codexReadyForInput() {
3
+ return codexState.presentation === 'structured' && codexState.status === 'idle' && !codexState.aborting;
4
+ }
2
5
  function codexJson(value) {
3
6
  if (typeof value === 'string') return value;
4
7
  try { return JSON.stringify(value || {}, null, 2); } catch (_) { return String(value || ''); }
@@ -324,7 +327,7 @@
324
327
  i += 1;
325
328
  }
326
329
  for (const request of codexPendingPermissions) if (!usedPermissions.has(request.id)) parts.push(renderCodexPermission(request));
327
- const skillBubble = selectedCodexSkill && codexState.status === 'idle'
330
+ const skillBubble = selectedCodexSkill && codexReadyForInput()
328
331
  ? `<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
332
  : '';
330
333
  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 +446,18 @@
443
446
  const modelButton = document.getElementById('codex-model-btn');
444
447
  if (modelButton) modelButton.textContent = 'Model';
445
448
  const abort = document.getElementById('codex-abort-btn');
446
- if (abort) abort.disabled = !codexState.canAbort;
449
+ if (abort) {
450
+ abort.disabled = !codexState.canAbort;
451
+ abort.textContent = codexState.aborting ? 'Aborting…' : 'Abort';
452
+ }
447
453
  const compact = document.getElementById('codex-compact-btn');
448
454
  if (compact) { compact.disabled = !codexState.canCompact; compact.textContent = codexState.compacting ? 'Compacting' : 'Compact'; }
449
455
  const skills = document.getElementById('codex-skills-btn');
450
- if (skills) { skills.disabled = !(codexState.presentation === 'structured' && codexState.status === 'idle'); skills.classList.toggle('primary', Boolean(selectedCodexSkill)); }
456
+ if (skills) { skills.disabled = !codexReadyForInput(); skills.classList.toggle('primary', Boolean(selectedCodexSkill)); }
457
+ const resume = document.getElementById('codex-resume-btn');
458
+ if (resume) resume.disabled = !codexReadyForInput();
451
459
  const fork = document.getElementById('codex-fork-btn');
452
- if (fork) fork.disabled = !(codexState.presentation === 'structured' && codexState.status === 'idle');
460
+ if (fork) fork.disabled = !codexReadyForInput();
453
461
  const terminal = document.getElementById('codex-terminal-switch');
454
462
  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
463
  renderCodexStateBar();
@@ -464,6 +472,7 @@
464
472
  const pending = Number(codexState.pendingPermissionCount || codexPendingPermissions.filter(item => item.status === 'pending').length) || 0;
465
473
  const subagents = Number(codexState.activeSubagentCount || 0) || 0;
466
474
  const parts = [];
475
+ if (codexState.aborting) parts.push('<span class="claude-state-pill warn">Stopping Codex…</span>');
467
476
  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
477
  if (subagents) parts.push(`<span class="claude-state-pill">${subagents} subagent${subagents === 1 ? '' : 's'} running</span>`);
469
478
  el.innerHTML = parts.join('');
@@ -589,7 +598,12 @@
589
598
  applyCodexState({ permissionMode, sandboxMode });
590
599
  sendCodexSettings({ permissionMode, sandboxMode });
591
600
  }
592
- function abortCodexSession() { if (currentSocket?.readyState === 1) currentSocket.send(JSON.stringify({ type: 'codex-abort' })); }
601
+ function abortCodexSession() {
602
+ if (!codexState.canAbort || codexState.aborting || currentSocket?.readyState !== 1) return false;
603
+ currentSocket.send(JSON.stringify({ type: 'codex-abort' }));
604
+ applyCodexState({ aborting: true, canAbort: false });
605
+ return true;
606
+ }
593
607
  function requestCodexStatus() { if (currentSocket?.readyState === 1) currentSocket.send(JSON.stringify({ type: 'codex-status' })); }
594
608
  function compactCodexContext() {
595
609
  if (!codexState.canCompact || currentSocket?.readyState !== 1) return false;
@@ -599,6 +613,7 @@
599
613
  }
600
614
  function respondCodexPermission(id, decision) { if (currentSocket?.readyState === 1) currentSocket.send(JSON.stringify({ type: 'codex-permission', id, decision })); }
601
615
  async function toggleCodexResumePanel() {
616
+ if (!codexReadyForInput()) return;
602
617
  codexResumePanelOpen = !codexResumePanelOpen;
603
618
  codexModelPanelOpen = false;
604
619
  codexForkPanelOpen = false;
@@ -615,7 +630,7 @@
615
630
  await loadCodexThreadPanel(panel, 'resume');
616
631
  }
617
632
  async function toggleCodexForkPanel() {
618
- if (!(codexState.presentation === 'structured' && codexState.status === 'idle')) return;
633
+ if (!codexReadyForInput()) return;
619
634
  codexForkPanelOpen = !codexForkPanelOpen;
620
635
  codexModelPanelOpen = false;
621
636
  codexResumePanelOpen = false;
@@ -889,7 +904,7 @@
889
904
  }
890
905
 
891
906
  async function toggleCodexSkillPanel() {
892
- if (!(codexState.presentation === 'structured' && codexState.status === 'idle')) return;
907
+ if (!codexReadyForInput()) return;
893
908
  codexSkillPanelOpen = !codexSkillPanelOpen;
894
909
  codexModelPanelOpen = false;
895
910
  codexResumePanelOpen = false;
@@ -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,9 +30,19 @@
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,
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;
@@ -226,14 +236,14 @@
226
236
  : '';
227
237
  html += `<div class="session-card">
228
238
  <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>
239
+ <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
240
  <p>${escapeHtml(s.tool)}</p>
231
241
  <p>${new Date(s.startTime).toLocaleTimeString()}</p>
232
242
  </div>
233
243
  <div class="session-actions">
234
244
  ${renderServerChanSessionAction(s)}
235
245
  <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>
246
+ <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
247
  </div>
238
248
  <div class="session-dir-row">
239
249
  <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.41",
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"