@staix/agent-hub 0.12.2 → 0.12.3

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/src/hub/daemon.ts CHANGED
@@ -6,7 +6,9 @@ import { spawn } from "node:child_process";
6
6
  import { childEnv } from "./child-process.ts";
7
7
  import { runCheck } from "./checks.ts";
8
8
  import { stripUntrusted } from "./config-trust.ts";
9
- import { eventLog, tokenDeltas } from "./events.ts";
9
+ import { eventLog, readEvents, tokenDeltas } from "./events.ts";
10
+ import { ExecutionBudget } from "./execution-budget.ts";
11
+ import { readClaudeTranscriptUsage } from "./usage.ts";
10
12
  import type { ServerWebSocket } from "bun";
11
13
  import { AcpPeer, type PermissionRequest } from "../adapters/acp.ts";
12
14
  import { CodexPeer } from "../adapters/codex-appserver.ts";
@@ -218,6 +220,8 @@ type Sock = ServerWebSocket<Client>;
218
220
 
219
221
  /** A peer that lives in another process and attaches over the control WS (the Claude channel plugin). */
220
222
  class WsPeer extends BasePeer {
223
+ private generation = crypto.randomUUID();
224
+ private delivered = new Set<string>();
221
225
  private sock: Sock | undefined;
222
226
  private claimed: Sock | undefined;
223
227
  /** Called at hello, before the async preface: the newest hello wins even if an older one's recall finishes last. */
@@ -231,8 +235,11 @@ class WsPeer extends BasePeer {
231
235
  }
232
236
  attach(sock: Sock): void {
233
237
  if (sock !== this.claimed) return void sock.close(4000, "replaced"); // a newer session said hello meanwhile
238
+ if (this.sock) this.setState("offline"); // unresolved work belongs to the previous connection
234
239
  this.sock?.close(4000, "replaced");
235
240
  this.sock = sock;
241
+ this.generation = crypto.randomUUID();
242
+ this.delivered.clear();
236
243
  this.setState("idle");
237
244
  }
238
245
  detach(sock: Sock): void {
@@ -242,9 +249,11 @@ class WsPeer extends BasePeer {
242
249
  }
243
250
  async deliver(envs: Envelope[], deliveryId?: string): Promise<void> {
244
251
  if (!this.sock) throw new Error(`${this.id} is offline`);
245
- this.sock.send(JSON.stringify({ t: "deliver", envs, ...(deliveryId ? { deliveryId } : {}) }));
252
+ if (deliveryId) this.delivered.add(deliveryId);
253
+ this.sock.send(JSON.stringify({ t: "deliver", envs, generation: this.generation, ...(deliveryId ? { deliveryId } : {}) }));
246
254
  }
247
255
  owns(sock: Sock): boolean { return this.sock === sock; }
256
+ ownsDelivery(sock: Sock, generation: unknown, id: string): boolean { return this.owns(sock) && generation === this.generation && this.delivered.has(id); }
248
257
  async start(): Promise<void> {}
249
258
  async stop(): Promise<void> {
250
259
  this.sock?.close();
@@ -441,6 +450,8 @@ export async function startDaemon(opts: DaemonOptions) {
441
450
  };
442
451
  const board = new Board(join(opts.stateDir, "hub.db"));
443
452
  startupCleanup.push(() => board.close());
453
+ const executionBudget = new ExecutionBudget(join(opts.stateDir, "hub.db"));
454
+ startupCleanup.push(() => executionBudget.close());
444
455
  // Completion checks (issue #7): only from a config file git does not track, one at a time, killed on stop.
445
456
  const checkCommands = Object.fromEntries(Object.entries(config.checks).filter(([k, v]) => (CLASSES as readonly string[]).includes(k) && typeof v === "string" && v.trim())) as Record<string, string>;
446
457
  const strayChecks = Object.keys(config.checks).filter((k) => k !== "timeout_s" && !(k in checkCommands));
@@ -456,6 +467,7 @@ export async function startDaemon(opts: DaemonOptions) {
456
467
  const tasks = new Tasks({
457
468
  board,
458
469
  bus,
470
+ executionBudget,
459
471
  routing: () => currentRouting(opts.cwd, log), // routing.toml is edited while the hub runs: re-read on change, last good parse kept
460
472
  cwd: opts.cwd,
461
473
  project: chain.at(-1)!,
@@ -807,6 +819,20 @@ export async function startDaemon(opts: DaemonOptions) {
807
819
  const config = process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude");
808
820
  return existsSync(join(config, "projects", opts.cwd.replace(/[^a-zA-Z0-9]/g, "-"), `${sessionId}.jsonl`));
809
821
  };
822
+ // Optional native usage: only the instance-fenced explicit Claude session is read.
823
+ // IDs persist in telemetry, so a restart/resume never counts the same native message twice.
824
+ const claudeNativeUsageSeen = new Set(readEvents(join(opts.stateDir, "events.jsonl")).filter(e => e.type === "usage" && e.source === "claude_transcript").map(e => (e as { id: string }).id));
825
+ const collectClaudeUsage = () => {
826
+ const session = claudeSession();
827
+ if (!session.sessionId || !session.transcriptPath) return;
828
+ for (const record of readClaudeTranscriptUsage(session.sessionId, session.transcriptPath)) {
829
+ if (claudeNativeUsageSeen.has(record.id)) continue;
830
+ claudeNativeUsageSeen.add(record.id);
831
+ event({ type: "usage", peer: "claude", source: "claude_transcript", id: record.id, ...record.usage, ...(record.servedModel ? { servedModel: record.servedModel } : {}), ...(record.at ? { measuredAt: record.at } : {}) });
832
+ }
833
+ };
834
+ const claudeUsageTimer = setInterval(collectClaudeUsage, 2000);
835
+ claudeUsageTimer.unref(); intervals.push(claudeUsageTimer);
810
836
  const recoveryPeers = (): Record<string, RestartPeerSnapshot> => Object.fromEntries([...bus.peers].map(([id, peer]) => {
811
837
  const metadata = peer.recoveryMetadata?.() ?? {};
812
838
  const row: RestartPeerSnapshot = { id, state: peer.state, queueIds: bus.queueIds(id), ...(metadata.launch ? { launch: metadata.launch as Record<string, unknown> } : {}) };
@@ -855,7 +881,7 @@ export async function startDaemon(opts: DaemonOptions) {
855
881
  ...(dashboard ? { uiOrigin: dashboard.origin } : {}),
856
882
  codexProxyPort: opts.codexProxyPort,
857
883
  peers: Object.fromEntries(
858
- bus.knownPeers().map((id) => { const p = bus.peers.get(id); const summary = bus.queueSummary(id); return [id, { state: bus.stateOf(id), queued: bus.queued(id), ...(p ? {} : { attached: false }), ...(summary.needsReview ? { needsReview: summary.needsReview } : {}), ...queueHoldStatus(id, summary.heldBy), ...(summary.oldestQueuedAt !== undefined ? { oldestQueuedAt: summary.oldestQueuedAt } : {}), ...(bus.queuedImportant(id) ? { queuedImportant: bus.queuedImportant(id) } : {}), ...pausedNote(id), ...(p instanceof LocalPeer && p.lastServedBy ? { servedBy: p.lastServedBy } : {}), ...(p instanceof WsPeer && p.claiming ? { claiming: true } : {}), ...(p instanceof PiPeer ? { requestedModel: p.getRequestedModel(), backends: modelRelay?.status().backends ?? [] } : {}) }]; }),
884
+ bus.knownPeers().map((id) => { const p = bus.peers.get(id); const summary = bus.queueSummary(id); return [id, { state: bus.stateOf(id), queued: bus.queued(id), ...(p ? {} : { attached: false }), ...(summary.needsReview ? { needsReview: summary.needsReview } : {}), ...(summary.liveAccepted ? { liveAccepted: summary.liveAccepted, settlementNote: "awaiting adapter completion (Claude: correlated reply or hub_delivery_done); task state is independent" } : {}), ...queueHoldStatus(id, summary.heldBy), ...(summary.oldestQueuedAt !== undefined ? { oldestQueuedAt: summary.oldestQueuedAt } : {}), ...(bus.queuedImportant(id) ? { queuedImportant: bus.queuedImportant(id) } : {}), ...pausedNote(id), ...(p instanceof LocalPeer && p.lastServedBy ? { servedBy: p.lastServedBy } : {}), ...(p instanceof WsPeer && p.claiming ? { claiming: true } : {}), ...(p instanceof PiPeer ? { requestedModel: p.getRequestedModel(), backends: modelRelay?.status().backends ?? [] } : {}) }]; }),
859
885
  ),
860
886
  ...(sidecar ? { switchyard: sidecar.status } : {}),
861
887
  ...(modelRelay ? { models: modelRelay.status() } : {}),
@@ -1297,7 +1323,7 @@ export async function startDaemon(opts: DaemonOptions) {
1297
1323
  const mode = args.mode ?? "headless";
1298
1324
  const backend = args.backend ?? config.pi.backend;
1299
1325
  if (!["headless", "tui"].includes(mode) || !["auto", "dgx", "mlx"].includes(backend)) return { ok: false, error: "invalid Pi mode/backend" };
1300
- modelRelay ??= await startModelRelay({ omni, dgxMaxInputTokens: currentRouting(opts.cwd, log).pi.dgx_max_context_tokens, allowedDGXmodels: { "dgx/coding": config.pi.dgx_coding, "dgx/fast": config.pi.dgx_fast }, mlx: config.mlx, mlxAlias: "mlx/fast", fallbackDGXAlias: "dgx/fast" });
1326
+ modelRelay ??= await startModelRelay({ omni, admitRequest: admitPiRequest, dgxMaxInputTokens: currentRouting(opts.cwd, log).pi.dgx_max_context_tokens, allowedDGXmodels: { "dgx/coding": config.pi.dgx_coding, "dgx/fast": config.pi.dgx_fast }, mlx: config.mlx, mlxAlias: "mlx/fast", fallbackDGXAlias: "dgx/fast" });
1301
1327
  piReceipts ??= new PiToolReceipts(join(opts.stateDir, "hub.db"));
1302
1328
  let piReply: Envelope | undefined;
1303
1329
  const ctx: ToolContext = {
@@ -1316,14 +1342,24 @@ export async function startDaemon(opts: DaemonOptions) {
1316
1342
  cwd: opts.cwd, stateDir: opts.stateDir, cmd: config.pi.cmd, mode, backend,
1317
1343
  model: args.model,
1318
1344
  sessionId: args.sessionId, sessionFile: args.sessionFile,
1345
+ admitBudget: async (envs, unit) => unit === "model_calls" ? [] : tasks.admitExecutionEnvelopes(envs, "pi", unit),
1319
1346
  relay: { url: modelRelay.url, token: modelRelay.token, models: modelRelay.models.map((id) => ({ id, contextWindow: id.startsWith("mlx/") ? Math.min(routing.pi.mlx_max_context_tokens, config.mlx.provider === "ollama" ? (config.mlx.contextWindow ?? 8192) : routing.pi.mlx_max_context_tokens) : routing.pi.dgx_max_context_tokens, maxTokens: id.startsWith("mlx/") ? (config.mlx.maxTokens ?? 2048) : 8192 })) },
1320
1347
  tools: [...TOOL_SCHEMAS.map((t) => t.function), ...TASK_TOOLS.map((t) => ({ name: t.name, description: t.description, parameters: t.inputSchema }))],
1321
- executeTool: async (name, raw, callId, sessionId) => {
1348
+ executeTool: async (name, raw, callId, sessionId, signal) => {
1349
+ if (signal?.aborted) return "error: turn cancelled before tool effects";
1322
1350
  if (stopping || (recoveryActive() && recoveryPhase !== "preparing")) return "error: recovery is holding tool effects";
1323
1351
  return piReceipts!.execute(sessionId ?? "", callId, name, raw, async () => {
1324
1352
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) return "error: invalid tool arguments";
1325
1353
  if (TASK_TOOLS.some((t) => t.name === name)) return taskOp("pi", name, raw as Record<string, unknown>, true);
1326
- return runTool(name, JSON.stringify(raw), ctx);
1354
+ return runTool(name, JSON.stringify(raw), { ...ctx, signal, permit: async title => {
1355
+ if (!signal) return ctx.permit(title);
1356
+ if (signal.aborted) return false;
1357
+ return new Promise<boolean>((resolve, reject) => {
1358
+ const finish = (allowed: boolean) => { signal.removeEventListener("abort", aborted); resolve(allowed && !signal.aborted); };
1359
+ const aborted = () => finish(false); signal.addEventListener("abort", aborted, { once: true });
1360
+ Promise.resolve(ctx.permit(title)).then(finish, error => { signal.removeEventListener("abort", aborted); reject(error); });
1361
+ });
1362
+ } });
1327
1363
  });
1328
1364
  },
1329
1365
  selectModel: async (envs) => {
@@ -1401,6 +1437,8 @@ export async function startDaemon(opts: DaemonOptions) {
1401
1437
  const local = new LocalPeer("local", {
1402
1438
  cwd: opts.cwd,
1403
1439
  omni,
1440
+ onUsage: record => event({ type: "usage", peer: "local", source: "omniroute", id: record.id, ...record.usage, requestedModel: record.requestedModel, ...(record.servedModel ? { servedModel: record.servedModel } : {}), ...(record.provider ? { provider: record.provider } : {}), measuredAt: record.at }),
1441
+ admitBudget: async (envs, unit) => tasks.admitExecutionEnvelopes(envs, "local", unit),
1404
1442
  ...(sidecar && route ? { sidecar, route } : {}),
1405
1443
  fixedModel: args.model ?? routing.local.fixed_model,
1406
1444
  tools: { deny: config.local.deny, bashNetwork: sandboxNetwork, readAllow: config.local.read_allow, permit },
@@ -1425,6 +1463,16 @@ export async function startDaemon(opts: DaemonOptions) {
1425
1463
  return { ok: false, error: `unknown peer "${peer}"` };
1426
1464
  }
1427
1465
 
1466
+ async function admitPiRequest(): Promise<{ allowed: boolean; reason?: string; remainingMs?: number }> {
1467
+ const current = bus.peers.get("pi");
1468
+ if (!(current instanceof PiPeer)) return { allowed: false, reason: "Pi owner is not active" };
1469
+ const decisions = tasks.admitExecutionEnvelopes(current.budgetEnvelopes, "pi", "model_calls");
1470
+ const denied = decisions.find(d => !d.allowed);
1471
+ const stop = denied ? current.recordBudgetStop(denied) : undefined;
1472
+ const deadline = decisions.filter(d => d.unit === "elapsed_ms" && d.remaining !== null).map(d => d.remaining!);
1473
+ return { allowed: !denied, ...(denied ? { reason: stop?.reason ?? `execution budget ${denied.reason}: ${denied.scope}` } : {}), ...(deadline.length ? { remainingMs: Math.min(...deadline) } : {}) };
1474
+ }
1475
+
1428
1476
  async function validateLocalChoice(route: string | undefined, fixedModel: string): Promise<void> {
1429
1477
  const routing = currentRouting(opts.cwd, log);
1430
1478
  if (route && !routing.routes[route]) throw new Error(`routing.toml has no route "${route}"`);
@@ -1729,10 +1777,30 @@ export async function startDaemon(opts: DaemonOptions) {
1729
1777
  }
1730
1778
  case "delivery_receipt": {
1731
1779
  const peer = c.peer ? bus.peers.get(c.peer) : undefined;
1732
- if (c.role !== "peer" || !(peer instanceof WsPeer) || !peer.owns(sock) || typeof msg.deliveryId !== "string" || !["accepted", "needs_review"].includes(msg.state)) return void reply({ ok: false, error: "invalid delivery receipt" });
1780
+ if (c.role !== "peer" || !(peer instanceof WsPeer) || typeof msg.deliveryId !== "string" || !peer.ownsDelivery(sock, msg.generation, msg.deliveryId) || !["accepted", "needs_review"].includes(msg.state)) return void reply({ ok: false, error: "invalid delivery receipt" });
1733
1781
  bus.deliveryReceipt(peer.id, { id: msg.deliveryId, state: msg.state, ...(msg.state === "needs_review" ? { reason: "Claude bridge could not confirm notification delivery" } : {}) });
1734
1782
  return void reply({ ok: true });
1735
1783
  }
1784
+ case "delivery_complete": {
1785
+ const peer = c.peer ? bus.peers.get(c.peer) : undefined;
1786
+ if (c.role !== "peer" || !(peer instanceof WsPeer) || typeof msg.deliveryId !== "string" || !peer.ownsDelivery(sock, msg.generation, msg.deliveryId)) return void reply({ ok: false, error: "delivery does not belong to this connection generation" });
1787
+ return void reply(bus.completeDelivery(peer.id, msg.deliveryId) ? { ok: true } : { ok: false, error: "delivery is not live and accepted; inspect ahub queue show before resolving" });
1788
+ }
1789
+ case "execution_budget": {
1790
+ if (c.role !== "console") return void reply({ ok: false, error: "execution budgets are console-only" });
1791
+ if (msg.op === "status") return void reply({ ok: true, budgets: tasks.executionBudgetStatus(msg.id) });
1792
+ if (recoveryActive() || stopping) return void reply({ ok: false, error: "recovery or shutdown is holding budget mutations" });
1793
+ try {
1794
+ if (msg.op === "configure") {
1795
+ const cfg = msg.config;
1796
+ if (!cfg || !Array.isArray(cfg.peers) || cfg.peers.some((p: unknown) => p !== "local" && p !== "pi")) throw new Error("strict execution budgets support local and Pi only; native Codex/Claude calls are not instrumented");
1797
+ if (cfg.kind === "task" && !board.get(cfg.taskId)) throw new Error("task not found");
1798
+ return void reply({ ok: true, budget: tasks.configureExecutionBudget(cfg) });
1799
+ }
1800
+ if (msg.op === "disable" && typeof msg.id === "string") return void reply({ ok: true, disabled: tasks.disableExecutionBudget(msg.id) });
1801
+ return void reply({ ok: false, error: "unknown execution budget operation" });
1802
+ } catch (error) { return void reply({ ok: false, error: (error as Error).message }); }
1803
+ }
1736
1804
  case "recovery":
1737
1805
  if (c.role !== "console") return void reply(recoveryError("recovery is a console command"));
1738
1806
  void recoveryOp(msg).then(reply, (error) => reply(recoveryError((error as Error)?.message ?? "recovery operation failed")));
@@ -1899,7 +1967,9 @@ export async function startDaemon(opts: DaemonOptions) {
1899
1967
  await modelRelay?.close();
1900
1968
  await egress?.close();
1901
1969
  await piReceipts?.close();
1970
+ collectClaudeUsage();
1902
1971
  budget.close();
1972
+ executionBudget.close();
1903
1973
  board.close();
1904
1974
  turnLog?.close();
1905
1975
  server.stop(true);
package/src/hub/events.ts CHANGED
@@ -14,6 +14,7 @@ export type HubEvent =
14
14
  | { type: "turn_start"; peer: string; turn: string }
15
15
  | { type: "turn_end"; peer: string; turn: string; ms: number; tokens?: number; files?: number; snapshotMs?: number }
16
16
  | { type: "tokens"; peer: string; n: number }
17
+ | { type: "usage"; peer: string; source: "omniroute" | "claude_transcript"; id: string; measuredAt?: string; requestedModel?: string; servedModel?: string; provider?: string; inputTokens?: number; outputTokens?: number; cacheReadTokens?: number; cacheWriteTokens?: number; totalTokens?: number }
17
18
  | { type: "task"; id: number; event: string; by: string; state: string; owner: string | null; reviewer: string | null; class: string; pii: boolean }
18
19
  | { type: "overlap"; task: number; owner: string; others: { task: number; owner: string; paths: string[]; symbols?: string[] }[] }
19
20
  | { type: "conflict"; peer: string; task?: number; other: number; owner: string; paths: string[]; concurrent: boolean; turns?: [string, string] }
@@ -75,7 +76,8 @@ export function readEvents(file: string, since = 0): StampedEvent[] {
75
76
  if (!line.trim()) return [];
76
77
  try {
77
78
  const e = JSON.parse(line) as StampedEvent;
78
- return Date.parse(e.at) >= since ? [e] : [];
79
+ const measuredAt = e.type === "usage" && typeof e.measuredAt === "string" ? e.measuredAt : e.at;
80
+ return Date.parse(measuredAt) >= since ? [e] : [];
79
81
  } catch {
80
82
  return [];
81
83
  }
@@ -0,0 +1,138 @@
1
+ import { Database } from "bun:sqlite";
2
+
3
+ export type ExecutionUnit = "model_calls" | "tool_calls" | "elapsed_ms" | "tokens";
4
+ export type ExecutionBudgetLimits = Partial<Record<ExecutionUnit, number>>;
5
+ export interface ExecutionBudgetConfig {
6
+ id: string;
7
+ kind: "task" | "run";
8
+ taskId?: number;
9
+ peers: string[];
10
+ limits: ExecutionBudgetLimits;
11
+ }
12
+ export interface ExecutionBudgetStatus extends ExecutionBudgetConfig {
13
+ used: Partial<Record<ExecutionUnit, number>>;
14
+ units: Partial<Record<ExecutionUnit, { used: number; limit: number; remaining: number; reason?: "exhausted" | "unknown_usage" }>>;
15
+ createdAt: number;
16
+ updatedAt: number;
17
+ }
18
+ export interface ExecutionBudgetDecision {
19
+ allowed: boolean;
20
+ scope: string;
21
+ unit: ExecutionUnit;
22
+ used: number;
23
+ limit: number | null;
24
+ remaining: number | null;
25
+ reason?: "exhausted" | "unknown_usage" | "not_eligible";
26
+ }
27
+
28
+ const UNITS = new Set<ExecutionUnit>(["model_calls", "tool_calls", "elapsed_ms", "tokens"]);
29
+ const validLimits = (raw: ExecutionBudgetLimits): ExecutionBudgetLimits => {
30
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("execution budget limits must be an object");
31
+ const out: ExecutionBudgetLimits = {};
32
+ for (const [key, value] of Object.entries(raw)) {
33
+ if (!UNITS.has(key as ExecutionUnit)) throw new Error(`unsupported execution budget unit: ${key}`);
34
+ if (!Number.isSafeInteger(value) || value < 0) throw new Error(`execution budget ${key} limit must be a nonnegative integer`);
35
+ out[key as ExecutionUnit] = value;
36
+ }
37
+ return out;
38
+ };
39
+
40
+ /** Persistent, cross-turn execution meter. Each admission and increment is one SQLite transaction. */
41
+ export class ExecutionBudget {
42
+ private readonly db: Database;
43
+ constructor(path: string) {
44
+ this.db = new Database(path, { create: true });
45
+ this.db.run("PRAGMA journal_mode = WAL");
46
+ this.db.run(`CREATE TABLE IF NOT EXISTS execution_budgets (
47
+ id TEXT PRIMARY KEY, kind TEXT NOT NULL, task_id INTEGER, peers TEXT NOT NULL,
48
+ limits TEXT NOT NULL, used TEXT NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL)`);
49
+ }
50
+
51
+ configure(input: ExecutionBudgetConfig): ExecutionBudgetStatus {
52
+ if (!input.id || input.id.length > 200) throw new Error("execution budget id is invalid");
53
+ if (input.kind !== "task" && input.kind !== "run") throw new Error("execution budget kind must be task or run");
54
+ if (input.kind === "task" && (!Number.isSafeInteger(input.taskId) || input.taskId! < 1)) throw new Error("task budget requires a positive task id");
55
+ const peers = [...new Set(input.peers.filter((p) => typeof p === "string" && p.length > 0))].sort();
56
+ if (!peers.length) throw new Error("execution budget requires at least one peer");
57
+ const unsupported = peers.filter((peer) => peer !== "pi" && peer !== "local");
58
+ if (unsupported.length) throw new Error(`execution budgets require instrumented peers (pi, local); unsupported: ${unsupported.join(", ")}`);
59
+ const limits = validLimits(input.limits ?? {});
60
+ if (!Object.keys(limits).length) throw new Error("execution budget requires at least one supported limit");
61
+ const now = Date.now();
62
+ const existing = this.db.query("SELECT kind,task_id FROM execution_budgets WHERE id=?").get(input.id) as { kind: string; task_id: number | null } | undefined;
63
+ if (existing && (existing.kind !== input.kind || existing.task_id !== (input.taskId ?? null))) throw new Error("execution budget id is already bound to another scope");
64
+ this.db.query(`INSERT INTO execution_budgets (id,kind,task_id,peers,limits,used,created_at,updated_at)
65
+ VALUES (?,?,?,?,?,'{}',?,?) ON CONFLICT(id) DO UPDATE SET kind=excluded.kind,task_id=excluded.task_id,
66
+ peers=excluded.peers,limits=excluded.limits,updated_at=excluded.updated_at`).run(
67
+ input.id, input.kind, input.taskId ?? null, JSON.stringify(peers), JSON.stringify(limits), now, now,
68
+ );
69
+ return this.status(input.id) as ExecutionBudgetStatus;
70
+ }
71
+
72
+ disable(id: string): boolean { return this.db.query("DELETE FROM execution_budgets WHERE id=?").run(id).changes > 0; }
73
+
74
+ status(id?: string): ExecutionBudgetStatus | ExecutionBudgetStatus[] | undefined {
75
+ const rows = (id
76
+ ? this.db.query("SELECT * FROM execution_budgets WHERE id=?").all(id)
77
+ : this.db.query("SELECT * FROM execution_budgets ORDER BY created_at,id").all()) as BudgetRow[];
78
+ const values = rows.map(fromRow);
79
+ return id ? values[0] : values;
80
+ }
81
+
82
+ admitTask(taskId: number | undefined, peer: string, unit: ExecutionUnit, amount = 1): ExecutionBudgetDecision[] {
83
+ return this.admitTasks(taskId === undefined ? [] : [taskId], peer, unit, amount);
84
+ }
85
+
86
+ admitTasks(taskIds: number[], peer: string, unit: ExecutionUnit, amount = 1): ExecutionBudgetDecision[] {
87
+ if (!UNITS.has(unit) || !Number.isSafeInteger(amount) || amount <= 0) throw new Error("execution budget admission is invalid");
88
+ const transaction = this.db.transaction(() => {
89
+ // Read scopes only after BEGIN IMMEDIATE so a concurrent configure/admission cannot evade a scope or overdraw it.
90
+ const ids = [...new Set(taskIds.filter((id) => Number.isSafeInteger(id) && id > 0))];
91
+ const taskRows = ids.length
92
+ ? this.db.query(`SELECT * FROM execution_budgets WHERE kind='task' AND task_id IN (${ids.map(() => "?").join(",")}) AND EXISTS (SELECT 1 FROM json_each(peers) WHERE value=?)`).all(...ids, peer) as BudgetRow[]
93
+ : [];
94
+ const runRows = this.db.query("SELECT * FROM execution_budgets WHERE kind='run' AND EXISTS (SELECT 1 FROM json_each(peers) WHERE value=?)").all(peer) as BudgetRow[];
95
+ const rows = [...taskRows, ...runRows].sort((a, b) => a.created_at - b.created_at || a.id.localeCompare(b.id));
96
+ const pending: { cfg: ExecutionBudgetStatus; updates: Partial<Record<ExecutionUnit, number>>; decisions: ExecutionBudgetDecision[] }[] = rows.map((row) => {
97
+ const cfg = fromRow(row), updates: Partial<Record<ExecutionUnit, number>> = {}, decisions: ExecutionBudgetDecision[] = [];
98
+ const now = Date.now(), elapsedLimit = cfg.limits.elapsed_ms;
99
+ if (elapsedLimit !== undefined) {
100
+ const elapsed = Math.max(cfg.used.elapsed_ms ?? 0, now - cfg.createdAt);
101
+ const decision: ExecutionBudgetDecision = { allowed: elapsed < elapsedLimit, scope: cfg.id, unit: "elapsed_ms", used: elapsed, limit: elapsedLimit, remaining: Math.max(0, elapsedLimit - elapsed), ...(elapsed >= elapsedLimit ? { reason: "exhausted" } : {}) };
102
+ decisions.push(decision); updates.elapsed_ms = elapsed;
103
+ }
104
+ const limit = cfg.limits[unit], current = cfg.used[unit] ?? 0;
105
+ if (unit === "tokens" || (unit === "model_calls" && cfg.limits.tokens !== undefined)) {
106
+ decisions.push({ allowed: false, scope: cfg.id, unit: "tokens", used: cfg.used.tokens ?? 0, limit: cfg.limits.tokens ?? null, remaining: cfg.limits.tokens === undefined ? null : Math.max(0, cfg.limits.tokens - (cfg.used.tokens ?? 0)), reason: "unknown_usage" });
107
+ } else if (limit === undefined) decisions.push({ allowed: true, scope: cfg.id, unit, used: current, limit: null, remaining: null });
108
+ else if (current + amount > limit) decisions.push({ allowed: false, scope: cfg.id, unit, used: current, limit, remaining: Math.max(0, limit - current), reason: "exhausted" });
109
+ else {
110
+ updates[unit] = current + amount;
111
+ decisions.push({ allowed: true, scope: cfg.id, unit, used: current + amount, limit, remaining: Math.max(0, limit - current - amount) });
112
+ }
113
+ return { cfg, updates, decisions };
114
+ });
115
+ if (pending.some((item) => item.decisions.some((decision) => !decision.allowed))) return pending.flatMap((item) => item.decisions);
116
+ for (const item of pending) if (Object.keys(item.updates).length) {
117
+ item.cfg.used = { ...item.cfg.used, ...item.updates };
118
+ this.db.query("UPDATE execution_budgets SET used=?,updated_at=? WHERE id=?").run(JSON.stringify(item.cfg.used), Date.now(), item.cfg.id);
119
+ }
120
+ return pending.flatMap((item) => item.decisions);
121
+ });
122
+ return transaction.immediate();
123
+ }
124
+ close(): void { this.db.close(); }
125
+ }
126
+
127
+ interface BudgetRow { id: string; kind: "task"|"run"; task_id: number | null; peers: string; limits: string; used: string; created_at: number; updated_at: number }
128
+ function fromRow(row: BudgetRow): ExecutionBudgetStatus {
129
+ const limits = JSON.parse(row.limits) as ExecutionBudgetLimits;
130
+ const used = JSON.parse(row.used) as Partial<Record<ExecutionUnit, number>>;
131
+ if (limits.elapsed_ms !== undefined) used.elapsed_ms = Math.max(used.elapsed_ms ?? 0, Date.now() - row.created_at);
132
+ const units: ExecutionBudgetStatus["units"] = {};
133
+ for (const [unit, limit] of Object.entries(limits) as [ExecutionUnit, number][]) {
134
+ const current = used[unit] ?? 0, remaining = Math.max(0, limit - current);
135
+ units[unit] = { used: current, limit, remaining, ...((unit === "tokens" && remaining > 0) ? { reason: "unknown_usage" as const } : (remaining === 0 ? { reason: "exhausted" as const } : {})) };
136
+ }
137
+ return { id: row.id, kind: row.kind, ...(row.task_id === null ? {} : { taskId: row.task_id }), peers: JSON.parse(row.peers), limits, used, units, createdAt: row.created_at, updatedAt: row.updated_at };
138
+ }
package/src/hub/report.ts CHANGED
@@ -4,6 +4,7 @@ export interface Report {
4
4
  from?: string;
5
5
  to?: string;
6
6
  peers: Record<string, { turns: number; busyMinutes: number; tokens: number }>;
7
+ usage: { peers: Record<string, UsageCoverage>; totals: UsageTotals; unknownPeers: string[]; completeCoverage: boolean; estimatedUsd?: number; measuredSpendUsd?: number };
7
8
  messages: { total: number; dropped: Record<string, number>; overflow: number; undeliverable: number; perTask: number };
8
9
  overlaps: { warnings: number; pairs: number };
9
10
  /** Files one agent changed after another owner's open task had changed them (issue #32). */
@@ -12,12 +13,82 @@ export interface Report {
12
13
  quota: { readings: number; hard: number };
13
14
  }
14
15
 
16
+ export interface UsageCoverage {
17
+ records: number;
18
+ withUsage: number;
19
+ withoutUsage: number;
20
+ inputTokens: number;
21
+ inputRecords: number;
22
+ outputTokens: number;
23
+ outputRecords: number;
24
+ cacheReadTokens: number;
25
+ cacheReadRecords: number;
26
+ cacheWriteTokens: number;
27
+ cacheWriteRecords: number;
28
+ totalTokens: number;
29
+ totalRecords: number;
30
+ }
31
+
32
+ export type UsageTotals = Omit<UsageCoverage, "records" | "withUsage" | "withoutUsage">;
33
+
34
+ const emptyUsage = (): UsageCoverage => ({ records: 0, withUsage: 0, withoutUsage: 0, inputTokens: 0, inputRecords: 0, outputTokens: 0, outputRecords: 0, cacheReadTokens: 0, cacheReadRecords: 0, cacheWriteTokens: 0, cacheWriteRecords: 0, totalTokens: 0, totalRecords: 0 });
35
+
36
+ function recordUsage(coverage: UsageCoverage, e: Extract<StampedEvent, { type: "usage" }>): void {
37
+ coverage.records++;
38
+ const fields = [e.inputTokens, e.outputTokens, e.cacheReadTokens, e.cacheWriteTokens, e.totalTokens];
39
+ if (fields.some((n) => typeof n === "number" && Number.isSafeInteger(n) && n >= 0)) coverage.withUsage++;
40
+ else coverage.withoutUsage++;
41
+ const add = (field: keyof UsageTotals, n: number | undefined, countField: keyof UsageTotals) => {
42
+ if (typeof n === "number" && Number.isSafeInteger(n) && n >= 0) {
43
+ coverage[field] += n;
44
+ coverage[countField]++;
45
+ }
46
+ };
47
+ add("inputTokens", e.inputTokens, "inputRecords");
48
+ add("outputTokens", e.outputTokens, "outputRecords");
49
+ add("cacheReadTokens", e.cacheReadTokens, "cacheReadRecords");
50
+ add("cacheWriteTokens", e.cacheWriteTokens, "cacheWriteRecords");
51
+ add("totalTokens", e.totalTokens, "totalRecords");
52
+ }
53
+
54
+ function finalizeUsage(report: Report): void {
55
+ for (const id of Object.keys(report.peers)) {
56
+ if (!report.usage.peers[id]) {
57
+ report.usage.peers[id] = emptyUsage();
58
+ report.usage.unknownPeers.push(id);
59
+ }
60
+ }
61
+ for (const p of Object.values(report.usage.peers)) {
62
+ const out = report.usage.totals;
63
+ for (const field of ["inputTokens", "inputRecords", "outputTokens", "outputRecords", "cacheReadTokens", "cacheReadRecords", "cacheWriteTokens", "cacheWriteRecords", "totalTokens", "totalRecords"] as const) out[field] += p[field];
64
+ if (p.withoutUsage || p.inputRecords < p.withUsage || p.outputRecords < p.withUsage || p.cacheReadRecords < p.withUsage || p.cacheWriteRecords < p.withUsage || p.totalRecords < p.withUsage) report.usage.completeCoverage = false;
65
+ }
66
+ if (report.usage.unknownPeers.length) report.usage.completeCoverage = false;
67
+ }
68
+
69
+ function formatUsage(usage: Report["usage"]): string[] {
70
+ const lines: string[] = [];
71
+ for (const [id, u] of Object.entries(usage.peers).sort(([a], [b]) => a.localeCompare(b))) {
72
+ const coverage = `${u.withUsage}/${u.records} records with usage`;
73
+ const value = (name: string, count: number, records: number) => records ? `${name} ${count} (${records} known)` : `${name} unknown (0 known)`;
74
+ const known = [value("input", u.inputTokens, u.inputRecords), value("output", u.outputTokens, u.outputRecords), value("cache read", u.cacheReadTokens, u.cacheReadRecords), value("cache write", u.cacheWriteTokens, u.cacheWriteRecords), value("reported total", u.totalTokens, u.totalRecords)].join(", ");
75
+ const incomplete = u.withoutUsage || u.inputRecords < u.withUsage || u.outputRecords < u.withUsage || u.cacheReadRecords < u.withUsage || u.cacheWriteRecords < u.withUsage || u.totalRecords < u.withUsage;
76
+ lines.push(`usage ${id}: ${u.records ? coverage : "no usage records"}; ${known}; coverage ${incomplete ? "incomplete" : u.records ? "complete for recorded calls" : "unknown"}`);
77
+ }
78
+ const total = usage.totals.totalRecords ? `${usage.totals.totalTokens} reported tokens (${usage.totals.totalRecords} known records)` : "reported token total unknown (0 known records)";
79
+ const coverage = usage.completeCoverage ? "recorded-call coverage complete" : `incomplete or unknown peer coverage${usage.unknownPeers.length ? ` (${usage.unknownPeers.join(", ")})` : ""}`;
80
+ lines.push(`usage team totals: ${total}; ${coverage}; estimated price unknown; measured spend unknown`);
81
+ return lines;
82
+ }
83
+
15
84
  /** The numbers `ahub report` prints, from `events.jsonl` alone (issue #40). */
16
85
  export function summarize(events: StampedEvent[]): Report {
17
- const r: Report = { peers: {}, messages: { total: 0, dropped: {}, overflow: 0, undeliverable: 0, perTask: 0 }, overlaps: { warnings: 0, pairs: 0 }, conflicts: 0, tasks: {}, quota: { readings: 0, hard: 0 } };
86
+ const r: Report = { peers: {}, usage: { peers: {}, totals: { inputTokens: 0, inputRecords: 0, outputTokens: 0, outputRecords: 0, cacheReadTokens: 0, cacheReadRecords: 0, cacheWriteTokens: 0, cacheWriteRecords: 0, totalTokens: 0, totalRecords: 0 }, unknownPeers: [], completeCoverage: true }, messages: { total: 0, dropped: {}, overflow: 0, undeliverable: 0, perTask: 0 }, overlaps: { warnings: 0, pairs: 0 }, conflicts: 0, tasks: {}, quota: { readings: 0, hard: 0 } };
18
87
  const peer = (id: string) => (r.peers[id] ??= { turns: 0, busyMinutes: 0, tokens: 0 });
88
+ const usagePeer = (id: string) => (r.usage.peers[id] ??= emptyUsage());
19
89
  const pairs = new Set<string>();
20
90
  const taskMessages = new Map<string, number>();
91
+ const seenUsage = new Set<string>();
21
92
  for (const e of events) {
22
93
  r.from ??= e.at;
23
94
  r.to = e.at;
@@ -35,9 +106,19 @@ export function summarize(events: StampedEvent[]): Report {
35
106
  peer(e.peer).turns++;
36
107
  peer(e.peer).busyMinutes += e.ms / 60_000;
37
108
  break;
109
+ case "state":
110
+ peer(e.peer);
111
+ break;
38
112
  case "tokens":
39
113
  peer(e.peer).tokens += e.n;
40
114
  break;
115
+ case "usage": {
116
+ const key = `${e.peer}\0${e.source}\0${e.id}`;
117
+ if (seenUsage.has(key)) break;
118
+ seenUsage.add(key);
119
+ recordUsage(usagePeer(e.peer), e);
120
+ break;
121
+ }
41
122
  case "task":
42
123
  r.tasks[e.event] = (r.tasks[e.event] ?? 0) + 1;
43
124
  break;
@@ -55,6 +136,8 @@ export function summarize(events: StampedEvent[]): Report {
55
136
  }
56
137
  }
57
138
  r.overlaps.pairs = pairs.size;
139
+ finalizeUsage(r);
140
+ // Pricing is intentionally absent: a token count is neither an estimated price nor provider-reported spend.
58
141
  r.messages.perTask = taskMessages.size ? Number(([...taskMessages.values()].reduce((a, b) => a + b, 0) / taskMessages.size).toFixed(1)) : 0;
59
142
  for (const p of Object.values(r.peers)) p.busyMinutes = Number(p.busyMinutes.toFixed(1));
60
143
  return r;
@@ -63,6 +146,7 @@ export function summarize(events: StampedEvent[]): Report {
63
146
  export function formatReport(r: Report): string[] {
64
147
  const lines = [`period: ${r.from ?? "-"} .. ${r.to ?? "-"}`];
65
148
  for (const [id, p] of Object.entries(r.peers).sort(([a], [b]) => a.localeCompare(b))) lines.push(`peer ${id}: ${p.turns} turn${p.turns === 1 ? "" : "s"}, ${p.busyMinutes} busy minutes, ${p.tokens ? `${p.tokens} tokens` : id === "pi" ? "tokens not reported" : "- tokens"}`);
149
+ if (Object.keys(r.usage.peers).length) lines.push(...formatUsage(r.usage));
66
150
  const dropped = Object.entries(r.messages.dropped).map(([k, n]) => `${n} ${k}`).join(", ");
67
151
  lines.push(`messages: ${r.messages.total} (dropped: ${dropped || "none"}; overflow ${r.messages.overflow}; undeliverable ${r.messages.undeliverable}); ${r.messages.perTask} per task that had any`);
68
152
  lines.push(`overlap warnings: ${r.overlaps.warnings}, task pairs: ${r.overlaps.pairs}; edit conflicts: ${r.conflicts}`);
package/src/hub/tasks.ts CHANGED
@@ -4,6 +4,7 @@ import { CLASSES, OUTCOMES_KEPT_MS, PLAN_KEYS, type Board, type Task, type TaskC
4
4
  import type { Bus } from "./bus.ts";
5
5
  import { HUB, newEnvelope, NOTE_KINDS, noteLine, USER, type Envelope, type PeerId, type PeerState } from "./envelope.ts";
6
6
  import { assign, detectSignals, LOCAL, PI, type Assignment, type Routing } from "./routing.ts";
7
+ import { ExecutionBudget, type ExecutionBudgetConfig, type ExecutionBudgetDecision, type ExecutionBudgetStatus, type ExecutionUnit } from "./execution-budget.ts";
7
8
 
8
9
  export interface TasksDeps {
9
10
  board: Board;
@@ -27,6 +28,8 @@ export interface TasksDeps {
27
28
  recordOverlap?: (task: number, owner: PeerId, others: { task: number; owner: PeerId; paths: string[]; symbols?: string[] }[]) => void;
28
29
  /** Quota per peer from fresh readings (issue #36): routing drains the windows that reset soonest first. */
29
30
  quota?: () => Record<PeerId, { headroom: number; resetsAt?: number }>;
31
+ /** Optional persisted task/run execution limits (issue #102). */
32
+ executionBudget?: ExecutionBudget;
30
33
  /** Reviewer choice from recorded review outcomes (issue #35); off unless the project config turns it on. */
31
34
  review?: { adaptive: boolean; min_reviews: number };
32
35
  /** Peers whose recent deliveries all failed, with the reason (issue #89); routing skips them. */
@@ -108,6 +111,25 @@ export class Tasks {
108
111
 
109
112
  isPii = (task: Pick<Task, "signals">) => task.signals.includes("pii") && this.d.routing().constraints.pii === "local_only";
110
113
 
114
+ configureExecutionBudget = (config: ExecutionBudgetConfig): ExecutionBudgetStatus | undefined => {
115
+ if (!this.d.executionBudget) return undefined;
116
+ if (config.kind === "task" && (!config.taskId || !this.d.board.get(config.taskId))) throw new Error(`task #${config.taskId ?? "?"} does not exist`);
117
+ return this.d.executionBudget.configure(config);
118
+ };
119
+ disableExecutionBudget = (id: string): boolean => this.d.executionBudget?.disable(id) ?? false;
120
+ executionBudgetStatus = (id?: string): ExecutionBudgetStatus | ExecutionBudgetStatus[] | undefined => this.d.executionBudget?.status(id);
121
+ /** Charge only budgets matching the task actually delivered to this peer, plus active run budgets. */
122
+ admitExecution = (taskId: number | undefined, peer: PeerId, unit: ExecutionUnit, amount = 1): ExecutionBudgetDecision[] => this.d.executionBudget?.admitTask(taskId, peer, unit, amount) ?? [];
123
+ /** A digest may include several tasks. Charge each matching task budget and each run budget once, atomically. */
124
+ admitExecutionEnvelopes = (envs: Envelope[], peer: PeerId, unit: ExecutionUnit, amount = 1): ExecutionBudgetDecision[] => {
125
+ const ids = [...new Set(envs.flatMap((e) => {
126
+ const value = e.refs?.task;
127
+ const id = typeof value === "string" ? Number(value) : NaN;
128
+ return Number.isSafeInteger(id) && id > 0 ? [id] : [];
129
+ }))];
130
+ return this.d.executionBudget?.admitTasks(ids, peer, unit, amount) ?? [];
131
+ };
132
+
111
133
  /** What peers other than the owner, the console stream and the log may see of a task. */
112
134
  publicTitle = (task: Task) => (this.isPii(task) ? `#${task.id} [pii]` : `#${task.id} ${task.title}`);
113
135
 
@@ -0,0 +1,55 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFileSync, statSync } from "node:fs";
3
+ import { normalizeUsage, safeModelLabel, type NormalizedUsage } from "../omniroute/usage.ts";
4
+
5
+ export interface UsageRecord {
6
+ id: string;
7
+ at?: string;
8
+ usage?: NormalizedUsage;
9
+ requestedModel?: string;
10
+ servedModel?: string;
11
+ provider?: string;
12
+ }
13
+
14
+ function opaqueId(sessionId: string, messageId: string): string {
15
+ return createHash("sha256").update(sessionId).update("\0").update(messageId).digest("hex");
16
+ }
17
+
18
+ // Documented successful Messages API values, including beta compaction, plus stop_sequence in native transcripts.
19
+ const COMPLETED_STOP_REASONS = new Set(["end_turn", "tool_use", "max_tokens", "stop_sequence", "pause_turn", "refusal", "model_context_window_exceeded", "compaction"]);
20
+
21
+ /**
22
+ * Read Claude's native JSONL usage without returning transcript text, paths or session identity.
23
+ * Repeated streaming records are collapsed by message.id, using only a completed assistant message.
24
+ * Malformed lines and unavailable files are ignored so telemetry cannot interrupt a Claude turn.
25
+ */
26
+ export function readClaudeTranscriptUsage(sessionId: string, transcriptPath: string): UsageRecord[] {
27
+ try {
28
+ // Avoid repeatedly parsing an unbounded transcript on the daemon's polling path.
29
+ if (statSync(transcriptPath).size > 32 * 1024 * 1024) {
30
+ // Signal unavailable coverage with a stable opaque id; a repeated poll dedupes in the report.
31
+ return [{ id: opaqueId(sessionId, "transcript-too-large") }];
32
+ }
33
+ const latest = new Map<string, { usage?: NormalizedUsage; at?: string; servedModel?: string }>();
34
+ for (const line of readFileSync(transcriptPath, "utf8").split("\n")) {
35
+ if (!line.trim()) continue;
36
+ try {
37
+ const row = JSON.parse(line) as { type?: unknown; timestamp?: unknown; sessionId?: unknown; message?: unknown };
38
+ if (typeof row.sessionId === "string" && row.sessionId !== sessionId) continue;
39
+ if (row.type !== "assistant" || !row.message || typeof row.message !== "object") continue;
40
+ const message = row.message as { id?: unknown; stop_reason?: unknown; usage?: unknown; model?: unknown };
41
+ if (typeof message.id !== "string" || !message.id || typeof message.stop_reason !== "string" || !message.stop_reason) continue;
42
+ const supportedStopReason = COMPLETED_STOP_REASONS.has(message.stop_reason);
43
+ const usage = supportedStopReason ? normalizeUsage(message.usage) : undefined;
44
+ const at = typeof row.timestamp === "string" && Number.isFinite(Date.parse(row.timestamp)) ? new Date(row.timestamp).toISOString() : undefined;
45
+ const servedModel = safeModelLabel(message.model);
46
+ latest.set(message.id, { ...(usage ? { usage } : {}), ...(at ? { at } : {}), ...(servedModel ? { servedModel } : {}) });
47
+ } catch {
48
+ // A streaming or crash-truncated line is not a record.
49
+ }
50
+ }
51
+ return [...latest].map(([messageId, value]) => ({ id: opaqueId(sessionId, messageId), ...value }));
52
+ } catch {
53
+ return [];
54
+ }
55
+ }
@@ -118,8 +118,9 @@ export interface ExecResult {
118
118
  }
119
119
 
120
120
  /** Run argv under seatbelt with a scrubbed environment. Never runs unsandboxed: no sandbox, no exec. */
121
- export function sandboxedExec(argv: string[], opts: { cwd: string; profile: string; timeoutMs?: number; env?: Record<string, string> }): Promise<ExecResult> {
121
+ export function sandboxedExec(argv: string[], opts: { cwd: string; profile: string; timeoutMs?: number; env?: Record<string, string>; signal?: AbortSignal }): Promise<ExecResult> {
122
122
  if (!sandboxAvailable()) return Promise.resolve({ code: null, output: "error: command execution needs macOS sandbox-exec and is disabled on this host" });
123
+ if (opts.signal?.aborted) return Promise.resolve({ code: null, output: "error: command cancelled before execution" });
123
124
  return new Promise((resolve) => {
124
125
  // A temp dir of its own (#63): the user's is shared with every other process, and what a command reads can reach
125
126
  // an answer shown to cloud peers. Allowed after the profile's denies, and gone when the command ends.
@@ -141,6 +142,8 @@ export function sandboxedExec(argv: string[], opts: { cwd: string; profile: stri
141
142
  child.kill("SIGKILL");
142
143
  }
143
144
  };
145
+ const abort = () => killGroup();
146
+ opts.signal?.addEventListener("abort", abort, { once: true });
144
147
  // Best effort, never in the way of the result: a command can mark its files immutable (`chflags uchg`), and then
145
148
  // the plain remove throws. What still fails is left to the OS temp cleanup.
146
149
  const dropOwn = () => {
@@ -154,11 +157,12 @@ export function sandboxedExec(argv: string[], opts: { cwd: string; profile: stri
154
157
  if (done) return;
155
158
  done = true;
156
159
  clearTimeout(timer);
160
+ opts.signal?.removeEventListener("abort", abort);
157
161
  dropOwn();
158
162
  const clipped = output.length >= OUTPUT_CAP ? `${output.slice(0, OUTPUT_CAP)}\n(output truncated)` : output;
159
163
  resolve({ code, output: signal ? `${clipped}\n(killed: ${signal}, timeout?)` : clipped });
160
164
  };
161
- child.on("error", (e) => (done || ((done = true), dropOwn(), resolve({ code: null, output: `error: ${e.message}` }))));
165
+ child.on("error", (e) => (done || ((done = true), clearTimeout(timer), opts.signal?.removeEventListener("abort", abort), dropOwn(), resolve({ code: null, output: `error: ${e.message}` }))));
162
166
  child.on("close", finish);
163
167
  // `close` waits for every holder of the pipes; after `exit` give stragglers a moment, then stop waiting and reap them.
164
168
  child.on("exit", (code, signal) => setTimeout(() => (killGroup(), finish(code, signal)), 500).unref());