@staix/agent-hub 0.12.22 → 0.12.23

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 (78) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +16 -8
  3. package/docs/agent-notes/adapters.md +10 -5
  4. package/docs/agent-notes/benchmarks.md +3 -0
  5. package/docs/agent-notes/budget.md +4 -0
  6. package/docs/agent-notes/daemon.md +8 -1
  7. package/docs/agent-notes/local-worker.md +3 -2
  8. package/docs/agent-notes/tasks.md +2 -1
  9. package/docs/agent-notes/tests.md +2 -0
  10. package/docs/events.md +7 -3
  11. package/docs/operations.md +232 -26
  12. package/docs/quickstart.md +9 -4
  13. package/docs/security.md +15 -6
  14. package/docs/smoke.md +72 -5
  15. package/docs/specs/2026-09-19-agent-hub-design.md +77 -10
  16. package/docs/verification/2026-10-10-cli-output-phase1.md +309 -0
  17. package/docs/verification/2026-10-10-cli-output-phase2.md +672 -0
  18. package/docs/verification/2026-10-11-0.12.23.md +90 -0
  19. package/docs/verified.json +25 -16
  20. package/package.json +1 -1
  21. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  22. package/plugins/agent-hub/server.js +14 -3
  23. package/src/adapters/acp-quota.ts +13 -0
  24. package/src/adapters/acp.ts +95 -13
  25. package/src/adapters/codex-appserver.ts +43 -13
  26. package/src/cli/bench.ts +2 -1
  27. package/src/cli/console-state.ts +28 -22
  28. package/src/cli/console.ts +20 -10
  29. package/src/cli/help.ts +19 -14
  30. package/src/cli/identity.ts +2 -2
  31. package/src/cli/init.ts +1 -1
  32. package/src/cli/main.ts +272 -159
  33. package/src/cli/output.ts +363 -0
  34. package/src/cli/preview.ts +2 -1
  35. package/src/cli/reset.ts +3 -0
  36. package/src/cli/status-lines.ts +0 -28
  37. package/src/cli/terminal-recovery.ts +27 -1
  38. package/src/cli/upgrade-runtime.ts +24 -7
  39. package/src/cli/upgrade.ts +23 -5
  40. package/src/hub/board.ts +2 -0
  41. package/src/hub/budget.ts +39 -7
  42. package/src/hub/bus.ts +10 -11
  43. package/src/hub/child-process.ts +11 -0
  44. package/src/hub/conductor.ts +23 -11
  45. package/src/hub/config-trust.ts +16 -5
  46. package/src/hub/conflicts.ts +2 -0
  47. package/src/hub/context-window.ts +8 -1
  48. package/src/hub/crash.ts +2 -1
  49. package/src/hub/daemon.ts +338 -23
  50. package/src/hub/db-timeout.ts +3 -0
  51. package/src/hub/delivery-journal.ts +2 -1
  52. package/src/hub/envelope.ts +1 -1
  53. package/src/hub/events.ts +2 -0
  54. package/src/hub/execution-budget.ts +2 -0
  55. package/src/hub/facts.ts +5 -4
  56. package/src/hub/git.ts +32 -0
  57. package/src/hub/hub-tools.ts +1 -1
  58. package/src/hub/lifecycle.ts +10 -4
  59. package/src/hub/manager.ts +18 -5
  60. package/src/hub/peers.ts +5 -3
  61. package/src/hub/permission-mode.ts +14 -0
  62. package/src/hub/project.ts +2 -1
  63. package/src/hub/recovery-error.ts +31 -0
  64. package/src/hub/recovery-store.ts +6 -5
  65. package/src/hub/registry.ts +2 -1
  66. package/src/hub/routing.ts +114 -13
  67. package/src/hub/settings.ts +392 -0
  68. package/src/hub/snapshots.ts +4 -2
  69. package/src/hub/start-mode.ts +48 -0
  70. package/src/hub/tasks.ts +129 -31
  71. package/src/hub/terminal-open.ts +64 -0
  72. package/src/hub/ui.ts +25 -13
  73. package/src/local/deny.ts +29 -7
  74. package/src/local/sandbox.ts +44 -6
  75. package/src/memory/recall.ts +2 -2
  76. package/src/models/mlx.ts +2 -1
  77. package/src/pi/tool-receipts.ts +2 -1
  78. package/src/ui/index.html +157 -10
package/src/cli/bench.ts CHANGED
@@ -5,6 +5,7 @@ import { join } from "node:path";
5
5
  import { childEnv, stopOwnedProcess, trackGroup } from "../hub/child-process.ts";
6
6
  import { ControlClient } from "../hub/control-client.ts";
7
7
  import { readEvents } from "../hub/events.ts";
8
+ import { hubGitArgv, hubGitEnv } from "../hub/git.ts";
8
9
  import { taskRecords } from "../hub/research.ts";
9
10
  import { appendBench, BENCH_SCHEMA, parseSuite, suiteHash, type Attempt, type AttemptError, type AttemptMetrics, type Fingerprint, type Outcome, type SuiteTask } from "../hub/bench.ts";
10
11
  import { hubHome, realPath } from "../hub/project.ts";
@@ -30,7 +31,7 @@ const fail = (message: string): never => { throw new Error(message); };
30
31
 
31
32
  /** Commands git runs without a shell; the arguments are ours, never suite text. */
32
33
  async function git(cwd: string, args: string[]): Promise<{ code: number; out: string }> {
33
- const p = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "ignore" });
34
+ const p = Bun.spawn(hubGitArgv(args), { cwd, stdout: "pipe", stderr: "ignore", env: hubGitEnv() });
34
35
  const [code, out] = await Promise.all([p.exited, new Response(p.stdout).text()]);
35
36
  return { code, out: out.trim() };
36
37
  }
@@ -1,4 +1,4 @@
1
- import { PERMISSION_MODES } from "../hub/permission-mode.ts";
1
+ import { PERMISSION_MODES, permissionBoundary } from "../hub/permission-mode.ts";
2
2
  import { taskProgress } from "../ui/task-progress.ts";
3
3
  import type { ProgressStage } from "../ui/task-progress.ts";
4
4
  import { sanitize } from "../hub/envelope.ts";
@@ -35,12 +35,7 @@ export function initialConsoleState(panels = false): ConsoleState {
35
35
  return { mode: panels ? "panels" : "stream", panel: 1, selection: 0,
36
36
  input: "", editing: false, history: [], historyIndex: 0, approvals: [], events: [], peers: {}, budget: {}, tasks: [], tasksKnown: false, queue: [], permissionDefaults: [], permissionDefaultsHandled: [], detailOffset: 0, help: false, notice: "" };
37
37
  }
38
- /** Approval frequency does not create a common sandbox across native peers. */
39
- export function permissionBoundary(peer: string): string {
40
- if (peer === "pi" || peer === "local") return `${peer}: inside hub sandbox, path guard and denylist`;
41
- if (peer === "claude" || peer === "codex") return `${peer}: native vendor bounds; no hub sandbox`;
42
- return `${peer}: runs its own tools; NO hub sandbox`;
43
- }
38
+ export { permissionBoundary };
44
39
  function permissionModeRefusal(peer: string, mode: unknown): string | undefined {
45
40
  if (mode === "unmanaged") return `${peer} permission mode is unmanaged; use its native controls`;
46
41
  if (mode === "unverified") return `${peer} permission mode is unverified; start with ahub ${peer} and run a tool first`;
@@ -174,7 +169,7 @@ export function resolveColor(flag: string | undefined, env: { isTTY: boolean; TE
174
169
  if (flag === "always") return true;
175
170
  if (flag === "never") return false;
176
171
  if (flag === undefined || flag === "auto") return env.isTTY && env.TERM !== "dumb" && !env.NO_COLOR;
177
- return new Error("usage: ahub console [--panels] [--color=auto|always|never]");
172
+ return new Error("usage: ahub <command> [--color=auto|always|never]");
178
173
  }
179
174
  export function stateTone(state: string): Tone | undefined {
180
175
  if (state === "idle" || state === "approved") return "success";
@@ -189,7 +184,7 @@ const flat = (value: unknown) => terminalText(value).replace(/[\n\t]/g, " ");
189
184
  * Clip sanitized plain text to the width and keep the tones of the surviving prefix. Widths are measured as paint()
190
185
  * prints them: paint() sanitizes each span again, and a cut that ends a span in `[agent-hub` earns it a `> `.
191
186
  */
192
- function fitLine(line: Span[], columns: number): Span[] {
187
+ export function fitLine(line: Span[], columns: number): Span[] {
193
188
  const clean = line.map(s => ({ ...s, text: flat(s.text) }));
194
189
  if (Bun.stringWidth(clean.map(s => s.text).join("")) <= columns) return clean;
195
190
  const marker = ".".repeat(Math.max(0, Math.min(3, columns)));
@@ -216,7 +211,9 @@ export function fit(value: unknown, columns: number): string {
216
211
  export function wrap(value: unknown, columns: number, hang = 4): string[] {
217
212
  const lines: string[] = [];
218
213
  const half = Math.floor(columns / 2);
219
- const width = (text: string) => Bun.stringWidth(sanitize(text));
214
+ // A bare chunk ending in "--- from" becomes a header marker when table padding follows it.
215
+ const quoteChunk = (text: string) => sanitize(text + " ").slice(0, -1);
216
+ const width = (text: string) => Bun.stringWidth(quoteChunk(text));
220
217
  for (const part of terminalText(value).replace(/\t/g, " ").split("\n")) {
221
218
  const indent = /^\s*/.exec(part)![0];
222
219
  const lead = Math.min(Bun.stringWidth(indent), half);
@@ -224,7 +221,7 @@ export function wrap(value: unknown, columns: number, hang = 4): string[] {
224
221
  const first = lines.length;
225
222
  let start = " ".repeat(lead);
226
223
  let line = start;
227
- const push = () => { lines.push(sanitize(line.trimEnd())); line = start = pad; };
224
+ const push = () => { lines.push(quoteChunk(line.trimEnd())); line = start = pad; };
228
225
  for (const token of part.slice(indent.length).match(/\s+|\S+/g) ?? []) {
229
226
  if (width(line + token) <= columns) { line += token; continue; }
230
227
  if (line !== start) push();
@@ -234,7 +231,7 @@ export function wrap(value: unknown, columns: number, hang = 4): string[] {
234
231
  line += char;
235
232
  }
236
233
  }
237
- if (line !== start || lines.length === first) lines.push(sanitize(line.trimEnd()));
234
+ if (line !== start || lines.length === first) lines.push(quoteChunk(line.trimEnd()));
238
235
  }
239
236
  return lines;
240
237
  }
@@ -308,7 +305,7 @@ export function permissionText(a: Pick<Approval, "id" | "peer" | "title" | "opti
308
305
  const KEYS: [string, string[]][] = [
309
306
  ["Everywhere", ["Tab stream/panels", ": command", "? keys", "Esc back", "q quit"]],
310
307
  ["Command", ["Enter run", "Esc cancel", "Up/Down history", "Ctrl-U clear"]],
311
- ["Panels", ["1-5 panel", "j/k or arrows move", "Enter view", "j/k scroll a view"]],
308
+ ["Panels", ["1-5 panel", "Left/Right panel", "j/k or Up/Down move", "Enter view", "j/k scroll a view"]],
312
309
  ["Approvals", ["a allow (then y)", "d deny", "v view", "[ ] select"]],
313
310
  ["Peers", ["p pause", "r resume", "m permission mode"]],
314
311
  ["Tasks", ["a assign (then y)", "r review"]],
@@ -444,7 +441,11 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
444
441
  if (key === "q") { effects.push({ type: "exit" }); return done(); }
445
442
  if (key === "\t") { s.mode = s.mode === "panels" ? "stream" : "panels"; s.detail = undefined; s.requestDetail = undefined; return done(); }
446
443
  if (key === ":") { s.editing = true; return done(); }
447
- if (s.mode === "panels" && /^[1-5]$/.test(key) && !s.optionChoice) { s.panel = Number(key); s.selection = 0; s.detail = undefined; s.requestDetail = undefined; return done(); }
444
+ const switchPanel = (panel: number) => { s.panel = panel; s.selection = 0; s.detail = undefined; s.requestDetail = undefined; return done(); };
445
+ if (s.mode === "panels" && /^[1-5]$/.test(key) && !s.optionChoice) return switchPanel(Number(key));
446
+ // Right and Left are the digit of the next or previous tab, wrapping at both ends (#309); the same guards as the
447
+ // digits apply (the confirm, mode-choice, key-table and editing branches above returned already).
448
+ if (s.mode === "panels" && (key === "\x1b[C" || key === "\x1b[D") && !s.optionChoice) return switchPanel((s.panel + (key === "\x1b[C" ? 0 : 3)) % 5 + 1);
448
449
  if (key === "?") {
449
450
  if (s.mode === "stream") effects.push({ type: "keys" });
450
451
  else s.help = !s.help;
@@ -522,7 +523,7 @@ export function renderConsole(s: ConsoleState, columns: number, rows = 24, now =
522
523
  return renderConsoleLines(s, columns, rows, now).map(line => paint(line, false));
523
524
  }
524
525
  const NOTICE_MS = 10_000;
525
- const TABLES: Record<number, string[]> = {
526
+ export const TABLES: Record<number, string[]> = {
526
527
  1: ["PEER", "STATE", "LINK", "Q", "!", "REVIEW", "PAUSE", "QUOTA", "MODEL"],
527
528
  2: ["ID", "PEER", "LEFT", "TITLE"],
528
529
  3: ["ID", "STATE", "OWNER", "REVIEWER", "CLASS", "AGE", "TITLE", "STAGE"],
@@ -531,7 +532,7 @@ const TABLES: Record<number, string[]> = {
531
532
  const showPermissionModes = (s: ConsoleState) => Object.values(s.peers).some(peer => peer.permissionMode && peer.permissionMode !== "ask");
532
533
  const count = (n: unknown) => typeof n === "number" && n ? String(n) : "-";
533
534
  /** One span per column of a panel row; zero counters and unknown values read `-`. */
534
- function cells(s: ConsoleState, row: any, selected: boolean, now: number, stages?: Map<number, ProgressStage>): Span[] {
535
+ export function cells(s: ConsoleState, row: any, selected: boolean, now: number, stages?: Map<number, ProgressStage>): Span[] {
535
536
  const id = (text: unknown, tone: Tone | undefined) => span(text, tone && (selected ? "strong" : tone));
536
537
  if (s.panel === 1) {
537
538
  const b = s.budget[row.id];
@@ -558,19 +559,22 @@ function cells(s: ConsoleState, row: any, selected: boolean, now: number, stages
558
559
  }
559
560
  if (s.panel === 4) return [id(row.id, "info"), span(row.peer), span(row.state, stateTone(row.state)), span(row.revision ?? "-"), span(typeof row.createdAt === "number" ? duration(now - row.createdAt) : "-", "muted")];
560
561
  const [header, ...body] = String(row.text).split("\n");
561
- return [span(header, row.tone), ...(body.length ? [span("\n" + body.join("\n"))] : [])];
562
+ // The header goes through the stream's own tokenizer with the event's tone and kind, on the same tab-flattened
563
+ // text the stream tokenizes, so an event reads the same in both places (#309); body lines stay plain, as in the
564
+ // stream. Colour off concatenates to the same bytes.
565
+ return [...streamTokens((header ?? "").replace(/\t/g, " "), row.tone, row.kind), ...(body.length ? [span("\n" + body.join("\n"))] : [])];
562
566
  }
563
567
  /**
564
568
  * A header row, then the rows, each column starting at the same place on every row: two spaces apart, as wide as its
565
569
  * widest cell or header (Bun.stringWidth, at most a third of the width, wider cells cut with the marker); the last column
566
- * takes the rest.
570
+ * takes the rest. One-shot callers may supply widths for already-wrapped cells; console defaults stay unchanged.
567
571
  */
568
- function table(head: string[], rows: Span[][], columns: number): Span[][] {
572
+ export function table(head: string[], rows: Span[][], columns: number, explicitWidths?: readonly number[]): Span[][] {
569
573
  const cap = Math.max(24, Math.floor(columns / 3));
570
574
  const width = (cell: Span | undefined) => Bun.stringWidth(flat(cell?.text));
571
- const widths = head.map((h, i) => Math.min(cap, rows.reduce((max, row) => Math.max(max, width(row[i])), Bun.stringWidth(h))));
575
+ const widths = explicitWidths ? [...explicitWidths] : head.map((h, i) => Math.min(cap, rows.reduce((max, row) => Math.max(max, width(row[i])), Bun.stringWidth(h))));
572
576
  const flexible = head.includes("STAGE") ? head.indexOf("TITLE") : head.length - 1;
573
- if (head.includes("STAGE")) {
577
+ if (!explicitWidths && head.includes("STAGE")) {
574
578
  // Keep the original columns adjacent; stage meters occupy a fixed right-edge column.
575
579
  const room = () => columns - 2 - widths.reduce((sum, value, index) => sum + (index === flexible ? 0 : value), 0) - 2 * (head.length - 1);
576
580
  for (const key of ["CLASS", "OWNER", "REVIEWER", "AGE", "STATE"]) {
@@ -579,7 +583,9 @@ function table(head: string[], rows: Span[][], columns: number): Span[][] {
579
583
  }
580
584
  widths[flexible] = Math.max(0, room());
581
585
  }
582
- return [head.map(h => span(h, "info")), ...rows].map(row => row.map((cell, i) => {
586
+ const headerCells = explicitWidths ? head.map((h, i) => wrap(h, Math.max(1, widths[i]!), 0)) : undefined;
587
+ const headers = headerCells ? Array.from({ length: Math.max(...headerCells.map(cell => cell.length)) }, (_, n) => head.map((_, i) => span(headerCells[i]![n] ?? "", "info"))) : [head.map(h => span(h, "info"))];
588
+ return [...headers, ...rows].map(row => row.map((cell, i) => {
583
589
  if (i === head.length - 1 && flexible === i) return cell;
584
590
  const text = fit(cell.text, widths[i]!);
585
591
  return { ...cell, text: text + " ".repeat(Math.max(0, widths[i]! - Bun.stringWidth(text)) + (i === head.length - 1 ? 0 : 2)) };
@@ -23,7 +23,7 @@ export interface ConsoleOptions {
23
23
  terminal?: ConsoleTerminal;
24
24
  color?: boolean;
25
25
  /** Injected command runner must use argv, closed stdin, and return a cancellation handle. */
26
- runCommand?: (args: string[], output: (text: string) => void, done: () => void) => (() => void);
26
+ runCommand?: (args: string[], output: (text: string) => void, done: () => void, columns?: number) => (() => void);
27
27
  pollMs?: number;
28
28
  }
29
29
  export const RESTORE_CONSOLE = "\x1b[?1049l\x1b[r\x1b[0m\x1b[?25h";
@@ -76,13 +76,18 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
76
76
  let pendingStream: ConsoleEvent[] = []; let droppedStream = 0;
77
77
  const notice = (text: string) => notify(state, text, Date.now());
78
78
  const safeWrite = (text: string) => terminal.write(paint([{ text }], color));
79
- const streamLines = (event: ConsoleEvent) => terminalText(event.text).split("\n").flatMap((text, index) => index === 0 ? wrapStreamTokens(text, columns, event.tone, event.kind) : wrap(text, columns).map(line => [{ text: line }]));
79
+ const streamLines = (event: ConsoleEvent) => terminalText(event.text).replace(/\t/g, " ").split("\n").flatMap((text, index) => {
80
+ // Tabs are flattened before measuring, as wrap() does; commands already wrap cells at their own offsets.
81
+ if (event.kind === "command" && Bun.stringWidth(text) <= columns) return [[{ text }]];
82
+ return index === 0 ? wrapStreamTokens(text, columns, event.tone, event.kind) : wrap(text, columns).map(line => [{ text: line }]);
83
+ });
80
84
  const writeStream = (event: ConsoleEvent) => {
81
85
  terminal.write(`\x1b[${rows - 4};1H`); // the scroll region ends above the rule and the three footer lines
82
86
  for (const line of streamLines(event)) { terminal.write(paint(line, color)); terminal.write("\r\n"); }
83
87
  };
84
88
  /** `record: false` is for the console's own prints (the key table, a request viewed again): not events, so not in Events. */
85
89
  const stream = (event: ConsoleEvent, record = true) => {
90
+ if (event.kind === "command") event = { ...event, text: terminalText(event.text).replace(/\t/g, " ") };
86
91
  if (record) state.events = [...state.events, { ...event, text: terminalText(event.text) }].slice(-1000);
87
92
  if (plain) {
88
93
  const [header, ...body] = terminalText(event.text).split("\n");
@@ -145,9 +150,10 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
145
150
  } catch (error) { if (active) { notice(String((error as Error).message)); draw(); } }
146
151
  finally { polling = false; }
147
152
  };
148
- const run = options.runCommand ?? ((args, output, finished) => {
153
+ const run = options.runCommand ?? ((args, output, finished, commandColumns) => {
149
154
  const child = Bun.spawn([process.execPath, fileURLToPath(new URL("./main.ts", import.meta.url)), "--project", options.cwd, ...args], {
150
155
  cwd: options.cwd, stdin: "ignore", stdout: "pipe", stderr: "pipe",
156
+ env: { ...process.env, COLUMNS: String(commandColumns), NO_COLOR: "1" },
151
157
  });
152
158
  const read = async (source: ReadableStream<Uint8Array>) => {
153
159
  const decoder = new TextDecoder(); const reader = source.getReader();
@@ -188,8 +194,8 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
188
194
  stream({ text: `> ${action.args.join(" ")}`, kind: "command" });
189
195
  try {
190
196
  // Output lines sit at column 4, as message bodies do: what a command prints can carry peer text, and columns 0 and 2 are the hub's.
191
- const output = (text: string) => { if (active) { stream({ text: text.replace(/\n$/, "").replace(/^/gm, " "), kind: "command" }); draw(); } };
192
- cancelChild = run(action.args, output, () => { childRunning = false; cancelChild = undefined; if (active) void refresh(); });
197
+ const output = (text: string) => { if (active) { stream({ text: terminalText(text).replace(/\t/g, " ").replace(/\n$/, "").replace(/^/gm, " "), kind: "command" }); draw(); } };
198
+ cancelChild = run(action.args, output, () => { childRunning = false; cancelChild = undefined; if (active) void refresh(); }, Math.max(1, columns - 4));
193
199
  } catch (error) { childRunning = false; notice(String((error as Error).message)); draw(); }
194
200
  };
195
201
  const handle = (key: string) => {
@@ -229,12 +235,16 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
229
235
  terminal.raw(true);
230
236
  removers.push(terminal.onData(text => {
231
237
  // A pasted chunk can only edit text, never approve, confirm or run commands. Bracketed-paste delimiters are stripped.
232
- // Under the modal key table it does nothing at all.
238
+ // Under the modal key table it does nothing at all. A read holding nothing but arrow sequences is that many
239
+ // key presses (key repeat, a slow link), not a paste (#309): each arrow reaches the reducer on its own.
233
240
  if (Array.from(text).length > 1 && !["\x1b[A", "\x1b[B", "\x1b[C", "\x1b[D"].includes(text)) {
234
- if (state.help && state.mode === "panels") return;
235
- if (state.confirm?.type === "permission_default") { draw(); return; }
236
- state.confirm = undefined; state.optionChoice = undefined; state.editing = true;
237
- state.input += terminalText(text.replace(/\x1b\[20[01]~/g, "")).replace(/\n/g, " "); draw();
241
+ if (/^(?:\x1b\[[ABCD])+$/.test(text)) { for (const key of text.match(/\x1b\[[ABCD]/g)!) handle(key); }
242
+ else {
243
+ if (state.help && state.mode === "panels") return;
244
+ if (state.confirm?.type === "permission_default") { draw(); return; }
245
+ state.confirm = undefined; state.optionChoice = undefined; state.editing = true;
246
+ state.input += terminalText(text.replace(/\x1b\[20[01]~/g, "")).replace(/\n/g, " "); draw();
247
+ }
238
248
  } else handle(text);
239
249
  }));
240
250
  removers.push(terminal.onResize(() => {
package/src/cli/help.ts CHANGED
@@ -7,7 +7,7 @@ export interface HelpEntry { section: string; usage: string; description: string
7
7
  const SECTIONS: [string, [usage: string, description: string][]][] = [
8
8
  ["Projects", [
9
9
  ["ahub --project <path|id> <command>", "select a repository or worktree explicitly"],
10
- ["ahub projects [--json]", "list registered projects and live status"],
10
+ ["ahub projects [--json] [--color=auto|always|never]", "list registered projects and live status"],
11
11
  ["ahub projects remove <id>", "forget a stopped registration (keeps project files)"],
12
12
  ["ahub setup [--yes]", "install or update the Claude Code channel plugin from this package, then run doctor"],
13
13
  ["ahub init [--dry-run --json]", "preview or write .agenthub/config.json and the AGENTS.md marker block (drops a legacy CLAUDE.md block)"],
@@ -17,11 +17,11 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
17
17
  ["Daemon and runtime", [
18
18
  ["ahub up [--unattended] [--no-console]", "start the daemon; interactive terminals enter console"],
19
19
  ["ahub console [--panels] [--color=auto|always|never]", "enter the human console, leaving the daemon running on exit"],
20
- ["ahub status", "the hub, its peers, model backends and task counts"],
21
- ["ahub status --all", "show every registered project"],
20
+ ["ahub status [--json] [--full] [--color=auto|always|never]", "the hub, its peers, model backends and task counts"],
21
+ ["ahub status --all [--json] [--color=auto|always|never]", "show every registered project"],
22
22
  ["ahub logs [-f]", "the last 100 lines of hub.log; -f shows the last 10 and follows it"],
23
- ["ahub doctor", "check the tools, daemon, plugin, gateway, models and memory worker this project uses"],
24
- ["ahub doctor --orphans [--kill]", "list registrations whose project root is gone; --kill stops their daemons (SIGTERM, then SIGKILL) only after the process identity checks out"],
23
+ ["ahub doctor [--json] [--color=auto|always|never]", "check the tools, daemon, plugin, gateway, models and memory worker this project uses"],
24
+ ["ahub doctor --orphans [--kill] [--json] [--color=auto|always|never]", "list registrations whose project root is gone; --kill stops their daemons (SIGTERM, then SIGKILL) only after the process identity checks out"],
25
25
  ["ahub kill", "stop this project's hub"],
26
26
  ["ahub reset [--all] [--yes]", "list, then with --yes stop the hub and discard every queued and needs_review delivery, clear holds and pauses and drop session pointers; --all moves the state directory to .agenthub/archive instead"],
27
27
  ["ahub restart [--dry-run] [--yes]", "recover this project's runtime; on a terminal without a flag it reviews the plan with you, follows the progress and offers the next steps"],
@@ -36,23 +36,26 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
36
36
  ["ahub claude [--print-command] [--unattended] [args...]", "launch Claude Code with the hub channel"],
37
37
  ["ahub codex [--print-command] [--unattended] [args...]", "start the Codex adapter and attach the TUI"],
38
38
  ["ahub kimi [--print-command] [--model <alias>]", "start Kimi headless under ACP"],
39
- ["ahub pi [--print-command] [--mode headless|tui] [--backend auto|dgx|mlx] [--session-id <id>] [--session-file <path>]", "start Pi"],
39
+ ["ahub pi [--print-command] [--mode headless|tui | --headless] [--backend auto|dgx|mlx] [--session-id <id>] [--session-file <path>]", "start Pi: its TUI in this terminal unless --headless or the start mode setting (peers.pi.start_mode) says headless; without a terminal the hub opens one or says how"],
40
40
  ["ahub local [--route <id> | --model <id>]", "start the hub-native worker on the self-hosted models (routing.toml)"],
41
41
  ["ahub stop <peer>", "human only: stop a hub-owned headless peer; end TUI agents in their terminals"],
42
- ["ahub models setup|status|start|stop", "prepare or inspect local Ollama MLX (legacy stop is explicit)"],
42
+ ["ahub models status [--json] [--color=auto|always|never]", "inspect local model state as labelled fields"],
43
+ ["ahub models setup|start|stop", "prepare or inspect local Ollama MLX (legacy stop is explicit)"],
43
44
  ]],
44
45
  ["Messages and approvals", [
45
46
  ["ahub say [@peer ...] <text>", "send as the console user (no @peer = broadcast); delivered at once, start the text with [STATUS] to let it batch or [FYI] for the record only"],
46
47
  ["ahub tail", "live stream of messages, states and permission requests"],
47
48
  ["ahub permission [<peer> [ask|ask-when-needed|never-ask]] [--yes]", "human only: inspect or set a running peer mode; never-ask requires --yes; config never-ask waits for console y"],
48
49
  ["ahub permit <id> <option>", "answer a permission request shown by tail (\"deny\" cancels)"],
50
+ ["ahub settings [list] [--json] | get <key>", "human only: the settings the dashboard can change, each with its value, the file it comes from and when it applies"],
51
+ ["ahub settings set <key> <value|inherit> [--yes] [--preview] | undo", "human only: change one, written to .agenthub/config.local.json or routing.local.toml (never a tracked file); --preview shows the effect and saves nothing; undo puts back the last write"],
49
52
  ["ahub pause|resume <peer>", "hold a peer's deliveries in its queue / release them"],
50
- ["ahub queue list [--peer <id>] [--json]", "inspect durable deliveries"],
51
- ["ahub queue show <delivery-id>", "inspect one delivery and revision"],
53
+ ["ahub queue list [--peer <id>] [--json] [--color=auto|always|never]", "inspect durable deliveries"],
54
+ ["ahub queue show <delivery-id> [--json] [--color=auto|always|never]", "inspect one delivery and revision"],
52
55
  ["ahub queue resolve <delivery-id> --action completed|retry|discard --reason <text>", "release a delivery held as needs_review: mark it completed, retry it or discard it"],
53
56
  ]],
54
57
  ["Tasks and review", [
55
- ["ahub board [state | --ready]", "the task board; --ready: proposed tasks with nothing left to wait for"],
58
+ ["ahub board [state | --ready] [--json] [--color=auto|always|never]", "the task board; --ready: proposed tasks with nothing left to wait for"],
56
59
  ["ahub task propose [--class <c> | <class>] <title...> [--owner <peer>] [--path <p>]... [--after <id>]... [--urgent] [--detail <text>]", "put a task on the board; a first word that names a class is taken as the class; while a task named by --after is not approved yet, --owner reserves the task for that peer, offered to it first once it is ready; otherwise --owner assigns it now"],
57
60
  ["ahub task show|escalate <id>", "full task with history (PII text included) / hand it to the next peer in escalate_to"],
58
61
  ["ahub task assign <id> <peer>", "give a task to a peer yourself; a task that still waits is reserved for that peer instead"],
@@ -64,15 +67,16 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
64
67
  ["ahub remember <text...>", "save a note to the memory all agents share"],
65
68
  ]],
66
69
  ["Budget", [
67
- ["ahub budget", "quota windows per peer, and who is paused until when"],
70
+ ["ahub budget [--json] [--color=auto|always|never]", "quota windows per peer, and who is paused until when"],
68
71
  ["ahub budget set <peer> <0..1> [--resets-in 30m] [--window 5h|week]", "feed a reading by hand (also: test the relay)"],
69
72
  ["ahub budget resume <peer>", "override a budget pause; readings are ignored for that peer until the window resets"],
70
- ["ahub budget execution configure <config.json> | status [id] | disable <id>", "opt-in limits on model calls, tool calls, time and tokens for pi and local"],
73
+ ["ahub budget execution status [id] [--json] [--color=auto|always|never]", "read execution counters and limits"],
74
+ ["ahub budget execution configure <config.json> | disable <id>", "opt-in limits on model calls, tool calls, time and tokens for pi and local"],
71
75
  ]],
72
76
  ["History and reports", [
73
- ["ahub turns [peer] [--limit N]", "recent turns and the files each changed (a git work tree only)"],
77
+ ["ahub turns [peer] [--limit N] [--json] [--color=auto|always|never]", "recent turns and the files each changed (a git work tree only)"],
74
78
  ["ahub undo <turn> [--yes] [--context]", "put back the files a turn changed; refuses files changed since. Without --yes it only lists them; --context also drops a Codex turn from its conversation"],
75
- ["ahub report [--since 7d|<iso>] [--by task] [--json]", "turns, tokens, messages, overlaps and task events per period"],
79
+ ["ahub report [--since 7d|<iso>] [--by task] [--json] [--color=auto|always|never]", "turns, tokens, messages, overlaps and task events per period"],
76
80
  ["ahub export [--since 7d|<iso>]", "structured events (events.jsonl) as JSON lines; never message bodies"],
77
81
  ["ahub research [--since 30d] [--all] [--json]", "success, first-pass, rework and check-failure rates, tokens and wall time per approved task, from the opt-in research records"],
78
82
  ["ahub research export [--format jsonl|csv] [--since 30d] [--all]", "the research records for outside analysis: ids, counts and tokens, never text"],
@@ -90,6 +94,7 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
90
94
  ]],
91
95
  ["Dashboard", [
92
96
  ["ahub ui [--no-open]", "open the local dashboard (or print its one-time link)"],
97
+ ["ahub ui --settings [--no-open]", "human only: open it with a settings session, which for 15 minutes may change permission modes, routing and start settings"],
93
98
  ["ahub ui --all [--no-open]", "open the unified project dashboard"],
94
99
  ["ahub ui --all --stop", "stop only the dashboard manager"],
95
100
  ["ahub manager", "internal: the dashboard manager process that ahub ui --all starts"],
@@ -39,7 +39,7 @@ export function classifyPeerCommand(command: string, args: readonly string[]): P
39
39
  // #251: a run resets the bench project's tree and proposes work; reading results is harmless.
40
40
  // The subcommand is the first argument that is not --json, which the command accepts anywhere.
41
41
  if (command === "bench") return args.find((arg) => arg !== "--json") === "run" ? "console" : "allowed";
42
- if (command === "budget") return !args.length || args.every((arg) => arg === "--json") ? "allowed" : "console";
42
+ if (command === "budget") return !args.length || args.every((arg) => arg === "--json" || arg === "--full" || arg.startsWith("--color=")) ? "allowed" : "console";
43
43
  if (["claude", "codex", "kimi", "pi", "local", "pause", "resume"].includes(command)) return "conductor";
44
44
  if (["help", "version", "--version", "say", "status", "board", "report", "turns", "review", "remember", "facts", "check-path"].includes(command)) return "allowed";
45
45
  return "console";
@@ -47,7 +47,7 @@ export function classifyPeerCommand(command: string, args: readonly string[]): P
47
47
 
48
48
  /** No argument text is included: notices and refusal logs must not copy task text or credentials. */
49
49
  export function cliCommandLabel(command: string, args: readonly string[]): string {
50
- if (!["upgrade", "restart", "recovery", "recovery-run", "help", "--help", "--version", "version", "projects", "ui", "manager", "setup", "init", "daemon", "up", "console", "claude", "codex", "kimi", "pi", "local", "stop", "models", "say", "queue", "tail", "budget", "board", "task", "review", "remember", "ask", "route", "pause", "resume", "permit", "permission", "status", "logs", "export", "report", "research", "bench", "facts", "check-path", "turns", "undo", "kill", "reset", "doctor"].includes(command)) return "unknown";
50
+ if (!["upgrade", "restart", "recovery", "recovery-run", "help", "--help", "--version", "version", "projects", "ui", "manager", "setup", "init", "daemon", "up", "console", "claude", "codex", "kimi", "pi", "local", "stop", "models", "say", "queue", "tail", "budget", "board", "task", "review", "remember", "ask", "route", "pause", "resume", "permit", "permission", "settings", "status", "logs", "export", "report", "research", "bench", "facts", "check-path", "turns", "undo", "kill", "reset", "doctor"].includes(command)) return "unknown";
51
51
  if (["task", "queue", "budget", "route", "recovery", "models", "projects", "research", "bench"].includes(command)) {
52
52
  const sub = command === "bench" ? args.find((arg) => arg !== "--json") : args[0];
53
53
  if (sub && ["propose", "show", "assign", "escalate", "label", "list", "resolve", "resume", "set", "execution", "explain", "status", "abort", "dispose", "setup", "start", "stop", "remove", "export", "backfill", "run", "report", "compare"].includes(sub)) return `${command} ${sub}`;
package/src/cli/init.ts CHANGED
@@ -57,7 +57,7 @@ function changes(cwd: string): PlannedWrite[] {
57
57
  }
58
58
  const ignore = join(cwd, ".gitignore");
59
59
  let lines = existsSync(ignore) ? readFileSync(ignore, "utf8") : "";
60
- for (const entry of [".agenthub/state/", ".agenthub/config.local.json"]) {
60
+ for (const entry of [".agenthub/state/", ".agenthub/config.local.json", ".agenthub/routing.local.toml"]) {
61
61
  if (!lines.split("\n").includes(entry)) lines = `${lines}${lines && !lines.endsWith("\n") ? "\n" : ""}${entry}\n`;
62
62
  }
63
63
  write(ignore, lines, "ignore runtime state and machine-local configuration");