@staix/agent-hub 0.11.0 → 0.12.1

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/tasks.ts CHANGED
@@ -29,6 +29,12 @@ export interface TasksDeps {
29
29
  quota?: () => Record<PeerId, { headroom: number; resetsAt?: number }>;
30
30
  /** Reviewer choice from recorded review outcomes (issue #35); off unless the project config turns it on. */
31
31
  review?: { adaptive: boolean; min_reviews: number };
32
+ /** Peers whose recent deliveries all failed, with the reason (issue #89); routing skips them. */
33
+ failing?: () => Record<PeerId, string>;
34
+ /** Peers whose queue a needs_review delivery holds, with the hold detail (issue #90); routing explains the hold, never rejects. */
35
+ held?: () => Record<PeerId, string>;
36
+ /** Roles from `.agenthub/config.json` (issue #92): peers with the reviewer role join the reviewer candidates. */
37
+ roles?: Record<string, string[]>;
32
38
  /** Optional: name a class for a task proposed without one. `onCampus` says whether the model call stays on campus. */
33
39
  triage?: { classify: (title: string, detail: string) => Promise<TaskClass | undefined>; onCampus: () => Promise<boolean> };
34
40
  }
@@ -111,6 +117,17 @@ export class Tasks {
111
117
  return this.isPii(task) ? { ...rest, refs: {}, plan: {}, title: "[pii]", detail: "[pii]" } : { ...rest, title, detail, history: history.slice(-5) };
112
118
  }
113
119
 
120
+ /** Console results expose direct approval and delivery holds, not just the chosen peer ids. */
121
+ resultLine(task: Task): string {
122
+ const base = `task #${task.id}: ${task.state}, owner ${task.owner ?? "none"}, reviewer ${task.reviewer ?? "none"}`;
123
+ const noReview = task.class !== "review" && !task.reviewer && OPEN.includes(task.state) && !this.waitsFor(task).length
124
+ ? this.noReviewer(assign(task, this.states(), this.d.routing(), { candidates: task.owner ? [task.owner] : [], notReviewer: task.owner ?? undefined, ...this.weights(task.class) }))
125
+ : undefined;
126
+ const held = task.owner ? this.d.held?.()[task.owner] : undefined;
127
+ const holdText = held ? `${task.owner}'s queue is held (${held}); it receives the task once the hold is resolved` : undefined;
128
+ return [base, noReview, holdText].filter(Boolean).join("; ");
129
+ }
130
+
114
131
  private states(): Record<PeerId, PeerState> {
115
132
  return Object.fromEntries([...this.d.bus.peers.keys()].map((id) => [id, this.d.bus.stateOf(id)]));
116
133
  }
@@ -164,11 +181,18 @@ export class Tasks {
164
181
  return new Set(task.history.slice(since + 1).filter((h) => h.event === "changes_requested").map((h) => h.by));
165
182
  }
166
183
 
184
+ /** Peer health and roles the daemon feeds routing (issues #89, #90, #92): failing peers are skipped, held queues explained. */
185
+ private health() {
186
+ const failing = this.d.failing?.();
187
+ const held = this.d.held?.();
188
+ return { ...(failing ? { failing } : {}), ...(held ? { held } : {}), ...(this.d.roles ? { roles: this.d.roles } : {}) };
189
+ }
190
+
167
191
  /** What assignment weighs besides states and policy: quota, demotion and the review record. */
168
192
  private weights(cls: TaskClass) {
169
193
  const now = Date.now();
170
194
  const quota = this.d.quota?.();
171
- return { now, demoted: this.demoted(cls, now), reviews: this.reviewRecord(cls), ...(quota ? { quota } : {}), ...(this.d.review?.adaptive ? { adaptive: { min: this.d.review.min_reviews } } : {}) };
195
+ return { ...this.health(), now, demoted: this.demoted(cls, now), reviews: this.reviewRecord(cls), ...(quota ? { quota } : {}), ...(this.d.review?.adaptive ? { adaptive: { min: this.d.review.min_reviews } } : {}) };
172
196
  }
173
197
 
174
198
  /**
@@ -334,7 +358,7 @@ export class Tasks {
334
358
  // Awaits run between tasks (briefs, memory): re-read, and stop for anything that changed meanwhile.
335
359
  const task = this.d.board.get(id);
336
360
  if (!task || task.owner !== peer || !OPEN.includes(task.state) || this.d.bus.stateOf(peer) !== "offline") continue;
337
- if (!assign(task, this.states(), this.d.routing(), { exclude: [...this.declined(task), peer] }).owner) continue;
361
+ if (!assign(task, this.states(), this.d.routing(), { exclude: [...this.declined(task), peer], ...this.health() }).owner) continue;
338
362
  try {
339
363
  const back = task.state === "in_progress" ? this.d.board.update(task.id, HUB, "released", { state: "proposed" }, why) : task;
340
364
  const next = await this.assignOwner(back, HUB, { exclude: [peer], event: "reassigned", note: why });
@@ -365,6 +389,21 @@ export class Tasks {
365
389
 
366
390
  private declined = (task: Task) => task.history.filter((h) => h.event === "declined").map((h) => h.by);
367
391
 
392
+ /** Why the task will approve directly at done: no reviewer could be picked, and who was skipped (issue #92). */
393
+ private noReviewer(a: Assignment): string {
394
+ const skipped = a.trace.flatMap((l) => {
395
+ const m = /^ {2}reviewer candidate (\S+): skipped, (.*)$/.exec(l);
396
+ return m ? [`${m[1]} (${m[2]})`] : [];
397
+ });
398
+ return `no reviewer: done will approve directly${skipped.length ? ` (skipped: ${skipped.join("; ")})` : ""}`;
399
+ }
400
+
401
+ private announceRouting(task: Task, a: Assignment): void {
402
+ if (task.class !== "review" && !a.reviewer) this.d.notify(`task ${this.publicTitle(task)}: ${this.noReviewer(a)}`);
403
+ const hold = a.owner ? this.d.held?.()[a.owner] : undefined;
404
+ if (hold) this.d.notify(`task ${this.publicTitle(task)}: ${a.owner}'s queue is held (${hold}); the task arrives once the hold is resolved`);
405
+ }
406
+
368
407
  private async assignOwner(task: Task, by: PeerId, opts: { candidates?: PeerId[]; event?: string; note?: string; clearOnFail?: boolean; exclude?: PeerId[]; context?: string; claim?: boolean } = {}): Promise<Task> {
369
408
  const waits = this.waitsFor(task);
370
409
  const a = assign(task, this.states(), this.d.routing(), { exclude: [...this.declined(task), ...(opts.exclude ?? []), ...(opts.event === "escalated" && task.owner ? [task.owner] : [])], ...(opts.candidates ? { candidates: opts.candidates } : {}), waitsFor: waits, ...this.weights(task.class) });
@@ -383,6 +422,7 @@ export class Tasks {
383
422
  this.announceOverlap(next, hits);
384
423
  this.tellEarlierOwners(next, hits);
385
424
  }
425
+ this.announceRouting(next, a);
386
426
  if (opts.claim && a.owner === by) {
387
427
  const claimed = this.d.board.update(next.id, by, "accepted", { state: "in_progress" });
388
428
  this.d.notify(`task ${this.publicTitle(claimed)} claimed by ${by}`);
@@ -396,7 +436,7 @@ export class Tasks {
396
436
  const pii = this.isPii(task);
397
437
  const brief = pii ? undefined : await this.d.briefs?.forTask(task.owner!, task).catch(() => undefined);
398
438
  const rejected = task.history.filter((h) => h.event === "changes_requested").map((h) => `- ${h.by}: ${this.screen(task, h.note ?? "", "review note", a.owner)}`);
399
- const facts = [`class ${task.class}`, a.owner === PI ? `backend pi/${a.piBackend ?? "dgx"}` : "", task.refs.paths?.length ? `paths ${task.refs.paths.join(", ")}` : "", task.refs.branch ? `branch ${task.refs.branch}` : "", a.reviewer ? `reviewer ${a.reviewer}` : "no reviewer"].filter(Boolean).join("; ");
439
+ const facts = [`class ${task.class}`, a.owner === PI ? `backend pi/${a.piBackend ?? "dgx"}` : "", task.refs.paths?.length ? `paths ${task.refs.paths.join(", ")}` : "", task.refs.branch ? `branch ${task.refs.branch}` : "", a.reviewer ? `reviewer ${a.reviewer}` : task.class === "review" ? "no reviewer" : this.noReviewer(a)].filter(Boolean).join("; ");
400
440
  const plan = planText(task.plan);
401
441
  const body = [
402
442
  `Task #${task.id} [${task.class}] ${task.title}`,
@@ -438,7 +478,7 @@ export class Tasks {
438
478
  for (const t of this.d.board.list("proposed")) {
439
479
  const last = t.history.at(-1)?.event;
440
480
  if (!t.deps?.length || t.owner || this.offered.has(t.id) || (last !== "blocked" && last !== "ready") || this.waitsFor(t).length) continue;
441
- if (!assign(t, this.states(), this.d.routing(), { exclude: this.declined(t) }).owner) continue; // nobody attached can take it yet
481
+ if (!assign(t, this.states(), this.d.routing(), { exclude: this.declined(t), ...this.health() }).owner) continue; // nobody attached can take it yet
442
482
  await this.offerReady(t, "nothing left to wait for");
443
483
  }
444
484
  }
@@ -47,11 +47,29 @@ export function allowed(list: string[], host: string, port: number): boolean {
47
47
  const INTERNAL = new BlockList();
48
48
  // IPv4 nets as leading octets: the package check refuses private address literals in published files.
49
49
  for (const [a, b, bits] of [[0, 0, 8], [10, 0, 8], [100, 64, 10], [127, 0, 8], [169, 254, 16], [172, 16, 12], [192, 168, 16], [198, 18, 15], [224, 0, 3]] as const) INTERNAL.addSubnet(`${a}.${b}.0.0`, bits, "ipv4");
50
- for (const [net, bits] of [["::", 96], ["fc00::", 7], ["fe80::", 10], ["fec0::", 10], ["ff00::", 8]] as const) INTERNAL.addSubnet(net, bits, "ipv6");
50
+ // RFC 8215's local-use NAT64 prefix puts the IPv4 address where the operator's prefix length says: refused outright.
51
+ for (const [net, bits] of [["::", 96], ["fc00::", 7], ["fe80::", 10], ["fec0::", 10], ["ff00::", 8], ["64:ff9b:1::", 48]] as const) INTERNAL.addSubnet(net, bits, "ipv6");
52
+ // The well-known NAT64 prefix (issue #82): with DNS64, a name whose only record is an internal IPv4 address comes back
53
+ // as 64:ff9b::<that address>, and the NAT64 gateway carries the connection there. Its IPv4 address is what counts.
54
+ const NAT64 = new BlockList();
55
+ NAT64.addSubnet("64:ff9b::", 96, "ipv6");
56
+
57
+ /** The IPv4 address in the last 32 bits of an IPv6 address, whatever its spelling. */
58
+ function lastIPv4(v6: string): string {
59
+ const [head = "", tail] = new URL(`http://[${v6}]`).hostname.slice(1, -1).split("::");
60
+ const h = head ? head.split(":") : [];
61
+ const t = tail ? tail.split(":") : [];
62
+ const all = tail === undefined ? h : [...h, ...Array<string>(8 - h.length - t.length).fill("0"), ...t];
63
+ const [a = 0, b = 0] = all.slice(6).map((g) => parseInt(g, 16));
64
+ return `${a >> 8}.${a & 255}.${b >> 8}.${b & 255}`;
65
+ }
51
66
 
52
67
  /** Loopback, private, link-local, site-local, unspecified, carrier-grade NAT, benchmark, multicast or reserved. */
53
68
  export function isInternal(address: string): boolean {
69
+ // Only a non-global address carries a zone (`fe80::1%en0`, `::1%lo0`), and BlockList cannot parse one: internal.
70
+ if (address.includes("%")) return true;
54
71
  const family = isIP(address);
72
+ if (family === 6 && NAT64.check(address, "ipv6")) return isInternal(lastIPv4(address));
55
73
  return family !== 0 && INTERNAL.check(address, family === 6 ? "ipv6" : "ipv4");
56
74
  }
57
75
 
@@ -75,7 +93,8 @@ export function startEgressProxy(opts: { allow: string[]; log: (line: string) =>
75
93
  head = Buffer.concat([head, chunk]);
76
94
  const end = head.indexOf("\r\n\r\n");
77
95
  if (end < 0) {
78
- if (head.length > 8192) refuse(client, 431, "request header too large");
96
+ // Once: later chunks of the same request must not refuse (and log) again. Nothing of the header is logged.
97
+ if (head.length > 8192) client.off("data", onData), refuse(client, 431, "request header too large", "a request");
79
98
  return;
80
99
  }
81
100
  client.off("data", onData);
@@ -114,14 +133,22 @@ export function startEgressProxy(opts: { allow: string[]; log: (line: string) =>
114
133
  const upstream = connect({ host: address, port, allowHalfOpen: true });
115
134
  sockets.add(upstream);
116
135
  upstream.on("close", () => sockets.delete(upstream));
136
+ let open = false;
117
137
  upstream.once("connect", () => {
138
+ open = true;
118
139
  client.write(`${http} 200 Connection Established\r\n\r\n`);
119
140
  if (rest.length) upstream.write(rest);
120
141
  client.pipe(upstream);
121
142
  upstream.pipe(client);
122
143
  client.resume();
123
144
  });
124
- upstream.on("error", () => (client.writable && !client.destroyed ? refuse(client, 403, `${host}:${port} is unreachable`) : client.destroy()));
145
+ // Before the tunnel opens a failure is a refusal, logged with the error code only (never its text). After, the
146
+ // stream is the client's TLS: an HTTP answer would corrupt it, so the client is just closed.
147
+ upstream.on("error", (e: NodeJS.ErrnoException) => {
148
+ if (open || !client.writable || client.destroyed) return void client.destroy();
149
+ const code = /^E[A-Z]{2,20}$/.test(e.code ?? "") ? `, ${e.code}` : "";
150
+ refuse(client, 403, `${host}:${port} is unreachable${code}`, `${host}:${port}`);
151
+ });
125
152
  client.on("close", () => upstream.destroy());
126
153
  };
127
154
 
@@ -59,21 +59,20 @@ const NETWORK_MACH_SERVICES = ["com.apple.dnssd.service", "com.apple.trustd", "c
59
59
  const PROXY_MACH_SERVICES = ["com.apple.trustd", "com.apple.trustd.agent"];
60
60
 
61
61
  /**
62
- * Network for the commands: none, direct (`local.bash_network: "direct"`, everything, kept for one release), or only
62
+ * Network for the commands: none, direct (`local.bash_network: "direct"`, everything, until 0.13.0), or only
63
63
  * the hub's egress proxy on a loopback port (#65), which opens allowlisted hosts.
64
64
  */
65
65
  export type SandboxNetwork = boolean | { proxyPort: number };
66
66
 
67
67
  /**
68
- * `base` "deny" (issue #39) starts from `(deny default)` and allows only what commands need: running and reading the
69
- * system, toolchain and project directories, writing the project and temp. "allow" is the profile of 0.9 and earlier,
70
- * kept for one release behind `local.sandbox: "allow-default"`. The denies at the end apply to both.
68
+ * Starts from `(deny default)` (issue #39) and allows only what commands need: running and reading the system,
69
+ * toolchain and project directories, writing the project and a temp dir of their own. The allow-default profile of
70
+ * 0.9 and earlier was removed in 0.12.0 (issue #83).
71
71
  */
72
- export function profile(cwd: string, network: SandboxNetwork, readAllow: string[] = [], deny: string[] = [], base: "deny" | "allow" = "deny"): string {
72
+ export function profile(cwd: string, network: SandboxNetwork, readAllow: string[] = [], deny: string[] = []): string {
73
73
  const home = homedir();
74
74
  const inHome = (p: string) => (p.startsWith("~/") ? join(home, p.slice(2)) : p);
75
75
  const root = realPath(cwd);
76
- const tmp = realPath(tmpdir());
77
76
  const gitDirs = externalGitDirs(root);
78
77
  const creds = [".ssh", ".aws", ".gnupg", ".config/gh", ".config/gcloud", ".kube", ".docker", ".netrc", ".npmrc", ".omniroute", ".claude", ".codex", ".kimi-code", "Library/Keychains"];
79
78
  const dev = developerDir();
@@ -81,38 +80,28 @@ export function profile(cwd: string, network: SandboxNetwork, readAllow: string[
81
80
  const subpaths = (paths: string[]) => paths.map((p) => `(subpath ${q(p)})`).join(" ");
82
81
  const globals = (names: string[]) => names.map((n) => `(global-name ${q(n)})`).join(" ");
83
82
  const proxy = typeof network === "object" ? `(allow network-outbound (remote ip ${q(`localhost:${network.proxyPort}`)}))` : undefined;
84
- const start = base === "deny"
85
- ? [
86
- "(deny default)",
87
- "(allow process-fork)",
88
- // Runs from the system, toolchain and project directories only; a script runs through an allowed interpreter.
89
- // The user's temp dir and /private/tmp are shared with every other process: each command gets its own (#63).
90
- `(allow process-exec ${subpaths([...SYSTEM_READABLE, ...readable])})`,
91
- "(allow signal (target same-sandbox))",
92
- "(allow process-info* (target same-sandbox))",
93
- "(allow sysctl-read)",
94
- `(allow mach-lookup ${globals([...MACH_SERVICES, ...(proxy ? PROXY_MACH_SERVICES : network ? NETWORK_MACH_SERVICES : [])])})`,
95
- '(allow ipc-posix-shm-read-data ipc-posix-shm-read-metadata (ipc-posix-name "apple.shm.notification_center"))',
96
- "(allow file-read-metadata)",
97
- `(allow file-read* (literal "/") ${subpaths([...SYSTEM_READABLE, "/dev"])} ${subpaths(readable)})`,
98
- '(allow file-ioctl (regex #"^/dev/"))',
99
- ...(proxy ? [proxy] : network ? ["(allow network*)"] : []),
100
- ]
101
- : [
102
- "(allow default)",
103
- ...(network === true ? [] : ["(deny network*)", ...(proxy ? [proxy] : [])]),
104
- // Home is default-deny for reads: whatever a command reads can end up in the model's answer, and that answer
105
- // is shared with agents that run on cloud subscriptions (~/.claude.json, app tokens, browser profiles ...).
106
- `(deny file-read* (subpath ${q(home)}))`,
107
- `(allow file-read-metadata (subpath ${q(home)}))`,
108
- `(allow file-read* ${subpaths(readable)})`,
109
- ];
83
+ const start = [
84
+ "(deny default)",
85
+ "(allow process-fork)",
86
+ // Runs from the system, toolchain and project directories only; a script runs through an allowed interpreter.
87
+ // The user's temp dir and /private/tmp are shared with every other process: each command gets its own (#63).
88
+ `(allow process-exec ${subpaths([...SYSTEM_READABLE, ...readable])})`,
89
+ "(allow signal (target same-sandbox))",
90
+ "(allow process-info* (target same-sandbox))",
91
+ "(allow sysctl-read)",
92
+ `(allow mach-lookup ${globals([...MACH_SERVICES, ...(proxy ? PROXY_MACH_SERVICES : network ? NETWORK_MACH_SERVICES : [])])})`,
93
+ '(allow ipc-posix-shm-read-data ipc-posix-shm-read-metadata (ipc-posix-name "apple.shm.notification_center"))',
94
+ "(allow file-read-metadata)",
95
+ `(allow file-read* (literal "/") ${subpaths([...SYSTEM_READABLE, "/dev"])} ${subpaths(readable)})`,
96
+ '(allow file-ioctl (regex #"^/dev/"))',
97
+ ...(proxy ? [proxy] : network ? ["(allow network*)"] : []),
98
+ ];
110
99
  const places = `${creds.map((c) => `(subpath ${q(join(home, c))})`).join(" ")} ${denyRegexes(root, deny, false).join(" ")}`;
111
100
  return [
112
101
  "(version 1)",
113
102
  ...start,
114
103
  "(deny file-write*)",
115
- `(allow file-write* (subpath ${q(root)}) ${base === "deny" ? "" : `(subpath ${q(tmp)}) (subpath "/private/tmp") `}(regex #"^/dev/") ${gitDirs.map((d) => `(subpath ${q(d)})`).join(" ")})`,
104
+ `(allow file-write* (subpath ${q(root)}) (regex #"^/dev/") ${gitDirs.map((d) => `(subpath ${q(d)})`).join(" ")})`,
116
105
  // Inside cwd: nothing that runs later outside the sandbox, nothing that reconfigures the hub.
117
106
  `(deny file-write* (subpath ${q(join(root, ".agenthub"))}) ${[join(root, ".git"), ...gitDirs].map((d) => `(subpath ${q(join(d, "hooks"))}) (literal ${q(join(d, "config"))})`).join(" ")})`,
118
107
  `(deny file-read* file-write* ${creds.map((c) => `(subpath ${q(join(home, c))})`).join(" ")})`,
@@ -124,6 +124,29 @@ export class OmniRoute {
124
124
  return undefined;
125
125
  }
126
126
 
127
+ /** Authenticated model inventory; upstream response bodies never enter diagnostics. */
128
+ async models(): Promise<string[]> {
129
+ const base = await this.base();
130
+ if (!base) throw new Error("no model gateway is configured or reachable (see ahub doctor)");
131
+ const key = this.apiKey();
132
+ if (!key) throw new Error("no OmniRoute API key (see ahub doctor)");
133
+ const response = await fetch(`${base}/models`, { headers: { authorization: `Bearer ${key}`, ...this.accessHeaders(base) }, signal: AbortSignal.timeout(4000) });
134
+ if (!response.ok) throw new Error(`model inventory failed: HTTP ${response.status}`);
135
+ const body = await response.json() as { data?: { id?: unknown }[] };
136
+ if (!Array.isArray(body.data)) throw new Error("model inventory returned no model list");
137
+ return body.data.flatMap((m) => typeof m?.id === "string" ? [m.id] : []);
138
+ }
139
+
140
+ /** A listed model can still lack provider credentials: prove it accepts a minimal call before attaching. */
141
+ async checkModel(model: string, inventory?: string[]): Promise<void> {
142
+ if (!(inventory ?? await this.models()).includes(model)) throw new Error(`model ${model} is not served by the gateway`);
143
+ try {
144
+ await this.chat({ model, messages: [{ role: "user", content: "agent-hub model availability check: reply OK" }], max_tokens: 1 }, { signal: AbortSignal.timeout(10_000) });
145
+ } catch {
146
+ throw new Error(`model ${model} is not usable (availability call failed; check gateway credentials and provider health)`);
147
+ }
148
+ }
149
+
127
150
  async chat(body: { model: string; messages: ChatMessage[]; tools?: unknown[]; max_tokens?: number }, opts: ChatOptions = {}): Promise<ChatResult> {
128
151
  const base = opts.via ?? (await this.base());
129
152
  if (!base) throw new Error("no model gateway is configured or reachable (omniroute.urls in .agenthub/config.json; see ahub doctor)");
@@ -1,3 +1,4 @@
1
+ import { assistantTokens } from "./usage.ts";
1
2
  type ExtensionAPI = any;
2
3
  import { execFileSync } from "node:child_process";
3
4
  import { createHash } from "node:crypto";
@@ -15,6 +16,7 @@ const models = (() => {
15
16
  let pollStarted = false;
16
17
  let toolSteps = 0;
17
18
  let lastActivity = 0;
19
+ let usageSeq = 0;
18
20
  let forcedFailure = "";
19
21
  const maxSteps = Number(process.env.AGENTHUB_PI_MAX_STEPS ?? 30);
20
22
  let shutdown: (() => void) | undefined;
@@ -89,6 +91,13 @@ export default function(pi: ExtensionAPI): void {
89
91
  const cancelled = !forcedFailure && message?.stopReason === "aborted";
90
92
  await post("/event", { type: "agent_end", text: text ?? "", failed, cancelled, ...(failed ? { error: forcedFailure || message?.errorMessage || message?.stopReason } : {}) });
91
93
  });
94
+ pi.on("message_end", async (event: any) => {
95
+ const tokens = assistantTokens(event.message);
96
+ if (tokens === undefined) return;
97
+ // Message-end is the sole usage source: agent_end carries those same messages again.
98
+ try { await post("/event", { type: "tokens", id: `usage-${++usageSeq}`, tokens }); }
99
+ catch { /* telemetry must not break a native turn */ }
100
+ });
92
101
  pi.on("message_update", async () => {
93
102
  const now = Date.now();
94
103
  if (now - lastActivity < 1000) return;
@@ -0,0 +1,14 @@
1
+ /** Pi 0.86 assistant usage, including cache reads/writes. Unknown shapes are not estimates. */
2
+ export function assistantTokens(message: unknown): number | undefined {
3
+ if (!message || typeof message !== "object") return undefined;
4
+ const m = message as { role?: unknown; usage?: unknown };
5
+ if (m.role !== "assistant" || !m.usage || typeof m.usage !== "object") return undefined;
6
+ const u = m.usage as Record<string, unknown>;
7
+ const count = (v: unknown): v is number => typeof v === "number" && Number.isSafeInteger(v) && v >= 0;
8
+ if (count(u.totalTokens)) return u.totalTokens;
9
+ if (!count(u.input) || !count(u.output)) return undefined;
10
+ const parts = [u.input, u.output, u.cacheRead ?? 0, u.cacheWrite ?? 0];
11
+ if (!parts.every(count)) return undefined;
12
+ const total = parts.reduce((n, v) => n + v, 0);
13
+ return Number.isSafeInteger(total) ? total : undefined;
14
+ }
@@ -23,7 +23,7 @@
23
23
  "cf_client_id_file": "",
24
24
  "cf_client_secret_file": ""
25
25
  },
26
- "local": { "deny": [], "bash_network": false, "max_steps": 30, "read_allow": [], "sandbox": "deny-default" },
26
+ "local": { "deny": [], "bash_network": false, "max_steps": 30, "read_allow": [] },
27
27
  "approvals": { "timeout_s": 120 },
28
28
  "tasks": { "release_after_min": 30 },
29
29
  "checks": { "timeout_s": 600 },