glad-web 1.0.39 → 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/README.md +1 -0
- package/README.zh-CN.md +1 -0
- package/lib/codex/structured-session.js +192 -22
- package/lib/commands/web.js +8 -3
- package/lib/server/routes/usage.js +23 -0
- package/lib/session/session-manager.js +2 -2
- package/lib/usage/ccusage-runner.js +128 -0
- package/lib/usage/source-catalog.js +26 -0
- package/lib/usage/usage-service.js +226 -0
- package/lib/web/codex.js +22 -7
- package/lib/web/composer.js +1 -0
- package/lib/web/core.js +13 -3
- package/lib/web/index.html +88 -1
- package/lib/web/session.js +1 -1
- package/lib/web/styles.css +87 -2
- package/lib/web/usage.js +323 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -34,6 +34,7 @@ Glad was created to enable **vibe coding** on mobile devices. By bringing variou
|
|
|
34
34
|
|
|
35
35
|
Our design philosophy is **Easy to use, Stable, and Restrained**. Glad focuses strictly on the essentials:
|
|
36
36
|
- **Session management:** Run multiple sessions from a single dashboard with per-session working directories.
|
|
37
|
+
- **Local usage dashboard:** Select a week or month, compare per-model and daily token totals, and inspect model-stacked token and cost charts through the bundled read-only `ccusage` engine. Costs use `ccusage` estimates and are shown only for GPT models used by Codex.
|
|
37
38
|
- **High-fidelity terminal interaction:** A mobile-friendly terminal experience with touch shortcuts.
|
|
38
39
|
- **Extreme performance history viewing:** Fast and responsive text history.
|
|
39
40
|
- **Simple but effective change checking:** Integrated Git changes preview.
|
package/README.zh-CN.md
CHANGED
|
@@ -34,6 +34,7 @@ Glad 的初衷是开发一款完全运行在本地的、足够简单的,且登
|
|
|
34
34
|
|
|
35
35
|
我们的设计哲学是:**易用、稳定、克制**。只提供最核心且体验优秀的功能:
|
|
36
36
|
- **Session 管理**:在一个面板中管理多个会话,每个会话可单独指定工作目录。
|
|
37
|
+
- **本地用量看板**:通过内置的只读 `ccusage` 引擎选择某周或某月,查看按模型汇总及每日 token,并用按模型堆叠的柱状图比较 token 和费用;费用完全采用 `ccusage` 估算,且只对 Codex 使用的 GPT 模型显示。
|
|
37
38
|
- **高还原度的 terminal 交互**:专为手机优化的终端体验与快捷按键。
|
|
38
39
|
- **极致性能的历史查看**:快速流畅的终端历史记录浏览。
|
|
39
40
|
- **简单但足够好用的改动检查**:内置 Git 改动预览功能。
|
|
@@ -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
|
@@ -47,6 +47,8 @@ const registerScheduleRoutes = require('../server/routes/schedules');
|
|
|
47
47
|
const registerWorkspaceRoutes = require('../server/routes/workspace');
|
|
48
48
|
const registerProviderRoutes = require('../server/routes/providers');
|
|
49
49
|
const registerNotificationRoutes = require('../server/routes/notifications');
|
|
50
|
+
const registerUsageRoutes = require('../server/routes/usage');
|
|
51
|
+
const { UsageService } = require('../usage/usage-service');
|
|
50
52
|
const { ServerChanSettingsStore } = require('../notifications/serverchan-settings-store');
|
|
51
53
|
const ServerChanClient = require('../notifications/serverchan-client');
|
|
52
54
|
const NotificationService = require('../notifications/notification-service');
|
|
@@ -101,6 +103,7 @@ async function webCommand(options) {
|
|
|
101
103
|
channel: new ServerChanClient(),
|
|
102
104
|
logger
|
|
103
105
|
});
|
|
106
|
+
const usageService = new UsageService({ logger });
|
|
104
107
|
sessionManager.on('output', ({ sessionId, data }) => {
|
|
105
108
|
broadcastToSession(sessionId, { type: 'output', data });
|
|
106
109
|
});
|
|
@@ -143,6 +146,7 @@ async function webCommand(options) {
|
|
|
143
146
|
settingsStore: serverChanSettings,
|
|
144
147
|
notificationService
|
|
145
148
|
});
|
|
149
|
+
registerUsageRoutes(app, { usageService, sendJson: sendCompressedJson });
|
|
146
150
|
|
|
147
151
|
// API: List all active sessions
|
|
148
152
|
app.get('/api/sessions', (req, res) => {
|
|
@@ -272,8 +276,8 @@ async function webCommand(options) {
|
|
|
272
276
|
});
|
|
273
277
|
|
|
274
278
|
// API: Delete/Kill session
|
|
275
|
-
app.delete('/api/sessions/:id', (req, res) => {
|
|
276
|
-
sessionManager.kill(req.params.id);
|
|
279
|
+
app.delete('/api/sessions/:id', async (req, res) => {
|
|
280
|
+
await sessionManager.kill(req.params.id);
|
|
277
281
|
res.json({ success: true });
|
|
278
282
|
});
|
|
279
283
|
|
|
@@ -481,7 +485,8 @@ async function webCommand(options) {
|
|
|
481
485
|
'composer.js',
|
|
482
486
|
'timed-inputs.js',
|
|
483
487
|
'terminal-scroll.js',
|
|
484
|
-
'git.js'
|
|
488
|
+
'git.js',
|
|
489
|
+
'usage.js'
|
|
485
490
|
];
|
|
486
491
|
for (const assetName of webAssets) {
|
|
487
492
|
const escapedName = assetName.replace('.', '\\.');
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module.exports = function registerUsageRoutes(app, { usageService, sendJson = (_req, res, payload) => res.json(payload) }) {
|
|
2
|
+
app.get('/api/usage/sources', async (req, res) => {
|
|
3
|
+
try {
|
|
4
|
+
res.json(await usageService.listSources(req.query.refresh === '1'));
|
|
5
|
+
} catch (error) {
|
|
6
|
+
res.status(error.statusCode || 500).json({ error: error.message || 'Failed to load usage sources' });
|
|
7
|
+
}
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
app.get('/api/usage/report', async (req, res) => {
|
|
11
|
+
try {
|
|
12
|
+
const report = await usageService.getDashboard(
|
|
13
|
+
req.query.source,
|
|
14
|
+
req.query.scope || 'weekly',
|
|
15
|
+
req.query.period,
|
|
16
|
+
req.query.refresh === '1'
|
|
17
|
+
);
|
|
18
|
+
sendJson(req, res, report);
|
|
19
|
+
} catch (error) {
|
|
20
|
+
res.status(error.statusCode || 500).json({ error: error.message || 'Failed to load usage report' });
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
};
|
|
@@ -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();
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
const { spawn } = require('child_process');
|
|
2
|
+
const { chmodSync, statSync } = require('fs');
|
|
3
|
+
|
|
4
|
+
const MAX_OUTPUT_BYTES = 64 * 1024 * 1024;
|
|
5
|
+
const DEFAULT_TIMEOUT_MS = 45000;
|
|
6
|
+
|
|
7
|
+
const NATIVE_PACKAGES = {
|
|
8
|
+
'darwin-arm64': '@ccusage/ccusage-darwin-arm64',
|
|
9
|
+
'darwin-x64': '@ccusage/ccusage-darwin-x64',
|
|
10
|
+
'linux-arm64': '@ccusage/ccusage-linux-arm64',
|
|
11
|
+
'linux-x64': '@ccusage/ccusage-linux-x64',
|
|
12
|
+
'win32-arm64': '@ccusage/ccusage-win32-arm64',
|
|
13
|
+
'win32-x64': '@ccusage/ccusage-win32-x64'
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
function resolveCcusageBinary(platform = process.platform, arch = process.arch) {
|
|
17
|
+
const packageName = NATIVE_PACKAGES[`${platform}-${arch}`];
|
|
18
|
+
if (!packageName) {
|
|
19
|
+
throw new Error(`ccusage is not available for ${platform}-${arch}`);
|
|
20
|
+
}
|
|
21
|
+
const binaryName = platform === 'win32' ? 'ccusage.exe' : 'ccusage';
|
|
22
|
+
try {
|
|
23
|
+
return require.resolve(`${packageName}/bin/${binaryName}`);
|
|
24
|
+
} catch (_error) {
|
|
25
|
+
throw new Error(`ccusage native package is missing for ${platform}-${arch}`);
|
|
26
|
+
}
|
|
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
|
+
|
|
45
|
+
function reportArgs(timezone) {
|
|
46
|
+
return [
|
|
47
|
+
'daily',
|
|
48
|
+
'--sections', 'daily,weekly,monthly',
|
|
49
|
+
'--by-agent',
|
|
50
|
+
'--json',
|
|
51
|
+
'--offline',
|
|
52
|
+
'--timezone', timezone
|
|
53
|
+
];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
class CcusageRunner {
|
|
57
|
+
constructor(options = {}) {
|
|
58
|
+
this.binaryPath = options.binaryPath
|
|
59
|
+
|| ensureCcusageBinaryExecutable(resolveCcusageBinary());
|
|
60
|
+
this.timeoutMs = options.timeoutMs || DEFAULT_TIMEOUT_MS;
|
|
61
|
+
this.spawnProcess = options.spawnProcess || spawn;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
loadAllPeriods(timezone) {
|
|
65
|
+
return this.runJson(reportArgs(timezone));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
runJson(args) {
|
|
69
|
+
return new Promise((resolve, reject) => {
|
|
70
|
+
const child = this.spawnProcess(this.binaryPath, args, {
|
|
71
|
+
env: { ...process.env, NO_COLOR: '1' },
|
|
72
|
+
shell: false,
|
|
73
|
+
windowsHide: true
|
|
74
|
+
});
|
|
75
|
+
const stdout = [];
|
|
76
|
+
const stderr = [];
|
|
77
|
+
let outputBytes = 0;
|
|
78
|
+
let stderrBytes = 0;
|
|
79
|
+
let settled = false;
|
|
80
|
+
|
|
81
|
+
const finish = callback => {
|
|
82
|
+
if (settled) return;
|
|
83
|
+
settled = true;
|
|
84
|
+
clearTimeout(timer);
|
|
85
|
+
callback();
|
|
86
|
+
};
|
|
87
|
+
const timer = setTimeout(() => {
|
|
88
|
+
child.kill();
|
|
89
|
+
finish(() => reject(new Error('ccusage timed out while reading local usage data')));
|
|
90
|
+
}, this.timeoutMs);
|
|
91
|
+
|
|
92
|
+
child.stdout.on('data', chunk => {
|
|
93
|
+
outputBytes += chunk.length;
|
|
94
|
+
if (outputBytes > MAX_OUTPUT_BYTES) {
|
|
95
|
+
child.kill();
|
|
96
|
+
finish(() => reject(new Error('ccusage report exceeded the safe output limit')));
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
stdout.push(chunk);
|
|
100
|
+
});
|
|
101
|
+
child.stderr.on('data', chunk => {
|
|
102
|
+
if (stderrBytes >= 64 * 1024) return;
|
|
103
|
+
stderr.push(chunk);
|
|
104
|
+
stderrBytes += chunk.length;
|
|
105
|
+
});
|
|
106
|
+
child.on('error', error => finish(() => reject(new Error(`Unable to start ccusage: ${error.message}`))));
|
|
107
|
+
child.on('close', code => finish(() => {
|
|
108
|
+
const errorText = Buffer.concat(stderr).toString('utf8').trim();
|
|
109
|
+
if (code !== 0) {
|
|
110
|
+
reject(new Error(errorText || `ccusage exited with code ${code}`));
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
resolve(JSON.parse(Buffer.concat(stdout).toString('utf8')));
|
|
115
|
+
} catch (_error) {
|
|
116
|
+
reject(new Error('ccusage returned invalid JSON'));
|
|
117
|
+
}
|
|
118
|
+
}));
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
module.exports = {
|
|
124
|
+
CcusageRunner,
|
|
125
|
+
ensureCcusageBinaryExecutable,
|
|
126
|
+
reportArgs,
|
|
127
|
+
resolveCcusageBinary
|
|
128
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
const SOURCES = [
|
|
2
|
+
{ id: 'codex', label: 'Codex', badge: 'CX' },
|
|
3
|
+
{ id: 'claude', label: 'Claude', badge: 'CL' },
|
|
4
|
+
{ id: 'gemini', label: 'Gemini', badge: 'GE' },
|
|
5
|
+
{ id: 'opencode', label: 'OpenCode', badge: 'OC' },
|
|
6
|
+
{ id: 'copilot', label: 'Copilot', badge: 'CP' },
|
|
7
|
+
{ id: 'amp', label: 'Amp', badge: 'AM' },
|
|
8
|
+
{ id: 'droid', label: 'Droid', badge: 'DR' },
|
|
9
|
+
{ id: 'codebuff', label: 'Codebuff', badge: 'CB' },
|
|
10
|
+
{ id: 'hermes', label: 'Hermes', badge: 'HE' },
|
|
11
|
+
{ id: 'pi', label: 'Pi', badge: 'PI' },
|
|
12
|
+
{ id: 'goose', label: 'Goose', badge: 'GO' },
|
|
13
|
+
{ id: 'kilo', label: 'Kilo', badge: 'KI' },
|
|
14
|
+
{ id: 'kimi', label: 'Kimi', badge: 'KM' },
|
|
15
|
+
{ id: 'qwen', label: 'Qwen', badge: 'QW' },
|
|
16
|
+
{ id: 'openclaw', label: 'OpenClaw', badge: 'OA' },
|
|
17
|
+
{ id: 'grok', label: 'Grok', badge: 'GR' }
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
const SOURCE_BY_ID = new Map(SOURCES.map(source => [source.id, source]));
|
|
21
|
+
|
|
22
|
+
function getUsageSource(id) {
|
|
23
|
+
return SOURCE_BY_ID.get(String(id || '').toLowerCase()) || null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
module.exports = { SOURCES, getUsageSource };
|