@staix/agent-hub 0.12.23 → 0.12.24

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 (158) hide show
  1. package/CHANGELOG.md +98 -0
  2. package/README.md +8 -3
  3. package/docs/addons/claude-mem.md +24 -0
  4. package/docs/addons/codex-plugin.md +8 -0
  5. package/docs/addons/dot-admit.md +8 -0
  6. package/docs/addons/open-code-review.md +8 -0
  7. package/docs/addons/ponytail.md +23 -0
  8. package/docs/addons/ripwire.md +22 -0
  9. package/docs/agent-notes/README.md +5 -0
  10. package/docs/agent-notes/adapters.md +8 -1
  11. package/docs/agent-notes/bus.md +3 -1
  12. package/docs/agent-notes/daemon.md +7 -1
  13. package/docs/agent-notes/local-worker.md +4 -2
  14. package/docs/agent-notes/tasks.md +4 -0
  15. package/docs/agent-notes/tests.md +2 -1
  16. package/docs/bench.md +28 -3
  17. package/docs/cooperbench.md +4 -0
  18. package/docs/data-policy.md +36 -0
  19. package/docs/events.md +3 -0
  20. package/docs/gsd-collaboration-analysis.md +48 -0
  21. package/docs/memory.md +231 -0
  22. package/docs/operations.md +651 -14
  23. package/docs/plans/2026-10-11-issues-109-sequential.md +81 -0
  24. package/docs/plans/2026-10-11-routing-evaluation-proposal.md +30 -0
  25. package/docs/profiles.md +57 -0
  26. package/docs/quickstart.md +1 -1
  27. package/docs/research.md +31 -1
  28. package/docs/roles.md +85 -0
  29. package/docs/sdlc.md +170 -0
  30. package/docs/security.md +111 -4
  31. package/docs/smoke.md +282 -0
  32. package/docs/specs/2026-09-19-agent-hub-design.md +601 -7
  33. package/docs/specs/2026-10-12-backlog-implementation-plan.md +186 -0
  34. package/docs/specs/2026-10-12-office-view-design.md +212 -0
  35. package/docs/text-projection.md +19 -0
  36. package/docs/verification/2026-10-11-acp-watch-t0.md +33 -0
  37. package/docs/verification/2026-10-11-agent-config-inventory.md +20 -0
  38. package/docs/verification/2026-10-11-jcode-peer-contract.md +27 -0
  39. package/docs/verification/2026-10-13-0.12.24.md +94 -0
  40. package/docs/verification/2026-10-13-board-phone.md +31 -0
  41. package/docs/verification/2026-10-13-fsmonitor-fixture-attribution.md +19 -0
  42. package/docs/verified.json +22 -13
  43. package/package.json +2 -1
  44. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  45. package/plugins/agent-hub/ROLE-NOTICE.md +26 -0
  46. package/plugins/agent-hub/server.js +1175 -339
  47. package/scripts/check-docs.mjs +129 -0
  48. package/src/adapters/acp.ts +112 -13
  49. package/src/adapters/claude-channel.ts +48 -11
  50. package/src/adapters/codex-appserver.ts +11 -0
  51. package/src/adapters/jcode.ts +110 -0
  52. package/src/adapters/local-worker.ts +10 -7
  53. package/src/adapters/pi.ts +94 -20
  54. package/src/cli/attention.ts +60 -0
  55. package/src/cli/bench.ts +24 -5
  56. package/src/cli/board-read.ts +12 -0
  57. package/src/cli/console-state.ts +103 -17
  58. package/src/cli/console.ts +54 -8
  59. package/src/cli/facts-hook.ts +12 -4
  60. package/src/cli/help.ts +35 -9
  61. package/src/cli/identity.ts +18 -7
  62. package/src/cli/init.ts +48 -10
  63. package/src/cli/jcode-mcp.ts +58 -0
  64. package/src/cli/kimi-mcp.ts +53 -0
  65. package/src/cli/launch.ts +16 -1
  66. package/src/cli/main.ts +392 -57
  67. package/src/cli/memory.ts +193 -0
  68. package/src/cli/output.ts +45 -4
  69. package/src/cli/pet-config.ts +1 -0
  70. package/src/cli/pet.ts +83 -0
  71. package/src/cli/preview.ts +38 -5
  72. package/src/cli/status-lines.ts +1 -1
  73. package/src/cli/tail-render.ts +1 -0
  74. package/src/cli/upgrade-runtime.ts +32 -3
  75. package/src/cli/watch.ts +31 -0
  76. package/src/hub/activity.ts +53 -0
  77. package/src/hub/ask.ts +10 -5
  78. package/src/hub/bench.ts +10 -3
  79. package/src/hub/board.ts +222 -16
  80. package/src/hub/bus.ts +48 -9
  81. package/src/hub/citations.ts +7 -0
  82. package/src/hub/conductor.ts +12 -7
  83. package/src/hub/config-trust.ts +4 -2
  84. package/src/hub/crash.ts +9 -0
  85. package/src/hub/daemon.ts +910 -120
  86. package/src/hub/delivery-journal.ts +5 -0
  87. package/src/hub/envelope.ts +1 -1
  88. package/src/hub/events.ts +12 -2
  89. package/src/hub/facts.ts +26 -7
  90. package/src/hub/git.ts +8 -2
  91. package/src/hub/github-import.ts +28 -0
  92. package/src/hub/hub-tools.ts +49 -10
  93. package/src/hub/inference.ts +1 -1
  94. package/src/hub/lifecycle-schema.ts +17 -0
  95. package/src/hub/listen.ts +15 -0
  96. package/src/hub/manager.ts +12 -5
  97. package/src/hub/path-policy.ts +96 -0
  98. package/src/hub/peer-catalog.ts +28 -0
  99. package/src/hub/permission-mode.ts +19 -5
  100. package/src/hub/planning.ts +70 -0
  101. package/src/hub/profiles.ts +74 -0
  102. package/src/hub/projected-diff.ts +73 -0
  103. package/src/hub/projection-exec.ts +24 -0
  104. package/src/hub/research.ts +144 -21
  105. package/src/hub/roles.ts +123 -0
  106. package/src/hub/roster.ts +23 -0
  107. package/src/hub/routing.ts +18 -5
  108. package/src/hub/sdlc-queue.ts +112 -0
  109. package/src/hub/sdlc-release.ts +125 -0
  110. package/src/hub/sdlc.ts +250 -0
  111. package/src/hub/settings.ts +34 -15
  112. package/src/hub/shipped-roles.ts +25 -0
  113. package/src/hub/snapshots.ts +89 -15
  114. package/src/hub/start-mode.ts +7 -5
  115. package/src/hub/supervision.ts +1 -0
  116. package/src/hub/task-sweep.ts +1 -1
  117. package/src/hub/tasks.ts +892 -87
  118. package/src/hub/text-projection.ts +126 -0
  119. package/src/hub/tooling.ts +159 -0
  120. package/src/hub/ui.ts +16 -2
  121. package/src/hub/workflow.ts +71 -0
  122. package/src/local/sandbox.ts +6 -4
  123. package/src/local/tools.ts +11 -6
  124. package/src/memory/brief.ts +28 -14
  125. package/src/memory/import.ts +55 -0
  126. package/src/memory/recall.ts +84 -0
  127. package/src/memory/store.ts +750 -0
  128. package/src/memory/sync.ts +145 -0
  129. package/src/pet/NOTICE.md +10 -0
  130. package/src/pet/characters.ts +1438 -0
  131. package/src/pet/config.ts +31 -0
  132. package/src/pet/frame.ts +17 -0
  133. package/src/pet/pixels.ts +28 -0
  134. package/src/role-text.d.ts +1 -0
  135. package/src/ui/index.html +842 -46
  136. package/src/ui/needs.ts +58 -0
  137. package/src/ui/office-model.ts +440 -0
  138. package/src/ui/peer-palette.ts +16 -0
  139. package/src/ui/task-board.ts +49 -0
  140. package/src/ui/task-progress.ts +5 -1
  141. package/templates/AGENTS.block.md +3 -2
  142. package/templates/config.json +5 -1
  143. package/templates/profiles/dev.json +22 -0
  144. package/templates/profiles/general.json +26 -0
  145. package/templates/profiles/research.json +57 -0
  146. package/templates/profiles/writing.json +28 -0
  147. package/templates/roles/NOTICE.md +26 -0
  148. package/templates/roles/dev/backend-engineer.md +26 -0
  149. package/templates/roles/dev/code-reviewer.md +26 -0
  150. package/templates/roles/dev/frontend-engineer.md +26 -0
  151. package/templates/roles/dev/security-reviewer.md +26 -0
  152. package/templates/roles/dev/software-architect.md +26 -0
  153. package/templates/roles/dev/test-engineer.md +26 -0
  154. package/templates/roles/research/analyst.md +26 -0
  155. package/templates/roles/research/fact-checker.md +26 -0
  156. package/templates/roles/research/research-lead.md +26 -0
  157. package/templates/roles/research/researcher.md +26 -0
  158. package/templates/roles/research/statistician.md +26 -0
@@ -1,3 +1,6 @@
1
+ import { consoleNeeds, characterAttentionLine, attentionCommand } from "./attention.ts";
2
+ import { CHARACTERS, petFrame, petSize, petFooterRows, type CharacterId, type PetSize, type PetPose } from "../pet/frame.ts";
3
+ import { PEER_PALETTE } from "../ui/peer-palette.ts";
1
4
  import { PERMISSION_MODES, permissionBoundary } from "../hub/permission-mode.ts";
2
5
  import { taskProgress } from "../ui/task-progress.ts";
3
6
  import type { ProgressStage } from "../ui/task-progress.ts";
@@ -10,6 +13,11 @@ export interface Approval {
10
13
  export interface PermissionDefault { peer: string; mode: "never-ask"; source: string }
11
14
  export interface ConsoleEvent { text: string; peer?: string; kind?: string; tone?: Tone }
12
15
  export interface ConsoleState {
16
+ /** Older embedding callers omit choices and retain the original STAI/off toggle. */
17
+ petChoices?: readonly CharacterId[]; petCharacter?: CharacterId; petSize?: PetSize; petGlyphs?: "ascii" | "blocks";
18
+ attentionFacts?: { kind: "review" | "offline"; id: number; peer?: string }[];
19
+ attentionFingerprint?: string;
20
+ petEnabled?: boolean; petBlink?: boolean; attentionMessages?: { id?: string; peer?: string; important: boolean; since?: number }[]; storageBlocked?: boolean; recoveryNeed?: { id?: string; running: boolean; since?: number }; attentionSelection?: number;
13
21
  mode: "stream" | "panels"; panel: number; selection: number;
14
22
  /** The selected request, by id: a, d and v act on it alone, and it is cleared, never moved, when the request closes. */
15
23
  approvalId?: string;
@@ -77,9 +85,9 @@ export function terminalText(value: unknown): string {
77
85
  export type Tone = "info" | "strong" | "success" | "attention" | "failure" | "muted" | "peerClaude" | "peerCodex" | "taskKeyword" | "number" | "issueRef" | "taskRef";
78
86
  export interface Span { text: string; tone?: Tone }
79
87
  export const PALETTE: Readonly<Record<Tone, string>> = Object.freeze({
80
- info: "\x1b[36m", strong: "\x1b[1;36m", success: "\x1b[32m", attention: "\x1b[33m", failure: "\x1b[31m", muted: "\x1b[90m",
81
- peerClaude: "\x1b[94m", peerCodex: "\x1b[96m",
82
- taskKeyword: "\x1b[35m", number: "\x1b[1m", issueRef: "\x1b[4m", taskRef: "\x1b[4;35m",
88
+ info: PEER_PALETTE.hub.ansi, strong: "\x1b[1;36m", success: PEER_PALETTE.local.ansi, attention: PEER_PALETTE.pi.ansi, failure: "\x1b[31m", muted: PEER_PALETTE.other.ansi,
89
+ peerClaude: PEER_PALETTE.claude.ansi, peerCodex: PEER_PALETTE.codex.ansi,
90
+ taskKeyword: PEER_PALETTE.kimi.ansi, number: "\x1b[1m", issueRef: "\x1b[4m", taskRef: "\x1b[4;35m",
83
91
  });
84
92
  export function paint(line: Span[], color: boolean): string {
85
93
  return line.map(span => {
@@ -179,7 +187,10 @@ export function stateTone(state: string): Tone | undefined {
179
187
  }
180
188
  const span = (text: unknown, tone?: Tone): Span => ({ text: String(text ?? ""), ...(tone ? { tone } : {}) });
181
189
  /** One line of sanitized text: a newline or a tab becomes a space, as wrap() counts a tab (stringWidth("\t") is 0). */
182
- const flat = (value: unknown) => terminalText(value).replace(/[\n\t]/g, " ");
190
+ const flat = (value: unknown) => {
191
+ const text = terminalText(value), line = text.replace(/[\n\t]/g, " ");
192
+ return text.includes("\t") ? terminalText(line) : line;
193
+ };
183
194
  /**
184
195
  * Clip sanitized plain text to the width and keep the tones of the surviving prefix. Widths are measured as paint()
185
196
  * prints them: paint() sanitizes each span again, and a cut that ends a span in `[agent-hub` earns it a `> `.
@@ -313,9 +324,9 @@ const KEYS: [string, string[]][] = [
313
324
  ["Events", ["f peer filter", "g kind filter"]],
314
325
  ];
315
326
  /** The `?` key table: one group per panel, its keys kept whole and packed into the width after the group column. */
316
- export function keyTable(columns: number): Span[][] {
327
+ export function keyTable(columns: number, attention = false, characters = false): Span[][] {
317
328
  const lines: Span[][] = []; const pad = 12;
318
- for (const [group, items] of KEYS) {
329
+ for (const [group, items] of [...KEYS, ...(attention ? [[characters ? "Pet" : "Attention", ["n next need", characters ? "P next character/off" : "P STAI/off"]] as [string, string[]]] : [])]) {
319
330
  let text = ""; let head = group;
320
331
  const flush = () => { lines.push([span(head.padEnd(pad), head ? "info" : undefined), span(text)]); head = ""; text = ""; };
321
332
  for (const item of items) { if (text && pad + Bun.stringWidth(`${text} ${item}`) > columns) flush(); text = text ? `${text} ${item}` : item; }
@@ -341,7 +352,7 @@ function bound(s: ConsoleState): ConsoleState {
341
352
  if (s.requestDetail && s.requestDetail !== s.approvalId) { s.requestDetail = s.approvalId; s.detailOffset = 0; }
342
353
  return s;
343
354
  }
344
- const COMMANDS = new Set(["status", "board", "task", "review", "say", "pause", "resume", "budget", "queue", "permit", "permission", "ask", "remember", "route", "turns", "undo", "check-path", "report"]);
355
+ const COMMANDS = new Set(["status", "board", "task", "review", "say", "pause", "resume", "budget", "queue", "permit", "permission", "ask", "remember", "route", "sdlc", "turns", "undo", "check-path", "report"]);
345
356
  /** A tiny argv parser, never a shell. Quotes group arguments; backslash escapes one character. */
346
357
  export function parseConsoleCommand(input: string): string[] {
347
358
  const words: string[] = []; let word = ""; let quote = ""; let escaped = false; let started = false;
@@ -377,6 +388,7 @@ export const selectedApproval = (s: ConsoleState) => s.approvals.find(a => a.id
377
388
  /** The selected row: the Approvals panel's is the selected request (-1 when none is). */
378
389
  const selection = (s: ConsoleState) => s.panel === 2 ? s.approvals.findIndex(a => a.id === s.approvalId) : s.selection;
379
390
  export function reduceConsole(state: ConsoleState, key: string, now = Date.now()): { state: ConsoleState; effects: ConsoleEffect[] } {
391
+ const attentionKeyBlocked = !!(state.confirm || state.optionChoice || state.modeChoice || state.help || state.editing || state.input);
380
392
  // A notice answers the key before this one; one that pruning raises now stays.
381
393
  let s = pruneApprovals({ ...state, notice: "" }, now); const effects: ConsoleEffect[] = [];
382
394
  const done = () => ({ state: bound(s), effects });
@@ -438,7 +450,47 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
438
450
  } else if (!key.startsWith("\x1b")) s.input += terminalText(key).replace(/\n/g, "");
439
451
  return done();
440
452
  }
453
+ if ((key === "n" || key === "P") && attentionKeyBlocked) return done();
441
454
  if (key === "q") { effects.push({ type: "exit" }); return done(); }
455
+ if (key === "P" && !s.optionChoice && !s.help) {
456
+ const choices = s.petChoices;
457
+ if (!choices?.length) s.petEnabled = !s.petEnabled;
458
+ else if (!s.petEnabled) { s.petEnabled = true; s.petCharacter = choices[0]; }
459
+ else { const next = choices.indexOf(s.petCharacter ?? "stai") + 1; s.petEnabled = next < choices.length; if (s.petEnabled) s.petCharacter = choices[next]; }
460
+ return done();
461
+ }
462
+ if (key === "n" && s.petEnabled && !s.optionChoice && !s.help) {
463
+ const items = consoleNeeds(s, now).items;
464
+ if (!items.length) return done();
465
+ const fingerprint = JSON.stringify(items);
466
+ const prior = s.attentionFingerprint === fingerprint ? s.attentionSelection ?? -1 : -1;
467
+ const index = (prior + 1) % items.length;
468
+ s.attentionFingerprint = fingerprint;
469
+ s.attentionSelection = index;
470
+ const item = items[index]!;
471
+ let navigated = false;
472
+ if (item.kind === "approval") {
473
+ s.approvalId = String(item.id);
474
+ if (s.mode === "panels") { s.panel = 2; s.selection = s.approvals.findIndex(row => row.id === item.id); }
475
+ navigated = true;
476
+ } else if (item.kind === "delivery" && s.mode === "panels") {
477
+ const rows = s.queue.filter(row => row.state === "needs_review" || row.state === "queued");
478
+ const selected = item.id === undefined ? -1 : rows.findIndex(row => row.id === item.id);
479
+ if (selected >= 0) { s.panel = 4; s.selection = selected; navigated = true; }
480
+ } else if (item.kind === "review" && s.mode === "panels") {
481
+ const rows = panelRows({ ...s, panel: 3 });
482
+ const selected = rows.findIndex(row => row.id === item.id);
483
+ if (selected >= 0) { s.panel = 3; s.selection = selected; navigated = true; }
484
+ }
485
+ if (navigated) { s.detail = undefined; s.requestDetail = undefined; }
486
+ else {
487
+ const command = attentionCommand(item);
488
+ const text = `${item.kind}${item.peer ? ` ${item.peer}` : ""}${item.id ? ` ${item.id}` : ""}: ${command}`;
489
+ if (s.mode === "panels") notify(s, text, now, "attention");
490
+ effects.push({ type: "print", text, kind: "attention", tone: "attention" });
491
+ }
492
+ return done();
493
+ }
442
494
  if (key === "\t") { s.mode = s.mode === "panels" ? "stream" : "panels"; s.detail = undefined; s.requestDetail = undefined; return done(); }
443
495
  if (key === ":") { s.editing = true; return done(); }
444
496
  const switchPanel = (panel: number) => { s.panel = panel; s.selection = 0; s.detail = undefined; s.requestDetail = undefined; return done(); };
@@ -523,6 +575,9 @@ export function renderConsole(s: ConsoleState, columns: number, rows = 24, now =
523
575
  return renderConsoleLines(s, columns, rows, now).map(line => paint(line, false));
524
576
  }
525
577
  const NOTICE_MS = 10_000;
578
+ export function activeNotice(s: Pick<ConsoleState, "notice" | "noticeAt">, now = Date.now()): boolean {
579
+ return !!s.notice && now - (s.noticeAt ?? now) < NOTICE_MS;
580
+ }
526
581
  export const TABLES: Record<number, string[]> = {
527
582
  1: ["PEER", "STATE", "LINK", "Q", "!", "REVIEW", "PAUSE", "QUOTA", "MODEL"],
528
583
  2: ["ID", "PEER", "LEFT", "TITLE"],
@@ -539,9 +594,9 @@ export function cells(s: ConsoleState, row: any, selected: boolean, now: number,
539
594
  const paused = b?.paused ? `budget ${relative(b.paused.resetsAt, now)}` : row.paused && typeof row.paused === "object"
540
595
  ? `${row.paused.by ?? "paused"}${typeof row.paused.at === "number" ? ` ${duration(now - row.paused.at)}` : ""}`
541
596
  : row.paused === "manual" ? "user" : row.paused ? String(row.paused).split(":")[0] : "-";
542
- return [id(row.id, row.state === "offline" ? undefined : "info"), span(row.state, stateTone(row.state)),
597
+ return [id(row.id, row.state === "offline" ? undefined : "info"), span(row.rostered === false ? `${row.state} off roster` : row.state, stateTone(row.state)),
543
598
  ...(showPermissionModes(s) ? [span(row.permissionMode && row.permissionMode !== "ask" ? row.permissionMode : "-", row.permissionMode === "never-ask" ? "attention" : undefined)] : []),
544
- span(row.toolsOnly ? "tools-only" : row.attached === false ? "detached" : "attached", row.toolsOnly ? "attention" : undefined),
599
+ span(row.pullOnly && row.id === "kimi" ? "pull-only" : row.toolsOnly ? "tools-only" : row.attached === false ? "detached" : "attached", row.toolsOnly ? "attention" : undefined),
545
600
  span(count(row.queued)), span(count(row.queuedImportant), row.queuedImportant ? "attention" : undefined),
546
601
  span(count(row.needsReview), row.needsReview ? "failure" : undefined), span(paused, paused === "-" ? undefined : "attention"),
547
602
  span(b?.windows?.length ? b.windows.map((w: any) => `${w.id} ${Math.round(w.used * 100)}%${typeof w.resetsAt === "number" ? ` ${relative(w.resetsAt, now)}` : ""}`).join(", ") : "-"),
@@ -555,9 +610,9 @@ export function cells(s: ConsoleState, row: any, selected: boolean, now: number,
555
610
  const stage = stages?.get(row.id);
556
611
  const meter = stage ? "[" + "#".repeat(stage.stage - (stage.changes ? 1 : 0)) + (stage.changes ? "!" : "") + "-".repeat(4 - stage.stage) + "]" : "[----]";
557
612
  return [id(`#${row.id}`, "info"), span(`${row.state}${failed && row.state !== last.event ? ` ${last.event}` : ""}${row.ready ? " ready" : ""}${stage?.waiting ? " waiting" : ""}`, failed ? "failure" : row.ready || stage?.waiting ? "attention" : stateTone(row.state)),
558
- span(row.owner ?? "-"), span(row.reviewer ?? "-"), span(row.class ?? "-"), span(typeof at === "number" ? duration(now - at) : "-", "muted"), span(row.title), span(meter, stage?.changes ? "failure" : stateTone(row.state))];
613
+ span(row.owner ?? "-"), span(row.reviewer ?? "-"), span(row.profileClass ? `${row.profileClass} (base ${row.class})` : row.class ?? "-"), span(typeof at === "number" ? duration(now - at) : "-", "muted"), span(row.title), span(row.lifecycle ? `${meter} ${row.lifecycle.workflow ? row.lifecycle.workflow + " " : ""}SDLC ${row.lifecycle.stage}${row.lifecycle.definition ? ` (base ${row.lifecycle.definition.base}) / next: ${row.lifecycle.actor} / entry: ${row.lifecycle.entry?.length ? "blocked" : "ready"} / exit: ${row.lifecycle.exit?.length ? "blocked" : "ready"}` : ""}: ${row.lifecycle.next}` : meter, stage?.changes ? "failure" : stateTone(row.state))];
559
614
  }
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")];
615
+ if (s.panel === 4) return [id(row.id, "info"), span(row.peer), span(row.rostered === false ? `${row.state} off roster` : row.state, stateTone(row.state)), span(row.revision ?? "-"), span(typeof row.createdAt === "number" ? duration(now - row.createdAt) : "-", "muted")];
561
616
  const [header, ...body] = String(row.text).split("\n");
562
617
  // The header goes through the stream's own tokenizer with the event's tone and kind, on the same tab-flattened
563
618
  // text the stream tokenizes, so an event reads the same in both places (#309); body lines stay plain, as in the
@@ -617,6 +672,35 @@ function header(s: ConsoleState, columns: number): Span[] {
617
672
  }
618
673
  /** The label column is the hub's own: a cut title or option list is marked `(more)` there; Enter shows it whole. */
619
674
  const more = (line: Span[], text?: string): Span[] => [span("(more)".padEnd(Bun.stringWidth(line[0]!.text)), "attention"), ...(text === undefined ? line.slice(1) : [span(text)])];
675
+ export function consoleFooterRows(s: ConsoleState, columns: number, rows: number): number {
676
+ return s.petEnabled ? petFooterRows(petSize(s.petSize, columns, rows)) : 4;
677
+ }
678
+ function decoratePetFooter(s: ConsoleState, columns: number, rows: number, now: number, rule: Span[], footer: Span[][], noticeShown: boolean): Span[][] {
679
+ if (!s.petEnabled) return [rule, ...footer];
680
+ const value = consoleNeeds(s, now), character = s.petCharacter ?? "stai", size = petSize(s.petSize, columns, rows);
681
+ const tone: Tone = value.state === "blocked" ? "failure" : value.act ? "attention" : "muted";
682
+ if (size === "line") {
683
+ const text = characterAttentionLine(value, character, now, s.petBlink, true, s.petGlyphs);
684
+ return [Bun.stringWidth(text) + 4 <= columns ? [span("-".repeat(columns - Bun.stringWidth(text) - 2) + " ", "muted"), span(text.slice(0, 5)), span(text.slice(5), tone), span("-", "muted")] : rule, ...footer];
685
+ }
686
+ const pose: PetPose = s.petBlink && value.items.some(item => item.kind === "approval") && Math.floor(now / 1000) % 2 ? "waitingBlink" : value.state;
687
+ const frame = petFrame(character, pose, size, s.petGlyphs), width = size === "mini" ? 9 : 12, height = size === "mini" ? 3 : 8;
688
+ const available = columns - width - 1, caption = characterAttentionLine(value, character, now, false).slice(6);
689
+ const left: Span[][] = [[span(caption, tone)], footer[0]!, footer[1]!];
690
+ // Notices and source paths stay in their existing views, never beside an expanded character.
691
+ if (noticeShown || s.confirm?.type === "permission_default") left[2] = [span(`${s.approvals.length} approvals / ${value.act} action / ${value.watch} watch`, tone)];
692
+ for (let index = 0; index < height - 3; index++) {
693
+ const item = value.items[index];
694
+ left.push(item ? [span([item.kind, item.peer, item.id].filter(part => part !== undefined).join(" "), item.level === "act" ? "attention" : "muted")] : []);
695
+ }
696
+ const result = left.map((line, index) => {
697
+ const fitted = fitLine(line, available), text = fitted.map(cell => cell.text).join("");
698
+ const body = frame[index] ?? " ".repeat(width), accent = CHARACTERS[character].accent?.[size];
699
+ const bodySpans = accent?.row === index ? [span(body.slice(0, accent.start)), span(body.slice(accent.start, accent.start + accent.length), "attention"), span(body.slice(accent.start + accent.length))] : [span(body)];
700
+ return [...fitted, span(" ".repeat(Math.max(0, available - Bun.stringWidth(text)) + 1)), ...bodySpans];
701
+ });
702
+ return [...result, footer[2]!]; // The prompt line remains full-width and in its original terminal row.
703
+ }
620
704
  export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24, now = Date.now()): Span[][] {
621
705
  const permission = selectedApproval(s);
622
706
  let prompt = s.editing ? `: ${s.input}` : hint(s, columns);
@@ -632,14 +716,14 @@ export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24,
632
716
  if (p.permissionMode && p.permissionMode !== "ask") summary.push(span(` ${p.permissionMode}`, "attention"));
633
717
  if (p.needsReview) summary.push(span(` review${p.needsReview}`, "failure"));
634
718
  if (p.paused) summary.push(span(" paused", "attention"));
635
- if (p.toolsOnly) summary.push(span(" tools-only: ahub claude", "attention"));
719
+ if (p.toolsOnly) summary.push(span(id === "kimi" ? ` pull-only, read ${typeof p.lastInboxAt === "number" ? relative(p.lastInboxAt, now) : "never"}` : " tools-only: ahub claude", "attention"));
636
720
  }
637
721
  const quotas = Object.entries(s.budget).map(([id, b]) => `${id}:${b.windows?.map((w: any) => `${w.id} ${Math.round(w.used * 100)}%`).join("/") ?? "?"}`).join(" ");
638
722
  if (quotas) summary.push(span(`${summary.length ? " | " : ""}${quotas}`));
639
723
  const approvals = [span(plural(s.approvals.length, "approval"), s.approvals.length ? "attention" : undefined)];
640
724
  if (permission) approvals.push(span(` | ${permission.peer} ${permission.id}`, "attention"), span(` ${duration(permission.expiresAt - now)} left`, "muted"));
641
725
  else if (s.approvals.length) approvals.push(span(" | none selected", "muted"));
642
- const noticeShown = !!s.notice && now - (s.noticeAt ?? now) < NOTICE_MS;
726
+ const noticeShown = activeNotice(s, now);
643
727
  if (noticeShown) approvals.push(span(" | "), span(s.notice, s.noticeTone ?? "failure"));
644
728
  const neverPeer = s.confirm?.type === "permission_default" ? s.confirm.peer : s.confirm?.type === "command" && s.confirm.args[0] === "permission" && s.confirm.args[2] === "never-ask" ? s.confirm.args[1] : undefined;
645
729
  const defaultSource = s.confirm?.type === "permission_default" ? s.confirm.source : undefined;
@@ -655,13 +739,15 @@ export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24,
655
739
  if (!noticeShown && defaultSource === undefined && (counts || !s.approvals.length) && Bun.stringWidth(taskCount.text + " | " + approvals.map(s => s.text).join("")) <= columns) {
656
740
  footer[1] = fitLine([taskCount, span(" | "), ...approvals], columns);
657
741
  }
658
- return [rule, ...footer];
742
+ return decoratePetFooter(s, columns, rows, now, rule, footer, noticeShown);
659
743
  }
660
744
  const progress = taskProgress(s.tasks);
661
- const height = rows - 6; // header, rule, body, rule, three footer lines
745
+ const bodyHeight = rows - 2 - consoleFooterRows(s, columns, rows);
746
+ const noticeLines = noticeShown && s.petEnabled && petSize(s.petSize, columns, rows) !== "line" ? wrap(`Notice: ${s.notice}`, columns).slice(0, Math.max(0, bodyHeight - 6)).map(text => [span(text, s.noticeTone ?? "failure")]) : [];
747
+ const height = bodyHeight - noticeLines.length;
662
748
  const lines: Span[][] = [];
663
749
  if (s.confirm?.type === "permission_default") lines.push(...fieldLines([["default", `${s.confirm.peer} never-ask`], ["source", s.confirm.source], ["bounds", permissionBoundary(s.confirm.peer)], ["decision", "y enables; n cancels for this hub; Esc defers; until then ask"]], columns, now).flat());
664
- else if (s.help) lines.push(...keyTable(columns));
750
+ else if (s.help) lines.push(...keyTable(columns, !!s.petEnabled || !!s.petChoices?.length, !!s.petChoices?.length));
665
751
  else if (s.detail !== undefined || (s.requestDetail && permission)) {
666
752
  const detail = s.detail !== undefined ? detailLines(s.detail, columns, now) : fieldLines(approvalFields(permission!), columns, now).flat();
667
753
  lines.push(...detail.slice(Math.min(s.detailOffset, Math.max(0, detail.length - height))));
@@ -702,5 +788,5 @@ export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24,
702
788
  }
703
789
  }
704
790
  const body = lines.slice(0, height); while (body.length < height) body.push([]);
705
- return [header(s, columns), rule, ...body, rule, ...footer];
791
+ return [header(s, columns), rule, ...noticeLines, ...body, ...decoratePetFooter(s, columns, rows, now, rule, footer, noticeShown)];
706
792
  }
@@ -7,6 +7,11 @@ import { renderTailEvent } from "./tail-render.ts";
7
7
  import { syncPermissionDefaults, permissionDefaultText, approvalFrom, initialConsoleState, keyTable, notify, paint, panelRows, permissionText, plural, pruneApprovals, reduceConsole, renderConsoleLines, resolveColor, stateTone, streamTokens, wrapStreamTokens, terminalText, wrap } from "./console-state.ts";
8
8
  import type { ConsoleEffect, ConsoleEvent, Detail, Tone } from "./console-state.ts";
9
9
  import type { BusEvent } from "../hub/bus.ts";
10
+ import { consoleNeeds, attentionFacts } from "./attention.ts";
11
+ import { loadPetConfig } from "./pet-config.ts";
12
+ import { CHARACTER_IDS, petGlyphs, petSize } from "../pet/frame.ts";
13
+ import { activeNotice, consoleFooterRows } from "./console-state.ts";
14
+ import { characterAttentionLine } from "./attention.ts";
10
15
 
11
16
  export interface ConsoleTerminal {
12
17
  columns: number; rows: number; isTTY: boolean; isRaw?: boolean;
@@ -22,6 +27,7 @@ export interface ConsoleOptions {
22
27
  cwd: string; stateDir: string; panels?: boolean; columns?: number; rows?: number;
23
28
  terminal?: ConsoleTerminal;
24
29
  color?: boolean;
30
+ noPet?: boolean;
25
31
  /** Injected command runner must use argv, closed stdin, and return a cancellation handle. */
26
32
  runCommand?: (args: string[], output: (text: string) => void, done: () => void, columns?: number) => (() => void);
27
33
  pollMs?: number;
@@ -67,6 +73,11 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
67
73
  const { client } = options; const terminal = options.terminal ?? nativeTerminal();
68
74
  const color = options.color ?? resolveColor(undefined, { isTTY: terminal.isTTY, TERM: process.env.TERM, NO_COLOR: process.env.NO_COLOR }) === true;
69
75
  let state = initialConsoleState(!!options.panels); state.project = basename(options.cwd);
76
+ const pet = loadPetConfig(options.cwd);
77
+ state.petEnabled = pet.enabled && !options.noPet; state.petBlink = pet.blink;
78
+ state.petChoices = CHARACTER_IDS; state.petCharacter = pet.character ?? "stai";
79
+ state.petSize = pet.size; state.petGlyphs = petGlyphs(pet.glyphs);
80
+ let lastAct = 0; let lastBell = -Infinity;
70
81
  let columns = options.columns ?? terminal.columns; let rows = options.rows ?? terminal.rows;
71
82
  let active = true; let inAlternate = false; let plain = !terminal.isTTY || columns < 80 || rows < 24;
72
83
  let polling = false; let childRunning = false; let cancelChild: (() => void) | undefined;
@@ -74,6 +85,8 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
74
85
  const removers: (() => void)[] = []; const timers: ReturnType<typeof setInterval>[] = [];
75
86
  let resolveDone: () => void = () => {}; const done = new Promise<void>(resolve => { resolveDone = resolve; });
76
87
  let pendingStream: ConsoleEvent[] = []; let droppedStream = 0;
88
+ let priorStreamFooterStart: number | undefined;
89
+ let lastExpandedNotice = "";
77
90
  const notice = (text: string) => notify(state, text, Date.now());
78
91
  const safeWrite = (text: string) => terminal.write(paint([{ text }], color));
79
92
  const streamLines = (event: ConsoleEvent) => terminalText(event.text).replace(/\t/g, " ").split("\n").flatMap((text, index) => {
@@ -82,7 +95,7 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
82
95
  return index === 0 ? wrapStreamTokens(text, columns, event.tone, event.kind) : wrap(text, columns).map(line => [{ text: line }]);
83
96
  });
84
97
  const writeStream = (event: ConsoleEvent) => {
85
- terminal.write(`\x1b[${rows - 4};1H`); // the scroll region ends above the rule and the three footer lines
98
+ terminal.write(`\x1b[${rows - consoleFooterRows(state, columns, rows)};1H`); // the scroll region ends above the rule and the three footer lines
86
99
  for (const line of streamLines(event)) { terminal.write(paint(line, color)); terminal.write("\r\n"); }
87
100
  };
88
101
  /** `record: false` is for the console's own prints (the key table, a request viewed again): not events, so not in Events. */
@@ -97,15 +110,34 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
97
110
  else { pendingStream.push(event); if (pendingStream.length > 1000) { pendingStream.shift(); droppedStream++; } }
98
111
  };
99
112
  const draw = () => {
100
- if (!active || plain) return;
113
+ if (!active) return;
114
+ if (state.petEnabled) {
115
+ const attention = consoleNeeds(state, Date.now());
116
+ if (plain && attention.act !== lastAct) safeWrite(characterAttentionLine(attention, state.petCharacter) + "\n");
117
+ if (pet.bell && !lastAct && attention.act && attention.items.some(item => item.level === "act" && item.kind !== "message") && Date.now() - lastBell >= 60000) { terminal.write("\x07"); lastBell = Date.now(); }
118
+ lastAct = attention.act;
119
+ }
120
+ if (plain) return;
121
+ const returningToStream = state.mode === "stream" && inAlternate;
101
122
  if (state.mode === "panels" && !inAlternate) { terminal.write("\x1b[r\x1b[?1049h\x1b[2J"); inAlternate = true; }
102
123
  else if (state.mode === "stream" && inAlternate) {
103
- terminal.write(`\x1b[?1049l\x1b[1;${rows - 4}r`); inAlternate = false;
124
+ terminal.write(`\x1b[?1049l\x1b[1;${rows - consoleFooterRows(state, columns, rows)}r`); inAlternate = false;
125
+ }
126
+ const footerStart = rows - consoleFooterRows(state, columns, rows) + 1;
127
+ if (state.mode === "stream" && priorStreamFooterStart !== undefined && priorStreamFooterStart < footerStart) {
128
+ for (let row = priorStreamFooterStart; row < footerStart; row++) terminal.write(`\x1b[${row};1H\x1b[2K`);
129
+ }
130
+ if (state.mode === "stream") priorStreamFooterStart = footerStart;
131
+ terminal.write(state.mode === "stream" ? `\x1b[1;${rows - consoleFooterRows(state, columns, rows)}r` : "\x1b[r");
132
+ if (returningToStream) {
104
133
  if (droppedStream) writeStream({ text: `${plural(droppedStream, "older panel-mode event")} omitted from console memory; inspect hub.log for the full stream.` });
105
134
  for (const event of pendingStream) writeStream(event);
106
135
  pendingStream = []; droppedStream = 0;
107
136
  }
108
- terminal.write(state.mode === "stream" ? `\x1b[1;${rows - 4}r` : "\x1b[r");
137
+ if (state.mode === "stream" && state.petEnabled && petSize(state.petSize, columns, rows) !== "line" && activeNotice(state)) {
138
+ const key = JSON.stringify([state.noticeAt, state.notice]);
139
+ if (key !== lastExpandedNotice) { lastExpandedNotice = key; stream({ text: `Notice: ${state.notice}`, kind: "notice", tone: state.noticeTone ?? "failure" }, false); }
140
+ }
109
141
  const lines = renderConsoleLines(state, columns, rows);
110
142
  const start = rows - lines.length + 1;
111
143
  for (const [index, line] of lines.entries()) { terminal.write(`\x1b[${start + index};1H\x1b[2K`); terminal.write(paint(line, color)); }
@@ -136,7 +168,12 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
136
168
  if (!active) return;
137
169
  const [status, budget, tasks, queue, ready] = replies;
138
170
  if (status.status?.tasks && status.ok !== false) state.taskCounts = status.status.tasks;
139
- if (status.status?.peers) state.peers = status.status.peers;
171
+ if (status.status?.peers) state.peers = Object.fromEntries(Object.entries(status.status.peers).map(([peer, value]) => [peer, { ...(value as object), ...status.status.roleInfo?.[peer] }]));
172
+ if (status.status && status.ok !== false) {
173
+ state.attentionFacts = attentionFacts(status.status.attentionItems);
174
+ state.storageBlocked = !!status.status.deliveryError;
175
+ state.recoveryNeed = status.status.recovery ? { id: status.status.recovery.operationId, running: true } : undefined;
176
+ }
140
177
  if (syncPermissionDefaults(state, status.status?.permissionDefaults) && state.confirm?.type === "permission_default") stream({ text: permissionDefaultText(state.confirm), kind: "permission_default", peer: state.confirm.peer, tone: "attention" });
141
178
  if (budget.budget) state.budget = budget.budget;
142
179
  if (tasks?.ok) { const parsed = JSON.parse(tasks.text); if (Array.isArray(parsed)) {
@@ -178,7 +215,7 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
178
215
  if (!action.option) stream({ text: ` ! denial requested for permission ${action.id}`, kind: "permission", tone: "failure" });
179
216
  return;
180
217
  }
181
- if (action.type === "keys") return stream({ text: keyTable(columns).map(line => paint(line, false)).join("\n"), kind: "console" }, false);
218
+ if (action.type === "keys") return stream({ text: keyTable(columns, !!state.petEnabled || !!state.petChoices?.length, !!state.petChoices?.length).map(line => paint(line, false)).join("\n"), kind: "console" }, false);
182
219
  if (action.type === "print") return stream({ text: action.text, kind: action.kind, ...(action.tone ? { tone: action.tone } : {}) }, false);
183
220
  if (action.type === "show") {
184
221
  const result = await client.request(action.panel === 3 ? { t: "task", op: "task_show", args: { id: action.id } } : { t: "queue", op: "show", id: action.id }, 3000);
@@ -201,6 +238,7 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
201
238
  const handle = (key: string) => {
202
239
  if (!active) return;
203
240
  const result = reduceConsole(state, key); state = result.state;
241
+ state.attentionMessages = []; state.attentionSelection = key === "n" ? state.attentionSelection : undefined;
204
242
  if (state.mode === "panels" && (plain || columns < 80 || rows < 24)) { state.mode = "stream"; notice("panels need at least 80x24"); }
205
243
  for (const action of result.effects) void effect(action).catch(error => stop(`Console error: ${String(error.message)}`));
206
244
  draw();
@@ -209,7 +247,10 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
209
247
  client.onPush = msg => {
210
248
  if (!active) return;
211
249
  try {
212
- if (msg.t === "event") stream({ text: renderTailEvent(msg.e), peer: msg.e.peer ?? msg.e.env?.from, kind: msg.e.env?.kind ?? msg.e.t, tone: eventTone(msg.e) });
250
+ if (msg.t === "event") {
251
+ if (state.petEnabled && msg.e.t === "envelope" && !msg.e.dropped && msg.e.env.to?.includes("user")) state.attentionMessages = [...(state.attentionMessages ?? []), { id: msg.e.env.id, peer: msg.e.env.from, important: msg.e.env.priority === "important", since: msg.e.env.ts }].slice(-1000);
252
+ stream({ text: renderTailEvent(msg.e), peer: msg.e.peer ?? msg.e.env?.from, kind: msg.e.env?.kind ?? msg.e.t, tone: eventTone(msg.e) });
253
+ }
213
254
  else if (msg.t === "context") stream({ text: ` ${msg.peer}: ${contextLine(msg.reading, at => new Date(at).toLocaleTimeString())}`, peer: msg.peer, kind: "context" });
214
255
  else if (msg.t === "notice") stream({ text: ` * ${msg.line}`, kind: "notice" });
215
256
  else if (msg.t === "permission") {
@@ -230,6 +271,10 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
230
271
  };
231
272
  client.onClose = (_code, reason) => stop(`Hub connection closed${reason ? `: ${reason}` : ""}`);
232
273
  try {
274
+ if (state.petEnabled && pet.notice) {
275
+ if (state.mode === "panels" && !plain) notify(state, pet.notice, Date.now(), "attention");
276
+ else stream({ text: pet.notice, kind: "attention", tone: "attention" }, false);
277
+ }
233
278
  if (plain) { state.mode = "stream"; safeWrite("Console panels need a terminal of at least 80x24; showing the plain stream.\n"); }
234
279
  if (terminal.isTTY) {
235
280
  terminal.raw(true);
@@ -240,7 +285,8 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
240
285
  if (Array.from(text).length > 1 && !["\x1b[A", "\x1b[B", "\x1b[C", "\x1b[D"].includes(text)) {
241
286
  if (/^(?:\x1b\[[ABCD])+$/.test(text)) { for (const key of text.match(/\x1b\[[ABCD]/g)!) handle(key); }
242
287
  else {
243
- if (state.help && state.mode === "panels") return;
288
+ state.attentionMessages = []; state.attentionSelection = undefined;
289
+ if (state.help && state.mode === "panels") { draw(); return; }
244
290
  if (state.confirm?.type === "permission_default") { draw(); return; }
245
291
  state.confirm = undefined; state.optionChoice = undefined; state.editing = true;
246
292
  state.input += terminalText(text.replace(/\x1b\[20[01]~/g, "")).replace(/\n/g, " "); draw();
@@ -3,8 +3,9 @@
3
3
  // PreToolUse preserves facts additionalContext and adds permission decisions; other phases observe only.
4
4
  // A failed or timed-out hub decides nothing, leaving Claude's native permission rules in effect.
5
5
  import { relative, resolve, isAbsolute } from "node:path";
6
+ import { matchesPolicy } from "../hub/path-policy.ts";
6
7
  import { realPath } from "../hub/project.ts";
7
- import { isAgentConfigPath, isPermissionMode, hardLinked, isHubWritePath } from "../hub/permission-mode.ts";
8
+ import { isAgentConfigPath, isInstructionPath, isPermissionMode, hardLinked, isHubWritePath } from "../hub/permission-mode.ts";
8
9
  import { ControlClient } from "../hub/control-client.ts";
9
10
 
10
11
  /** A library call may target another hub; only the managed command hook inherits that target's native identity. */
@@ -14,7 +15,7 @@ export function nativeHookIdentity(stateDir: string, peer: string, env: NodeJS.P
14
15
  }
15
16
 
16
17
  /** Only resolving project paths outside the hub/native configuration directories receive a file grant. */
17
- export function projectFileTool(tool: unknown, input: unknown, projectRoot: unknown): boolean {
18
+ export function projectFileTool(tool: unknown, input: unknown, projectRoot: unknown, protectInstructions = false, readOnly: string[] = []): boolean {
18
19
  if (typeof projectRoot !== "string" || !isAbsolute(projectRoot) || !input || typeof input !== "object" || Array.isArray(input)) return false;
19
20
  const args = input as Record<string, unknown>;
20
21
  const key = ["Read", "Edit", "Write", "MultiEdit"].includes(String(tool)) ? "file_path" : tool === "NotebookEdit" ? "notebook_path" : ["LS", "Glob", "Grep"].includes(String(tool)) ? "path" : undefined;
@@ -24,7 +25,8 @@ export function projectFileTool(tool: unknown, input: unknown, projectRoot: unkn
24
25
  if (!optional && args[key] === undefined) return false;
25
26
  try {
26
27
  const root = realPath(projectRoot);
27
- const protectedPath = (path: string) => isAgentConfigPath(path) || isHubWritePath(path);
28
+ const mutates = ["Edit", "Write", "MultiEdit", "NotebookEdit"].includes(String(tool));
29
+ const protectedPath = (path: string) => isAgentConfigPath(path) || isHubWritePath(path) || (protectInstructions && mutates && isInstructionPath(path));
28
30
  const safe = (path: string) => {
29
31
  const requested = resolve(root, path), lexical = relative(root, requested);
30
32
  if (protectedPath(lexical)) return false;
@@ -34,6 +36,7 @@ export function projectFileTool(tool: unknown, input: unknown, projectRoot: unkn
34
36
  };
35
37
  const target = typeof args[key] === "string" ? args[key] as string : root;
36
38
  if (target.split(/[\\/]/).includes("..") || !safe(target)) return false;
39
+ if (mutates && matchesPolicy(root, target, readOnly, true)) return false;
37
40
  if (tool === "Grep" && args.glob !== undefined) {
38
41
  if (typeof args.glob !== "string" || !args.glob || args.glob.split(/[\\/]/).includes("..") || protectedPath(args.glob)) return false;
39
42
  if (/[?*[{]/.test(args.glob) || !safe(resolve(root, target, args.glob))) return false;
@@ -48,6 +51,11 @@ export function projectFileTool(tool: unknown, input: unknown, projectRoot: unkn
48
51
  } catch { return false; }
49
52
  }
50
53
 
54
+ /** Only a usable managed scoped-write grant may evaluate filesystem policy. */
55
+ export function scopedWriteBlocked(phase: string, permission: unknown, tool: unknown, evaluate: () => boolean): boolean {
56
+ return phase === "pre" && permission === "ask-when-needed" && ["Edit", "Write", "MultiEdit", "NotebookEdit"].includes(String(tool)) && !evaluate();
57
+ }
58
+
51
59
  /** The hook's stdout for one Claude Code hook input, or undefined for none. */
52
60
  export async function factsHook(stdin: string, stateDir: string, peer: string, timeoutMs = 2000): Promise<string | undefined> {
53
61
  const input = JSON.parse(stdin) as { hook_event_name?: unknown; tool_name?: unknown; tool_input?: unknown; tool_use_id?: unknown; session_id?: unknown; transcript_path?: unknown };
@@ -67,7 +75,7 @@ export async function factsHook(stdin: string, stateDir: string, peer: string, t
67
75
  }, timeoutMs);
68
76
  if (phase !== "pre" || !res?.ok) return undefined;
69
77
  const permission = isPermissionMode(res.permission) ? res.permission : "ask";
70
- const fileTool = projectFileTool(input.tool_name, input.tool_input, res.projectRoot);
78
+ const fileTool = projectFileTool(input.tool_name, input.tool_input, res.projectRoot, res.protectInstructionFiles === true);
71
79
  const allow = input.hook_event_name === "PreToolUse" && (permission === "never-ask" || permission === "ask-when-needed" && fileTool);
72
80
  const text = typeof res.text === "string" && res.text ? res.text : undefined;
73
81
  if (!allow && !text) return undefined;
package/src/cli/help.ts CHANGED
@@ -10,13 +10,17 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
10
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
- ["ahub init [--dry-run --json]", "preview or write .agenthub/config.json and the AGENTS.md marker block (drops a legacy CLAUDE.md block)"],
13
+ ["ahub init [--profile dev|research|writing|general] [--shadow] [--dry-run --json]", "preview or write .agenthub/config.json and the AGENTS.md marker block (drops a legacy CLAUDE.md block)"],
14
14
  ["ahub help [command]", "this help, or one command's entries; also --help and -h"],
15
15
  ["ahub version | --version", "print the installed version"],
16
16
  ]],
17
17
  ["Daemon and runtime", [
18
18
  ["ahub up [--unattended] [--no-console]", "start the daemon; interactive terminals enter console"],
19
- ["ahub console [--panels] [--color=auto|always|never]", "enter the human console, leaving the daemon running on exit"],
19
+ ["ahub console [--panels] [--color=auto|always|never]", "enter the human console; P cycles built-in pet characters/off; exiting leaves the daemon running"],
20
+ ["ahub console --no-pet", "turn off attention for this session; combines with --panels and --color"],
21
+ ["ahub pet [--short]", "read attention without connecting; --short prints ahub!N only when action is needed"],
22
+ ["ahub sdlc status [task] | explain <task> | export <task> | record <task> <receipt-json>", "read head-bound lifecycle gates and the next actor/action (person only)"],
23
+ ["ahub sdlc queue <owner/repo> | release <owner/repo> [live-issue...]", "read-only merge and release evidence (person only)"],
20
24
  ["ahub status [--json] [--full] [--color=auto|always|never]", "the hub, its peers, model backends and task counts"],
21
25
  ["ahub status --all [--json] [--color=auto|always|never]", "show every registered project"],
22
26
  ["ahub logs [-f]", "the last 100 lines of hub.log; -f shows the last 10 and follows it"],
@@ -35,30 +39,44 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
35
39
  ["Agents", [
36
40
  ["ahub claude [--print-command] [--unattended] [args...]", "launch Claude Code with the hub channel"],
37
41
  ["ahub codex [--print-command] [--unattended] [args...]", "start the Codex adapter and attach the TUI"],
38
- ["ahub kimi [--print-command] [--model <alias>]", "start Kimi headless under ACP"],
42
+ ["ahub kimi [--print-command] [--mode tui|headless] [--model <alias>]", "default: Kimi's own TUI, pull-only via hub_inbox; no push, steer, watchdog, console approvals or automatic quota handoff. Select headless for ACP"],
43
+ ["ahub jcode [--mode headless] [--model <alias>] [--provider-profile <name>] [--session-id <id>] [--print-command]", "optional owned Jcode daemon/ACP; native policy unmanaged, no hub sandbox"],
44
+ ["ahub qwen [--mode headless] [--model <alias>] [--session-id <id>] [--print-command]", "start optional Qwen Code 0.25.0 ACP (roster.qwen, default off)"],
39
45
  ["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
46
  ["ahub local [--route <id> | --model <id>]", "start the hub-native worker on the self-hosted models (routing.toml)"],
47
+ ["ahub role list [--group <group>] | show <id>", "read bounded catalog guidance with layer and content hash"],
48
+ ["ahub role assign <peer> <id> | clear <peer> [<id>]", "person only: add or clear catalog guidance, applied at the next peer start"],
41
49
  ["ahub stop <peer>", "human only: stop a hub-owned headless peer; end TUI agents in their terminals"],
42
50
  ["ahub models status [--json] [--color=auto|always|never]", "inspect local model state as labelled fields"],
43
51
  ["ahub models setup|start|stop", "prepare or inspect local Ollama MLX (legacy stop is explicit)"],
44
52
  ]],
45
53
  ["Messages and approvals", [
46
54
  ["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"],
55
+ ["ahub watch kimi|qwen [--no-thoughts] [--color=auto|always|never]", "person-only transient activity from a headless ACP peer; q quits, approvals stay in console"],
47
56
  ["ahub tail", "live stream of messages, states and permission requests"],
48
57
  ["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"],
49
58
  ["ahub permit <id> <option>", "answer a permission request shown by tail (\"deny\" cancels)"],
59
+ ["ahub roster [--json] [--color=auto|always|never]", "human only: every known peer, participation, start mode and retained work"],
60
+ ["ahub roster add <peer> | remove <peer> [--yes] [--now]", "human only: change live participation through settings; removal previews retained messages and task handoff"],
50
61
  ["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"],
62
+ ["ahub settings set <key> <value|inherit> [--yes] [--preview] [--now] | 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"],
52
63
  ["ahub pause|resume <peer>", "hold a peer's deliveries in its queue / release them"],
53
64
  ["ahub queue list [--peer <id>] [--json] [--color=auto|always|never]", "inspect durable deliveries"],
54
65
  ["ahub queue show <delivery-id> [--json] [--color=auto|always|never]", "inspect one delivery and revision"],
55
66
  ["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"],
56
67
  ]],
57
68
  ["Tasks and review", [
58
- ["ahub board [state | --ready] [--json] [--color=auto|always|never]", "the task board; --ready: proposed tasks with nothing left to wait for"],
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"],
69
+ ["ahub board import github --label <label> [--milestone <milestone>] [--repo <owner/repo>]", "person only: read bounded open issues into deduplicated Backlog; never writes GitHub"],
70
+ ["ahub board [state | --ready] [--columns] [--release <name> | --release-id <id>] [--json] [--color=auto|always|never]", "the task board; --ready: proposed tasks with nothing left to wait for"],
71
+ ["ahub task propose [--class <c> | <class>] <title...> [--owner <peer>] [--path <p>]... [--after <id>]... [--urgent] [--backlog] [--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"],
72
+ ["ahub backlog list | add <class> <title...> | plan <id> <revision> <fields-json> <reason...> | pin <id> <revision> true|false <reason...> | undo <id> <revision>", "person only: stage and plan work, preserve pins/frozen scope and refuse stale edits"],
73
+ ["ahub release new <name> <goal> <order> | list | show <name> | plan <name> <revision> <dates-json> | activate|freeze|ship|drop <name> <revision>", "person only: planned UTC millisecond dates and phased scope; ship records a time after approval, never tags or publishes"],
74
+ ["ahub backlog split <id> <revision> <parts-json> <reason...>", "person only: stage2..8 child prerequisites; original task retains integration, undo refuses used or changed children"],
75
+ ["ahub backlog pull-ready <id> <revision> <reason...>", "person only: reserve an eligible WIP slot before routing; pending unowned work can be undone"],
76
+ ["ahub backlog pull|return <id>", "person only: pull backlog into To Do or return ownerless proposed work; --backlog on task propose stages unoffered work"],
60
77
  ["ahub task show|escalate <id>", "full task with history (PII text included) / hand it to the next peer in escalate_to"],
61
- ["ahub task assign <id> <peer>", "give a task to a peer yourself; a task that still waits is reserved for that peer instead"],
78
+ ["ahub task role <id> <role|none>", "person only: change bounded catalog guidance on an open task; routing and authority are unchanged"],
79
+ ["ahub task assign <id> <peer> [--role owner|reviewer]", "name the owner (default) or reviewer; a task that still waits reserves its owner"],
62
80
  ["ahub task label <id> ok|regressed|reverted|incomplete|wrong|abandoned", "your later verdict on an approved task, kept in the research records (research must be on)"],
63
81
  ["ahub review <id> approved|changes_requested [note...] [--unmet <item>]...", "give a review verdict on a task"],
64
82
  ["ahub route explain <id>", "why a task went where it went, its reserved owner included"],
@@ -79,21 +97,29 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
79
97
  ["ahub report [--since 7d|<iso>] [--by task] [--json] [--color=auto|always|never]", "turns, tokens, messages, overlaps and task events per period"],
80
98
  ["ahub export [--since 7d|<iso>]", "structured events (events.jsonl) as JSON lines; never message bodies"],
81
99
  ["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"],
100
+ ["ahub research addons [--since 30d] [--all] [--json]", "approved tasks with each add-on (claude-mem, ponytail, ripwire, ...) on against off, with intervals; association, not cause"],
82
101
  ["ahub research export [--format jsonl|csv] [--since 30d] [--all]", "the research records for outside analysis: ids, counts and tokens, never text"],
83
102
  ["ahub research backfill", "build this project's research records once from events.jsonl"],
84
- ["ahub bench run <suite.json> --arm <label> [--repeat N] [--tasks a,b]", "run a benchmark suite against the peers attached here, in a project kept for benchmarks (it resets the work tree)"],
103
+ ["ahub bench run <suite.json> --arm <label> [--repeat N] [--tasks a,b] [--addon <name>=on|off]... [--memory off|claude-mem|native|both]", "run a benchmark suite against the peers attached here, in a project kept for benchmarks (it resets the work tree); --addon and --memory declare the arm's add-on and memory state and refuse a setup that differs"],
85
104
  ["ahub bench list|status", "benchmark runs on this machine / the one running now"],
86
105
  ["ahub bench report <run> [--json]", "pass rate, first pass, rework, tokens and wall time per task and overall"],
87
106
  ["ahub bench compare <run|arm>... [--suite <name>] [--mixed] [--json]", "compare arms of one suite with 95% bootstrap intervals; inconclusive below 5 counted attempts per arm"],
88
107
  ["ahub bench export [--format jsonl|csv]", "benchmark records for outside analysis: ids, outcomes and counts, never suite text"],
89
108
  ["ahub check-path <file> [--peer <id>]", "other owners' open tasks that claim or changed a file"],
109
+ ["ahub memory [list] [--tier short|long] [--kind <kind>] [--peer <p>] [--machine <id>] [--since <iso>] [--limit N] [--json]", "the hub's own memory for this project: decisions, contracts, fails and findings, short- and long-term (docs/memory.md)"],
110
+ ["ahub memory search <words> | show <id> | graph <id|node> [--hops 1|2] [--json]", "find entries, read one with its provenance, or list its graph neighbourhood"],
111
+ ["ahub memory promote|demote|forget <id> | supersede <old> <new> | rebuild", "a person's changes to memory: move an entry between tiers, forget it, mark it superseded, or rebuild the index from the logs"],
112
+ ["ahub memory sync [--remote <private git url>] | machine [--label <name>]", "sync hub memory with your other machines through a private git remote (a person's), or show or name this machine"],
113
+ ["ahub memory import claude-mem [--db <path>] [--limit N] | suggestions | confirm|reject <id>", "copy the hub's notes from claude-mem into hub memory (a person's); review the model's suggested edges"],
114
+ ["ahub goal [list] [--all] | show <id> [--json]", "long-term tasks: state, which machine holds each and until when, linked board tasks"],
115
+ ["ahub goal new <title> [--milestone <text>]... [--depends <id>]... [--shared] | link <id> <task> | state <id> <state> | done <id> | take <id> [--force]", "a person's changes to long-term tasks; take --force voids other machines' claims"],
90
116
  ]],
91
117
  ["Hooks", [
92
118
  ["ahub check-path --hook", "check-path as a Claude Code PreToolUse hook (templates/claude-hooks.json); never blocks"],
93
119
  ["ahub facts --hook", "turn-free facts as a Claude Code PreToolUse, PostToolUse and Stop hook (issue #108); never blocks"],
94
120
  ]],
95
121
  ["Dashboard", [
96
- ["ahub ui [--no-open]", "open the local dashboard (or print its one-time link)"],
122
+ ["ahub ui [--no-open]", "open the local dashboard with configured attention character (or print its one-time link)"],
97
123
  ["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"],
98
124
  ["ahub ui --all [--no-open]", "open the unified project dashboard"],
99
125
  ["ahub ui --all --stop", "stop only the dashboard manager"],