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.
- package/lib/codex/structured-session.js +192 -22
- package/lib/commands/web.js +2 -2
- package/lib/session/session-manager.js +2 -2
- package/lib/usage/ccusage-runner.js +25 -2
- package/lib/web/codex.js +22 -7
- package/lib/web/composer.js +1 -0
- package/lib/web/core.js +13 -3
- package/lib/web/session.js +1 -1
- package/lib/web/styles.css +6 -2
- package/package.json +1 -1
|
@@ -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
|
-
|
|
316
|
-
|
|
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'
|
|
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.
|
|
518
|
-
|
|
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'
|
|
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 =
|
|
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.
|
|
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() {
|
|
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;
|
package/lib/commands/web.js
CHANGED
|
@@ -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
|
|
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 = {
|
|
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 &&
|
|
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)
|
|
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 = !(
|
|
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 = !(
|
|
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() {
|
|
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 (!(
|
|
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 (!(
|
|
907
|
+
if (!codexReadyForInput()) return;
|
|
893
908
|
codexSkillPanelOpen = !codexSkillPanelOpen;
|
|
894
909
|
codexModelPanelOpen = false;
|
|
895
910
|
codexResumePanelOpen = false;
|
package/lib/web/composer.js
CHANGED
|
@@ -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 =
|
|
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)">
|
package/lib/web/session.js
CHANGED
|
@@ -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 =
|
|
158
|
+
codexState = createDefaultCodexState();
|
|
159
159
|
setClaudeModeEnabled(false);
|
|
160
160
|
applyCodexState(codexState);
|
|
161
161
|
installCodexLazyDetailHandler();
|
package/lib/web/styles.css
CHANGED
|
@@ -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;
|
|
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); }
|