@staix/agent-hub 0.12.19 → 0.12.21

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.
Files changed (87) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +7 -6
  3. package/docs/agent-notes/adapters.md +10 -2
  4. package/docs/agent-notes/bus.md +6 -2
  5. package/docs/agent-notes/daemon.md +10 -3
  6. package/docs/agent-notes/local-worker.md +3 -1
  7. package/docs/agent-notes/models.md +3 -0
  8. package/docs/agent-notes/tasks.md +6 -3
  9. package/docs/agent-notes/tests.md +3 -2
  10. package/docs/bench.md +143 -0
  11. package/docs/events.md +6 -2
  12. package/docs/operations.md +655 -34
  13. package/docs/quickstart.md +12 -10
  14. package/docs/research.md +92 -0
  15. package/docs/security.md +59 -17
  16. package/docs/smoke.md +137 -0
  17. package/docs/specs/2026-09-19-agent-hub-design.md +586 -16
  18. package/docs/specs/2026-09-20-pi-local-models-design.md +2 -0
  19. package/docs/specs/2026-09-20-upgrade-recovery-design.md +345 -0
  20. package/docs/specs/2026-10-04-switchyard-source-port-design.md +10 -0
  21. package/docs/verified.json +29 -18
  22. package/package.json +1 -1
  23. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  24. package/plugins/agent-hub/server.js +65 -26
  25. package/src/adapters/acp.ts +67 -6
  26. package/src/adapters/claude-channel.ts +61 -20
  27. package/src/adapters/codex-appserver.ts +70 -2
  28. package/src/adapters/local-worker.ts +8 -3
  29. package/src/adapters/pi.ts +58 -4
  30. package/src/cli/bench.ts +287 -0
  31. package/src/cli/console-state.ts +531 -110
  32. package/src/cli/console.ts +59 -26
  33. package/src/cli/facts-hook.ts +50 -8
  34. package/src/cli/help.ts +130 -0
  35. package/src/cli/identity.ts +11 -5
  36. package/src/cli/launch.ts +98 -17
  37. package/src/cli/main.ts +325 -88
  38. package/src/cli/preview.ts +14 -3
  39. package/src/cli/reset.ts +194 -0
  40. package/src/cli/status-lines.ts +10 -5
  41. package/src/cli/terminal-recovery.ts +196 -40
  42. package/src/cli/upgrade-runtime.ts +327 -60
  43. package/src/cli/upgrade.ts +442 -45
  44. package/src/hub/ask.ts +1 -1
  45. package/src/hub/bench.ts +211 -0
  46. package/src/hub/board.ts +15 -7
  47. package/src/hub/bus.ts +79 -8
  48. package/src/hub/child-process.ts +1 -0
  49. package/src/hub/conductor.ts +54 -21
  50. package/src/hub/config-trust.ts +1 -0
  51. package/src/hub/control-client.ts +9 -4
  52. package/src/hub/daemon.ts +401 -48
  53. package/src/hub/delivery-journal.ts +6 -6
  54. package/src/hub/events.ts +9 -2
  55. package/src/hub/execution-budget.ts +15 -6
  56. package/src/hub/hub-tools.ts +4 -4
  57. package/src/hub/inference.ts +136 -1
  58. package/src/hub/lifecycle.ts +13 -12
  59. package/src/hub/manager.ts +16 -14
  60. package/src/hub/peers.ts +2 -0
  61. package/src/hub/permission-mode.ts +44 -0
  62. package/src/hub/recovery-store.ts +68 -8
  63. package/src/hub/registry.ts +36 -22
  64. package/src/hub/report.ts +43 -1
  65. package/src/hub/research.ts +255 -0
  66. package/src/hub/restart.ts +25 -0
  67. package/src/hub/routing.ts +48 -16
  68. package/src/hub/snapshots.ts +3 -2
  69. package/src/hub/tasks.ts +295 -115
  70. package/src/hub/tool-refusal.ts +7 -0
  71. package/src/hub/ui.ts +4 -1
  72. package/src/local/deny.ts +2 -1
  73. package/src/local/tools.ts +14 -10
  74. package/src/models/mlx.ts +11 -8
  75. package/src/models/ollama.ts +2 -2
  76. package/src/models/relay.ts +190 -42
  77. package/src/models/route/relay-selector.ts +43 -31
  78. package/src/models/route/runtime.ts +17 -7
  79. package/src/models/route/signals.ts +12 -1
  80. package/src/models/route/stage.ts +57 -3
  81. package/src/omniroute/client.ts +1 -1
  82. package/src/pi/process-signature.ts +23 -0
  83. package/src/pi/tool-receipts.ts +7 -1
  84. package/src/ui/index.html +84 -6
  85. package/src/ui/task-progress.ts +33 -0
  86. package/templates/AGENTS.block.md +1 -1
  87. package/templates/routing.toml +8 -0
@@ -38,9 +38,19 @@ export function launcherPreview(tool: "claude" | "codex" | "kimi" | "pi", raw: s
38
38
  }
39
39
  // Build with original inputs so channel selection and owned-flag validation match normal launch.
40
40
  const facts = claudeObservationHooks(config, { script: join(import.meta.dir, "facts-hook.ts"), stateDir });
41
- launch = buildLaunch(tool, args, { unattended, statusLine: { script: join(import.meta.dir, "statusline-tee.ts"), stateDir, ...(original ? { original: { command: original.command ? REDACTED : "", ...(typeof original.refreshInterval === "number" && Number.isFinite(original.refreshInterval) ? { refreshInterval: original.refreshInterval } : {}), ...(typeof original.padding === "number" && Number.isFinite(original.padding) ? { padding: original.padding } : {}) } } : {}) }, ...(facts ? { facts } : {}) });
42
- const passthrough = args.filter(arg => !["--unattended", "--safe", "--new"].includes(arg));
43
- launch.args = launch.args.slice(0, launch.args.length - passthrough.length).concat(passthrough.map(publicArg));
41
+ launch = buildLaunch(tool, args, { preview: true, unattended, statusLine: { script: join(import.meta.dir, "statusline-tee.ts"), stateDir, ...(original ? { original: { command: original.command ? REDACTED : "", ...(typeof original.refreshInterval === "number" && Number.isFinite(original.refreshInterval) ? { refreshInterval: original.refreshInterval } : {}), ...(typeof original.padding === "number" && Number.isFinite(original.padding) ? { padding: original.padding } : {}) } } : {}) }, ...(facts ? { facts } : {}) });
42
+ // Merged caller settings change the native argument count. Rebuild a redacted preview,
43
+ // retaining the managed hooks but never serializing caller settings or their file path.
44
+ const safeArgs = args.map((arg, i) => {
45
+ if (args[i - 1] === "--settings") return "{}";
46
+ if (arg.startsWith("--settings=")) return "--settings={}";
47
+ if (["--unattended", "--safe", "--new"].includes(arg)) return arg;
48
+ return publicArg(arg);
49
+ });
50
+ const publicLaunch = buildLaunch(tool, safeArgs, { preview: true, unattended, statusLine: { script: join(import.meta.dir, "statusline-tee.ts"), stateDir,
51
+ ...(original ? { original: { command: original.command ? REDACTED : "", ...(typeof original.refreshInterval === "number" && Number.isFinite(original.refreshInterval) ? { refreshInterval: original.refreshInterval } : {}), ...(typeof original.padding === "number" && Number.isFinite(original.padding) ? { padding: original.padding } : {}) } } : {}) }, facts });
52
+ publicLaunch.args.splice(0, 2, ...launch.args.slice(0, 2)); // channel selection was verified against the original MCP config
53
+ launch = publicLaunch;
44
54
  } else if (tool === "codex") {
45
55
  // A fresh native proxy is assigned by start, never guessed from an old status file.
46
56
  launch = buildLaunch(tool, args, { unattended, proxyUrl: UNRESOLVED, codexBin: config.codex_bin === "codex" ? "codex" : REDACTED });
@@ -71,6 +81,7 @@ export function launcherPreview(tool: "claude" | "codex" | "kimi" | "pi", raw: s
71
81
  unresolved.push({ field: "bridge/relay endpoints and owner credentials", reason: "assigned at runtime; preview does not bind a server or start a model" });
72
82
  if (!value("--session-id") && !value("--session-file")) unresolved.push({ field: "sessionId", reason: "new native session identity is not allocated by preview" });
73
83
  }
84
+ if (tool === "claude") envNames.push("AGENTHUB_CHANNEL");
74
85
  if (tool === "claude" || tool === "codex") {
75
86
  envNames.push("AGENTHUB_INSTANCE_ID", "AGENTHUB_LAUNCH_ID");
76
87
  unresolved.push({ field: "env.AGENTHUB_INSTANCE_ID", reason: tool === "claude" ? "injected when an existing daemon instance is read at launch; preview does not read runtime state" : "injected when a verified Orca terminal launch is recorded against the daemon; preview does not record it" });
@@ -0,0 +1,194 @@
1
+ import { constants, Database } from "bun:sqlite";
2
+ import { randomUUID } from "node:crypto";
3
+ import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { Bus } from "../hub/bus.ts";
6
+ import { DeliveryJournal } from "../hub/delivery-journal.ts";
7
+ import type { Project } from "../hub/registry.ts";
8
+
9
+ /** Agent session resume pointers. `pi-sessions/` holds Pi transcripts, history rather than pointers, and stays. */
10
+ export const SESSION_POINTERS = ["sessions.json", "claude-session.json", "claude-context.json"];
11
+ /** What a running daemon publishes; a daemon that did not stop cleanly (crash, SIGKILL, forced exit) leaves it. */
12
+ export const MANIFEST = ["status.json", "control-token", "hub.pid"];
13
+
14
+ /** A state the reset cannot read the same way twice: rerunning fails alike, while `--all` archives it as it is. */
15
+ export function damagedState(error: unknown): boolean {
16
+ return error instanceof SyntaxError || /invalid delivery journal|not a database|malformed|corrupt/i.test((error as Error)?.message ?? "");
17
+ }
18
+
19
+ /**
20
+ * What may be printed about a failure: a parser's message can quote the data it choked on (envelope bodies, task text),
21
+ * so JSON and SQLite errors are named by class and code only. Every other message is written by the hub or the system.
22
+ */
23
+ export function failureText(error: unknown): string {
24
+ const e = error as NodeJS.ErrnoException;
25
+ if (e instanceof SyntaxError || e?.name === "SQLiteError") return `${[e.name, e.code].filter(Boolean).join(" ")} reading hub.db`;
26
+ return e?.message ?? String(error);
27
+ }
28
+
29
+ /** Ids and peer names only: a reset never prints task or message text (#214). */
30
+ export interface ResetPlan {
31
+ queued: string[];
32
+ needsReview: string[];
33
+ /** Dispatching or accepted: the journal holds them as needs_review once the hub has stopped. */
34
+ inFlight: string[];
35
+ manualHolds: string[];
36
+ budgetPauses: string[];
37
+ conductorHolds: string[];
38
+ sessionPointers: string[];
39
+ entries: number;
40
+ }
41
+
42
+ const isList = (value: unknown, item: (v: unknown) => boolean) => Array.isArray(value) && value.every(item);
43
+ const isText = (v: unknown) => typeof v === "string";
44
+ const isObject = (v: unknown): v is Record<string, unknown> => !!v && typeof v === "object" && !Array.isArray(v);
45
+ const isEnvelope = (v: unknown) => isObject(v) && typeof v.id === "string";
46
+ /** Valid JSON of the wrong shape is damage as well: the journal refuses it on every open, so the message says so. */
47
+ const shaped = (ok: boolean, what: string) => { if (!ok) throw new Error(`invalid delivery journal: ${what} has the wrong shape`); };
48
+
49
+ /** What a reset would act on, read from the state directory without changing it (hub.db opened read-only). */
50
+ export function planReset(stateDir: string, projectId: string): ResetPlan {
51
+ const plan: ResetPlan = { queued: [], needsReview: [], inFlight: [], manualHolds: [], budgetPauses: [], conductorHolds: [],
52
+ sessionPointers: SESSION_POINTERS.filter((name) => existsSync(join(stateDir, name))), entries: existsSync(stateDir) ? readdirSync(stateDir).length : 0 };
53
+ const file = join(stateDir, "hub.db");
54
+ if (!existsSync(file)) return plan;
55
+ // A read-only open of a WAL database creates hub.db-wal and -shm when the last writer's close removed them (Linux),
56
+ // and fails without them on macOS. Without them no writer is mid-transaction, so `immutable` reads hub.db alone and
57
+ // writes nothing. ponytail: a -wal left without its -shm (deleted by hand) is ignored then, and commits still in
58
+ // it go unlisted; open plainly in that corner if it ever matters. SQLITE_OPEN_URI is passed because Linux builds do
59
+ // not parse `file:` names by default (the name then fails as a plain path).
60
+ const quiet = !existsSync(`${file}-wal`) || !existsSync(`${file}-shm`);
61
+ const db = quiet ? new Database(`file:${file.replace(/[%?#]/g, (c) => `%${c.charCodeAt(0).toString(16)}`)}?immutable=1`, constants.SQLITE_OPEN_READONLY | constants.SQLITE_OPEN_URI) : new Database(file, { readonly: true });
62
+ try {
63
+ const has = (table: string) => !!db.query("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(table);
64
+ const peers = (table: string) => has(table) ? (db.query(`SELECT peer FROM ${table} ORDER BY peer`).all() as { peer: string }[]).map((row) => row.peer) : [];
65
+ plan.budgetPauses = peers("budget_pauses");
66
+ plan.conductorHolds = peers("conductor_holds");
67
+ if (!has("deliveries")) return plan;
68
+ const rows = db.query("SELECT id, peer, state, originals FROM deliveries WHERE project_id = ? AND state IN ('queued', 'dispatching', 'accepted', 'needs_review') ORDER BY created_at, id").all(projectId) as { id: string; peer: string; state: string; originals: string }[];
69
+ for (const row of rows) (row.state === "queued" ? plan.queued : row.state === "needs_review" ? plan.needsReview : plan.inFlight).push(row.id);
70
+ const meta = db.query("SELECT bus_snapshot, manual_paused FROM delivery_meta WHERE project_id = ?").get(projectId) as { bus_snapshot: string; manual_paused: string } | null;
71
+ const bus: unknown = JSON.parse(meta?.bus_snapshot || "{}"), manual: unknown = JSON.parse(meta?.manual_paused || "[]");
72
+ shaped(isList(manual, isText), "manual_paused");
73
+ shaped(isObject(bus) && (bus.manualPaused === undefined || isList(bus.manualPaused, isText)) && (bus.queues === undefined || (isObject(bus.queues) && Object.values(bus.queues).every((queue) => isList(queue, isEnvelope)))), "bus_snapshot");
74
+ const { queues = {}, manualPaused = [] } = bus as { queues?: Record<string, { id: string }[]>; manualPaused?: string[] };
75
+ plan.manualHolds = [...new Set([...manual as string[], ...manualPaused])].sort();
76
+ // As Bus.queueList: a queued envelope that no open row stands for is listed as q:<peer>:<envelope id>.
77
+ // ponytail: the rule is copied so the dry run never opens the journal for writing; the reset tests compare the
78
+ // listed ids with the settled ones. Share one pure helper with queueList if that rule changes.
79
+ const recorded = new Set(rows.flatMap((row) => {
80
+ const originals: unknown = JSON.parse(row.originals);
81
+ shaped(isList(originals, isEnvelope), `delivery ${row.id} originals`);
82
+ return (originals as { id: string }[]).map((env) => `${row.peer}:${env.id}`);
83
+ }));
84
+ for (const [peer, queue] of Object.entries(queues)) for (const env of queue) if (!recorded.has(`${peer}:${env.id}`)) plan.queued.push(`q:${peer}:${env.id}`);
85
+ } finally { db.close(); }
86
+ return plan;
87
+ }
88
+
89
+ const named = (label: string, items: string[]) => `${label} ${items.length}${items.length ? `: ${items.join(", ")}` : ""}`;
90
+ /** The runtime scope, ids and counts: the deliveries first, then the holds and pointers. */
91
+ export function resetLines(plan: ResetPlan): string[] {
92
+ return [
93
+ `deliveries: ${named("queued", plan.queued)}; ${named("needs_review", plan.needsReview)}; ${named("in flight", plan.inFlight)}`,
94
+ named("manual holds", plan.manualHolds),
95
+ named("budget pauses", plan.budgetPauses),
96
+ named("conductor holds", plan.conductorHolds),
97
+ named("session pointers", plan.sessionPointers),
98
+ ];
99
+ }
100
+
101
+ /**
102
+ * Runtime reset of a stopped hub: every queued and needs_review delivery is discarded with reason `reset` through
103
+ * the journal (one resolution_history entry each), manual, budget and conductor holds are cleared and the session
104
+ * pointers dropped, with a crashed run's manifest. The board, logs, audit, recovery records and execution budgets stay.
105
+ */
106
+ export function resetRuntime(stateDir: string, project: Project): { settled: string[]; plan: ResetPlan; stale: string[] } {
107
+ const file = join(stateDir, "hub.db");
108
+ const plan = planReset(stateDir, project.id);
109
+ const settled: string[] = [];
110
+ if (existsSync(file)) {
111
+ // Opening the journal as a new instance turns dispatching and accepted rows of the stopped run into needs_review.
112
+ const journal = new DeliveryJournal({ file, projectRoot: project.root, projectId: project.id, instanceId: randomUUID() });
113
+ const bus = new Bus({ journal });
114
+ try {
115
+ for (const { id } of bus.queueList().filter((row) => row.state === "queued" || row.state === "needs_review")) {
116
+ // Read again each time: a queue entry's revision is the journal's, and every resolution moves it on.
117
+ bus.resolveDelivery(id, bus.queueShow(id)!.revision, "discard", "reset");
118
+ settled.push(id);
119
+ }
120
+ bus.setManualPaused([]);
121
+ } finally { bus.closeJournal(); }
122
+ const db = new Database(file);
123
+ try {
124
+ db.transaction(() => {
125
+ for (const table of ["budget_pauses", "conductor_holds"]) {
126
+ if (db.query("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(table)) db.run(`DELETE FROM ${table}`);
127
+ }
128
+ })();
129
+ } finally { db.close(); }
130
+ }
131
+ for (const name of SESSION_POINTERS) rmSync(join(stateDir, name), { force: true });
132
+ // The caller has checked that no daemon behind these is alive or uncertain: they are a crashed run's leftovers.
133
+ const stale = MANIFEST.filter((name) => existsSync(join(stateDir, name)));
134
+ for (const name of stale) rmSync(join(stateDir, name), { force: true });
135
+ return { settled, plan, stale };
136
+ }
137
+
138
+ /**
139
+ * Why `--all` must not move this state directory into `<root>/.agenthub/archive`, or undefined. An agent with write
140
+ * access to the project, or a repository that commits `.agenthub/`, could otherwise turn the archive into a symlink and
141
+ * have hub.db (PII) moved out of the project, or the `.gitignore` into one that truncates any file it names.
142
+ */
143
+ export function archiveProblem(root: string, stateDir: string): string | undefined {
144
+ const uid = process.getuid?.();
145
+ const own = (path: string) => { const st = lstatSync(path); return st.isDirectory() && (uid === undefined || st.uid === uid); };
146
+ for (const dir of [join(root, ".agenthub"), stateDir]) if (!own(dir)) return `${dir} is not a directory of this user`;
147
+ const archive = join(root, ".agenthub", "archive");
148
+ if (lstatSync(archive, { throwIfNoEntry: false }) && !own(archive)) return `${archive} is not a directory of this user (a symlink, or another owner's)`;
149
+ const ignore = lstatSync(join(archive, ".gitignore"), { throwIfNoEntry: false });
150
+ if (ignore && !ignore.isFile()) return `${join(archive, ".gitignore")} is not a regular file`;
151
+ return undefined;
152
+ }
153
+
154
+ /**
155
+ * Full reset, first step: the state directory moves to `.agenthub/archive/state-<UTC time>/` (0700). Nothing is
156
+ * deleted; moving the archive back restores it. Only the default `<root>/.agenthub/state` qualifies (the caller checks).
157
+ * The rename is the last step: when this throws, nothing was moved.
158
+ */
159
+ export function archiveState(root: string, stateDir: string, now = new Date()): string {
160
+ const problem = archiveProblem(root, stateDir);
161
+ if (problem) throw new Error(problem);
162
+ const archive = join(root, ".agenthub", "archive");
163
+ if (!lstatSync(archive, { throwIfNoEntry: false })) mkdirSync(archive, { mode: 0o700 });
164
+ // hub.db holds task text, PII included: keep the archive out of git whatever the project's .gitignore says.
165
+ // The local worker's denylist (src/local/deny.ts) keeps it out of its tools, sandbox and memory capture.
166
+ // Verified, not only created: an edited, emptied or hard-linked one is replaced by a rename, never written through.
167
+ // Turn snapshots exclude the archive as well.
168
+ const ignore = join(archive, ".gitignore");
169
+ const current = lstatSync(ignore, { throwIfNoEntry: false });
170
+ if (!current || current.nlink > 1 || readFileSync(ignore, "utf8") !== "*\n") {
171
+ const temporary = join(archive, `.gitignore.${randomUUID()}.tmp`);
172
+ try { writeFileSync(temporary, "*\n", { flag: "wx", mode: 0o600 }); renameSync(temporary, ignore); }
173
+ finally { rmSync(temporary, { force: true }); }
174
+ }
175
+ const target = join(archive, `state-${now.toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z")}`);
176
+ if (lstatSync(target, { throwIfNoEntry: false })) throw new Error(`${target} already exists`);
177
+ chmodSync(stateDir, 0o700);
178
+ // ponytail: checked, then renamed by path; a symlink swapped in for .agenthub/archive in the microseconds between
179
+ // still wins. Rename relative to a held directory descriptor (renameat) if Bun ever exposes one.
180
+ renameSync(stateDir, target);
181
+ return target;
182
+ }
183
+
184
+ /**
185
+ * Full reset, second step: a new state directory with only the archive's `project.json`, so the project id and
186
+ * registration stay. Something else may have created the directory meanwhile (the status line tee, 0755).
187
+ */
188
+ export function startState(stateDir: string, archived: string): void {
189
+ mkdirSync(stateDir, { recursive: true, mode: 0o700 });
190
+ const st = lstatSync(stateDir);
191
+ if (!st.isDirectory() || st.uid !== (process.getuid?.() ?? st.uid)) throw new Error(`${stateDir} was re-created as something other than a directory of this user`);
192
+ chmodSync(stateDir, 0o700);
193
+ if (existsSync(join(archived, "project.json"))) copyFileSync(join(archived, "project.json"), join(stateDir, "project.json"));
194
+ }
@@ -1,8 +1,8 @@
1
1
  import type { ContextView } from "../hub/context-window.ts";
2
2
  // What `ahub status` prints for a peer and for a model backend, kept pure so it can be checked.
3
3
 
4
- export interface PeerRow { context?: ContextView; state?: string; queued?: number; queuedImportant?: number; needsReview?: number; heldBy?: string; holdNote?: string; liveAccepted?: string[]; oldestQueuedAt?: number; attached?: boolean; paused?: string; servedBy?: string; requestedModel?: string }
5
- export interface BackendRow { kind?: string; alias?: string; state?: string; active?: number; requestedModel?: string; actualModel?: string; provider?: string }
4
+ export interface PeerRow { permissionMode?: string; context?: ContextView; state?: string; queued?: number; queuedImportant?: number; needsReview?: number; heldBy?: string; holdNote?: string; liveAccepted?: string[]; oldestQueuedAt?: number; attached?: boolean; toolsOnly?: string; paused?: string; servedBy?: string; requestedModel?: string }
5
+ export interface BackendRow { kind?: string; alias?: string; state?: string; active?: number; requestedModel?: string; actualModel?: string; provider?: string; coolingUntil?: string; failures?: number; failingUntil?: string }
6
6
 
7
7
  /** Aliases are already namespaced ("dgx/coding"); only an alias that is not gets its kind in front of it. */
8
8
  export function backendLabel(backend: BackendRow): string {
@@ -11,8 +11,9 @@ export function backendLabel(backend: BackendRow): string {
11
11
  return alias.startsWith(`${kind}/`) ? alias : `${kind}/${alias}`;
12
12
  }
13
13
 
14
- export function contextLine(reading: ContextView): string {
15
- return `context ${reading.used === null ? "unknown" : `${Math.round(reading.used * 100)}%`} (${reading.freshness}${reading.source ? `, ${reading.source}` : ""}${reading.measuredAt !== null ? `, measured ${new Date(reading.measuredAt).toISOString()}` : ""})`;
14
+ /** `measured` reads as an ISO time unless `time` says otherwise: the console uses local time, as its event headers do. */
15
+ export function contextLine(reading: ContextView, time = (at: number) => new Date(at).toISOString()): string {
16
+ return `context ${reading.used === null ? "unknown" : `${Math.round(reading.used * 100)}%`} (${reading.freshness}${reading.source ? `, ${reading.source}` : ""}${reading.measuredAt !== null ? `, measured ${time(reading.measuredAt)}` : ""})`;
16
17
  }
17
18
 
18
19
  export function peerLine(id: string, p: PeerRow): string {
@@ -24,6 +25,8 @@ export function peerLine(id: string, p: PeerRow): string {
24
25
  (p.heldBy ? ` ${p.holdNote ?? `held by needs_review ${p.heldBy}; ahub queue resolve ${p.heldBy} --action completed|retry|discard --reason <text>`}` : "") +
25
26
  (p.oldestQueuedAt !== undefined ? ` oldest ${Math.max(0, Math.floor((Date.now() - p.oldestQueuedAt) / 1000))}s` : "") +
26
27
  (p.attached === false ? " disconnected" : "") +
28
+ (p.toolsOnly ? ` ${p.toolsOnly}` : "") +
29
+ (p.permissionMode && p.permissionMode !== "ask" ? ` permission: ${p.permissionMode}` : "") +
27
30
  (p.paused ? ` (${p.paused})` : "") +
28
31
  (p.servedBy ? ` last call: ${p.servedBy}` : "") +
29
32
  // Which backend the peer asked for on its last turn: without it, telling a Pi DGX turn from an MLX one
@@ -36,5 +39,7 @@ export function backendLine(backend: BackendRow): string {
36
39
  return ` model ${backendLabel(backend)} ${backend.state ?? "unknown"} active ${backend.active ?? 0}` +
37
40
  (backend.requestedModel ? ` requested ${backend.requestedModel}` : "") +
38
41
  (backend.actualModel ? ` actual ${backend.actualModel}` : "") +
39
- (backend.provider ? ` provider ${backend.provider}` : "");
42
+ (backend.provider ? ` provider ${backend.provider}` : "") +
43
+ (backend.coolingUntil ? ` cooling down until ${backend.coolingUntil} after ${backend.failures ?? "?"} failures` : "") +
44
+ (backend.failingUntil ? ` last dispatch failed, no load moves until ${backend.failingUntil}` : "");
40
45
  }