@staix/agent-hub 0.12.23 → 0.12.25

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 (161) hide show
  1. package/CHANGELOG.md +109 -1
  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 +10 -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 +8 -0
  15. package/docs/agent-notes/tests.md +5 -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 +666 -19
  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 +2 -2
  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 +288 -0
  32. package/docs/specs/2026-09-19-agent-hub-design.md +633 -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/verification/2026-10-14-0.12.24-audit.md +123 -0
  43. package/docs/verification/2026-10-14-0.12.25.md +27 -0
  44. package/docs/verified.json +22 -13
  45. package/package.json +2 -1
  46. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  47. package/plugins/agent-hub/ROLE-NOTICE.md +26 -0
  48. package/plugins/agent-hub/server.js +1175 -339
  49. package/scripts/check-docs.mjs +129 -0
  50. package/src/adapters/acp.ts +112 -13
  51. package/src/adapters/claude-channel.ts +48 -11
  52. package/src/adapters/codex-appserver.ts +11 -0
  53. package/src/adapters/jcode.ts +110 -0
  54. package/src/adapters/local-worker.ts +10 -7
  55. package/src/adapters/pi.ts +171 -23
  56. package/src/cli/attention.ts +60 -0
  57. package/src/cli/bench.ts +24 -5
  58. package/src/cli/board-read.ts +12 -0
  59. package/src/cli/console-state.ts +103 -17
  60. package/src/cli/console.ts +54 -8
  61. package/src/cli/facts-hook.ts +12 -4
  62. package/src/cli/help.ts +35 -9
  63. package/src/cli/identity.ts +18 -7
  64. package/src/cli/init.ts +48 -10
  65. package/src/cli/jcode-mcp.ts +58 -0
  66. package/src/cli/kimi-mcp.ts +53 -0
  67. package/src/cli/launch.ts +16 -1
  68. package/src/cli/main.ts +392 -57
  69. package/src/cli/memory.ts +193 -0
  70. package/src/cli/output.ts +45 -4
  71. package/src/cli/pet-config.ts +1 -0
  72. package/src/cli/pet.ts +83 -0
  73. package/src/cli/preview.ts +38 -5
  74. package/src/cli/status-lines.ts +1 -1
  75. package/src/cli/tail-render.ts +1 -0
  76. package/src/cli/upgrade-runtime.ts +32 -3
  77. package/src/cli/watch.ts +31 -0
  78. package/src/hub/activity.ts +53 -0
  79. package/src/hub/ask.ts +10 -5
  80. package/src/hub/bench.ts +10 -3
  81. package/src/hub/board.ts +222 -16
  82. package/src/hub/bus.ts +48 -9
  83. package/src/hub/citations.ts +7 -0
  84. package/src/hub/conductor.ts +12 -7
  85. package/src/hub/config-trust.ts +4 -2
  86. package/src/hub/crash.ts +9 -0
  87. package/src/hub/daemon.ts +914 -120
  88. package/src/hub/delivery-journal.ts +5 -0
  89. package/src/hub/envelope.ts +1 -1
  90. package/src/hub/events.ts +12 -2
  91. package/src/hub/facts.ts +26 -7
  92. package/src/hub/git.ts +8 -2
  93. package/src/hub/github-import.ts +28 -0
  94. package/src/hub/hub-tools.ts +49 -10
  95. package/src/hub/inference.ts +1 -1
  96. package/src/hub/lifecycle-schema.ts +17 -0
  97. package/src/hub/listen.ts +15 -0
  98. package/src/hub/manager.ts +12 -5
  99. package/src/hub/path-policy.ts +115 -0
  100. package/src/hub/peer-catalog.ts +28 -0
  101. package/src/hub/permission-mode.ts +19 -5
  102. package/src/hub/planning.ts +70 -0
  103. package/src/hub/profiles.ts +74 -0
  104. package/src/hub/projected-diff.ts +73 -0
  105. package/src/hub/projection-exec.ts +24 -0
  106. package/src/hub/research.ts +144 -21
  107. package/src/hub/roles.ts +123 -0
  108. package/src/hub/roster.ts +23 -0
  109. package/src/hub/routing.ts +18 -5
  110. package/src/hub/sdlc-queue.ts +112 -0
  111. package/src/hub/sdlc-release.ts +125 -0
  112. package/src/hub/sdlc.ts +250 -0
  113. package/src/hub/settings.ts +34 -15
  114. package/src/hub/shipped-roles.ts +25 -0
  115. package/src/hub/snapshots.ts +89 -15
  116. package/src/hub/start-mode.ts +7 -5
  117. package/src/hub/supervision.ts +1 -0
  118. package/src/hub/task-sweep.ts +1 -1
  119. package/src/hub/tasks.ts +905 -89
  120. package/src/hub/text-projection.ts +151 -0
  121. package/src/hub/tooling.ts +170 -0
  122. package/src/hub/ui.ts +16 -2
  123. package/src/hub/workflow.ts +71 -0
  124. package/src/local/sandbox.ts +6 -4
  125. package/src/local/tools.ts +11 -6
  126. package/src/memory/brief.ts +28 -14
  127. package/src/memory/import.ts +55 -0
  128. package/src/memory/recall.ts +84 -0
  129. package/src/memory/store.ts +757 -0
  130. package/src/memory/sync.ts +145 -0
  131. package/src/pet/NOTICE.md +10 -0
  132. package/src/pet/characters.ts +1438 -0
  133. package/src/pet/config.ts +31 -0
  134. package/src/pet/frame.ts +17 -0
  135. package/src/pet/pixels.ts +28 -0
  136. package/src/pi/extension.ts +40 -5
  137. package/src/role-text.d.ts +1 -0
  138. package/src/ui/index.html +843 -46
  139. package/src/ui/needs.ts +58 -0
  140. package/src/ui/office-model.ts +440 -0
  141. package/src/ui/peer-palette.ts +16 -0
  142. package/src/ui/task-board.ts +49 -0
  143. package/src/ui/task-progress.ts +5 -1
  144. package/templates/AGENTS.block.md +3 -2
  145. package/templates/config.json +5 -1
  146. package/templates/profiles/dev.json +22 -0
  147. package/templates/profiles/general.json +26 -0
  148. package/templates/profiles/research.json +57 -0
  149. package/templates/profiles/writing.json +28 -0
  150. package/templates/roles/NOTICE.md +26 -0
  151. package/templates/roles/dev/backend-engineer.md +26 -0
  152. package/templates/roles/dev/code-reviewer.md +26 -0
  153. package/templates/roles/dev/frontend-engineer.md +26 -0
  154. package/templates/roles/dev/security-reviewer.md +26 -0
  155. package/templates/roles/dev/software-architect.md +26 -0
  156. package/templates/roles/dev/test-engineer.md +26 -0
  157. package/templates/roles/research/analyst.md +26 -0
  158. package/templates/roles/research/fact-checker.md +26 -0
  159. package/templates/roles/research/research-lead.md +26 -0
  160. package/templates/roles/research/researcher.md +26 -0
  161. package/templates/roles/research/statistician.md +26 -0
package/src/cli/bench.ts CHANGED
@@ -7,9 +7,11 @@ import { ControlClient } from "../hub/control-client.ts";
7
7
  import { readEvents } from "../hub/events.ts";
8
8
  import { hubGitArgv, hubGitEnv } from "../hub/git.ts";
9
9
  import { taskRecords } from "../hub/research.ts";
10
- import { appendBench, BENCH_SCHEMA, parseSuite, suiteHash, type Attempt, type AttemptError, type AttemptMetrics, type Fingerprint, type Outcome, type SuiteTask } from "../hub/bench.ts";
10
+ import { appendBench, BENCH_SCHEMA, memoryMode, type MemoryMode, parseSuite, suiteHash, type Attempt, type AttemptError, type AttemptMetrics, type Fingerprint, type Outcome, type SuiteTask } from "../hub/bench.ts";
11
11
  import { hubHome, realPath } from "../hub/project.ts";
12
12
  import { processSignature } from "../pi/process-signature.ts";
13
+ import { loadConfig } from "../hub/daemon.ts";
14
+ import { addonState, detectTooling, type Addon, type AddonName } from "../hub/tooling.ts";
13
15
  import { VERSION } from "../version.ts";
14
16
 
15
17
  /** What the runner needs from the world, so tests can drive it without a terminal. */
@@ -24,8 +26,12 @@ export interface BenchIo {
24
26
  now(): number;
25
27
  sleep(ms: number): Promise<void>;
26
28
  log(line: string): void;
29
+ /** #248: the add-ons a peer works with; `detectTooling` on this machine unless a test supplies its own. */
30
+ tooling?(peer: string): Addon[] | Promise<Addon[]>;
31
+ /** #258: the memory settings the project's hub runs with; the project config unless a test supplies them. */
32
+ memoryConfig?(): { enabled: boolean; native?: boolean };
27
33
  }
28
- export interface BenchOptions { cwd: string; stateDir: string; suitePath: string; arm: string; repeat: number; only?: string[]; home?: string; pollMs?: number; verifyTimeoutMs?: number; setupTimeoutMs?: number; settleMs?: number; stopped?: () => boolean }
34
+ export interface BenchOptions { cwd: string; stateDir: string; suitePath: string; arm: string; repeat: number; addons?: Partial<Record<AddonName, "on" | "off">>; memory?: MemoryMode; only?: string[]; home?: string; pollMs?: number; verifyTimeoutMs?: number; setupTimeoutMs?: number; settleMs?: number; stopped?: () => boolean }
29
35
 
30
36
  const fail = (message: string): never => { throw new Error(message); };
31
37
 
@@ -192,12 +198,25 @@ export async function runBench(opts: BenchOptions, io: BenchIo): Promise<string>
192
198
  // ponytail: the hub's status reports a requested model for Pi only and permission modes once #242 lands; the rest
193
199
  // are recorded as absent. Reading each peer's launch settings would fill them in.
194
200
  // The status lists every peer the hub has known; an arm is the ones attached now.
195
- peers: Object.entries(status.peers ?? {}).filter(([, p]) => p.state !== "offline")
196
- .map(([id, p]) => ({ id, ...(p.requestedModel ? { model: p.requestedModel } : {}), ...(p.permissionMode ? { permissionMode: p.permissionMode } : {}) })).sort((a, b) => a.id.localeCompare(b.id)),
201
+ peers: (await Promise.all(Object.entries(status.peers ?? {}).filter(([, p]) => p.state !== "offline")
202
+ .map(async ([id, p]) => ({ id, ...(p.requestedModel ? { model: p.requestedModel } : {}), ...(p.permissionMode ? { permissionMode: p.permissionMode } : {}),
203
+ addons: await (io.tooling ?? ((peer: string) => detectTooling(peer, { cwd: opts.cwd })))(id) })))).sort((a, b) => a.id.localeCompare(b.id)),
197
204
  routingHash: existsSync(routing) ? createHash("sha256").update(readFileSync(routing)).digest("hex") : null,
198
205
  hubVersion: status.version,
199
206
  };
207
+ // #248: a declared add-on condition must hold for the attached peers before anything is recorded or reset.
208
+ const addons = Object.entries(opts.addons ?? {}) as [AddonName, "on" | "off"][];
209
+ for (const [name, want] of addons) {
210
+ const found = addonState(name, fingerprint.peers.map((p) => p.addons));
211
+ if (found !== want) throw new Error(`the arm declares ${name} ${want}, but the attached peers have it ${found}; change their setup or the declaration (nothing was run)`);
212
+ }
213
+ // #258 AC11: the memory the project's hub runs with (its config, read at hub start) must be the arm's.
214
+ // ponytail: read from the project config, not the running hub; a config edited since the hub started reads wrong.
215
+ const runsWith = opts.memory ? memoryMode((io.memoryConfig ?? (() => loadConfig(opts.cwd).memory))()) : undefined;
216
+ if (opts.memory && runsWith !== opts.memory) throw new Error(`the arm declares memory ${opts.memory}, but this project's hub runs with ${runsWith}; change memory.enabled / memory.native and restart the hub, or the declaration (nothing was run)`);
217
+ const condition = { ...(addons.length ? { addons: Object.fromEntries(addons.sort(([a], [b]) => a.localeCompare(b))) } : {}), ...(opts.memory ? { memory: opts.memory } : {}) };
200
218
  appendBench({ schema: BENCH_SCHEMA, kind: "run", run, suite: suite.name, suiteHash: suiteHash(suiteText), arm: opts.arm, fingerprint, version: VERSION,
219
+ ...(Object.keys(condition).length ? { condition } : {}),
201
220
  startedAt: new Date(io.now()).toISOString(), tasks: tasks.length, repeats: opts.repeat, order: tasks.map((t) => t.id), runner: { pid: process.pid, signature: processSignature(process.pid) ?? null } }, home);
202
221
  io.log(`bench run ${run}: ${suite.name} [${opts.arm}], ${tasks.length} task${tasks.length === 1 ? "" : "s"} x ${opts.repeat}`);
203
222
  let stopped: "timeout" | "interrupted" | "error" | undefined;
@@ -237,7 +256,7 @@ async function runAttempt(task: SuiteTask, commit: string, repeat: number, run:
237
256
  // The attempt ends when its outcome is known: the wait for its measures is not part of its time.
238
257
  const done = (outcome: Outcome, extra: { error?: AttemptError; verifyExit?: number | null; hubTask?: number | null; metrics?: AttemptMetrics | null; ended?: number; open?: boolean } = {}) => {
239
258
  const ended = extra.ended ?? io.now();
240
- const attempt: Attempt = { schema: BENCH_SCHEMA, kind: "attempt", run, task: task.id, repeat, outcome, ...(extra.error ? { error: extra.error } : {}), verifyExit: extra.verifyExit ?? null,
259
+ const attempt: Attempt = { schema: BENCH_SCHEMA, kind: "attempt", run, arm: opts.arm, task: task.id, repeat, outcome, ...(extra.error ? { error: extra.error } : {}), verifyExit: extra.verifyExit ?? null,
241
260
  hubTask: extra.hubTask ?? null, startedAt: new Date(started).toISOString(), endedAt: new Date(ended).toISOString(), ms: ended - started, metrics: extra.metrics ?? null };
242
261
  return { attempt, open: extra.open ?? false };
243
262
  };
@@ -0,0 +1,12 @@
1
+ /** Older same-protocol daemons may ignore newly added filter arguments. */
2
+ export function supportsBoardReleaseFilter(status: unknown): boolean {
3
+ if (!status || typeof status !== "object") return false;
4
+ const capability = (status as Record<string,unknown>).boardReadCapabilities;
5
+ return !!capability && typeof capability === "object" && (capability as Record<string,unknown>).releaseFilter === true;
6
+ }
7
+
8
+ export function supportsBoardColumnMetadata(status: unknown): boolean {
9
+ if (!status || typeof status !== "object") return false;
10
+ const capability = (status as Record<string,unknown>).boardReadCapabilities;
11
+ return !!capability && typeof capability === "object" && (capability as Record<string,unknown>).columnMetadata === true;
12
+ }
@@ -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;