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 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
- 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;
@@ -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 };