@staix/agent-hub 0.12.22 → 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 (183) hide show
  1. package/CHANGELOG.md +117 -0
  2. package/README.md +22 -9
  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 +18 -6
  11. package/docs/agent-notes/benchmarks.md +3 -0
  12. package/docs/agent-notes/budget.md +4 -0
  13. package/docs/agent-notes/bus.md +3 -1
  14. package/docs/agent-notes/daemon.md +14 -1
  15. package/docs/agent-notes/local-worker.md +6 -3
  16. package/docs/agent-notes/tasks.md +6 -1
  17. package/docs/agent-notes/tests.md +4 -1
  18. package/docs/bench.md +28 -3
  19. package/docs/cooperbench.md +4 -0
  20. package/docs/data-policy.md +36 -0
  21. package/docs/events.md +10 -3
  22. package/docs/gsd-collaboration-analysis.md +48 -0
  23. package/docs/memory.md +231 -0
  24. package/docs/operations.md +873 -30
  25. package/docs/plans/2026-10-11-issues-109-sequential.md +81 -0
  26. package/docs/plans/2026-10-11-routing-evaluation-proposal.md +30 -0
  27. package/docs/profiles.md +57 -0
  28. package/docs/quickstart.md +9 -4
  29. package/docs/research.md +31 -1
  30. package/docs/roles.md +85 -0
  31. package/docs/sdlc.md +170 -0
  32. package/docs/security.md +124 -8
  33. package/docs/smoke.md +354 -5
  34. package/docs/specs/2026-09-19-agent-hub-design.md +676 -15
  35. package/docs/specs/2026-10-12-backlog-implementation-plan.md +186 -0
  36. package/docs/specs/2026-10-12-office-view-design.md +212 -0
  37. package/docs/text-projection.md +19 -0
  38. package/docs/verification/2026-10-10-cli-output-phase1.md +309 -0
  39. package/docs/verification/2026-10-10-cli-output-phase2.md +672 -0
  40. package/docs/verification/2026-10-11-0.12.23.md +90 -0
  41. package/docs/verification/2026-10-11-acp-watch-t0.md +33 -0
  42. package/docs/verification/2026-10-11-agent-config-inventory.md +20 -0
  43. package/docs/verification/2026-10-11-jcode-peer-contract.md +27 -0
  44. package/docs/verification/2026-10-13-0.12.24.md +94 -0
  45. package/docs/verification/2026-10-13-board-phone.md +31 -0
  46. package/docs/verification/2026-10-13-fsmonitor-fixture-attribution.md +19 -0
  47. package/docs/verified.json +34 -16
  48. package/package.json +2 -1
  49. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  50. package/plugins/agent-hub/ROLE-NOTICE.md +26 -0
  51. package/plugins/agent-hub/server.js +1108 -261
  52. package/scripts/check-docs.mjs +129 -0
  53. package/src/adapters/acp-quota.ts +13 -0
  54. package/src/adapters/acp.ts +201 -20
  55. package/src/adapters/claude-channel.ts +48 -11
  56. package/src/adapters/codex-appserver.ts +54 -13
  57. package/src/adapters/jcode.ts +110 -0
  58. package/src/adapters/local-worker.ts +10 -7
  59. package/src/adapters/pi.ts +94 -20
  60. package/src/cli/attention.ts +60 -0
  61. package/src/cli/bench.ts +26 -6
  62. package/src/cli/board-read.ts +12 -0
  63. package/src/cli/console-state.ts +131 -39
  64. package/src/cli/console.ts +73 -17
  65. package/src/cli/facts-hook.ts +12 -4
  66. package/src/cli/help.ts +52 -21
  67. package/src/cli/identity.ts +19 -8
  68. package/src/cli/init.ts +48 -10
  69. package/src/cli/jcode-mcp.ts +58 -0
  70. package/src/cli/kimi-mcp.ts +53 -0
  71. package/src/cli/launch.ts +16 -1
  72. package/src/cli/main.ts +641 -193
  73. package/src/cli/memory.ts +193 -0
  74. package/src/cli/output.ts +404 -0
  75. package/src/cli/pet-config.ts +1 -0
  76. package/src/cli/pet.ts +83 -0
  77. package/src/cli/preview.ts +40 -6
  78. package/src/cli/reset.ts +3 -0
  79. package/src/cli/status-lines.ts +1 -29
  80. package/src/cli/tail-render.ts +1 -0
  81. package/src/cli/terminal-recovery.ts +27 -1
  82. package/src/cli/upgrade-runtime.ts +56 -10
  83. package/src/cli/upgrade.ts +23 -5
  84. package/src/cli/watch.ts +31 -0
  85. package/src/hub/activity.ts +53 -0
  86. package/src/hub/ask.ts +10 -5
  87. package/src/hub/bench.ts +10 -3
  88. package/src/hub/board.ts +224 -16
  89. package/src/hub/budget.ts +39 -7
  90. package/src/hub/bus.ts +58 -20
  91. package/src/hub/child-process.ts +11 -0
  92. package/src/hub/citations.ts +7 -0
  93. package/src/hub/conductor.ts +34 -17
  94. package/src/hub/config-trust.ts +20 -7
  95. package/src/hub/conflicts.ts +2 -0
  96. package/src/hub/context-window.ts +8 -1
  97. package/src/hub/crash.ts +11 -1
  98. package/src/hub/daemon.ts +1218 -113
  99. package/src/hub/db-timeout.ts +3 -0
  100. package/src/hub/delivery-journal.ts +7 -1
  101. package/src/hub/envelope.ts +1 -1
  102. package/src/hub/events.ts +14 -2
  103. package/src/hub/execution-budget.ts +2 -0
  104. package/src/hub/facts.ts +28 -8
  105. package/src/hub/git.ts +38 -0
  106. package/src/hub/github-import.ts +28 -0
  107. package/src/hub/hub-tools.ts +49 -10
  108. package/src/hub/inference.ts +1 -1
  109. package/src/hub/lifecycle-schema.ts +17 -0
  110. package/src/hub/lifecycle.ts +10 -4
  111. package/src/hub/listen.ts +15 -0
  112. package/src/hub/manager.ts +26 -6
  113. package/src/hub/path-policy.ts +96 -0
  114. package/src/hub/peer-catalog.ts +28 -0
  115. package/src/hub/peers.ts +5 -3
  116. package/src/hub/permission-mode.ts +33 -5
  117. package/src/hub/planning.ts +70 -0
  118. package/src/hub/profiles.ts +74 -0
  119. package/src/hub/project.ts +2 -1
  120. package/src/hub/projected-diff.ts +73 -0
  121. package/src/hub/projection-exec.ts +24 -0
  122. package/src/hub/recovery-error.ts +31 -0
  123. package/src/hub/recovery-store.ts +6 -5
  124. package/src/hub/registry.ts +2 -1
  125. package/src/hub/research.ts +144 -21
  126. package/src/hub/roles.ts +123 -0
  127. package/src/hub/roster.ts +23 -0
  128. package/src/hub/routing.ts +132 -18
  129. package/src/hub/sdlc-queue.ts +112 -0
  130. package/src/hub/sdlc-release.ts +125 -0
  131. package/src/hub/sdlc.ts +250 -0
  132. package/src/hub/settings.ts +411 -0
  133. package/src/hub/shipped-roles.ts +25 -0
  134. package/src/hub/snapshots.ts +92 -16
  135. package/src/hub/start-mode.ts +50 -0
  136. package/src/hub/supervision.ts +1 -0
  137. package/src/hub/task-sweep.ts +1 -1
  138. package/src/hub/tasks.ts +1014 -111
  139. package/src/hub/terminal-open.ts +64 -0
  140. package/src/hub/text-projection.ts +126 -0
  141. package/src/hub/tooling.ts +159 -0
  142. package/src/hub/ui.ts +40 -14
  143. package/src/hub/workflow.ts +71 -0
  144. package/src/local/deny.ts +29 -7
  145. package/src/local/sandbox.ts +50 -10
  146. package/src/local/tools.ts +11 -6
  147. package/src/memory/brief.ts +28 -14
  148. package/src/memory/import.ts +55 -0
  149. package/src/memory/recall.ts +86 -2
  150. package/src/memory/store.ts +750 -0
  151. package/src/memory/sync.ts +145 -0
  152. package/src/models/mlx.ts +2 -1
  153. package/src/pet/NOTICE.md +10 -0
  154. package/src/pet/characters.ts +1438 -0
  155. package/src/pet/config.ts +31 -0
  156. package/src/pet/frame.ts +17 -0
  157. package/src/pet/pixels.ts +28 -0
  158. package/src/pi/tool-receipts.ts +2 -1
  159. package/src/role-text.d.ts +1 -0
  160. package/src/ui/index.html +983 -40
  161. package/src/ui/needs.ts +58 -0
  162. package/src/ui/office-model.ts +440 -0
  163. package/src/ui/peer-palette.ts +16 -0
  164. package/src/ui/task-board.ts +49 -0
  165. package/src/ui/task-progress.ts +5 -1
  166. package/templates/AGENTS.block.md +3 -2
  167. package/templates/config.json +5 -1
  168. package/templates/profiles/dev.json +22 -0
  169. package/templates/profiles/general.json +26 -0
  170. package/templates/profiles/research.json +57 -0
  171. package/templates/profiles/writing.json +28 -0
  172. package/templates/roles/NOTICE.md +26 -0
  173. package/templates/roles/dev/backend-engineer.md +26 -0
  174. package/templates/roles/dev/code-reviewer.md +26 -0
  175. package/templates/roles/dev/frontend-engineer.md +26 -0
  176. package/templates/roles/dev/security-reviewer.md +26 -0
  177. package/templates/roles/dev/software-architect.md +26 -0
  178. package/templates/roles/dev/test-engineer.md +26 -0
  179. package/templates/roles/research/analyst.md +26 -0
  180. package/templates/roles/research/fact-checker.md +26 -0
  181. package/templates/roles/research/research-lead.md +26 -0
  182. package/templates/roles/research/researcher.md +26 -0
  183. package/templates/roles/research/statistician.md +26 -0
@@ -1,4 +1,7 @@
1
- import { PERMISSION_MODES } from "../hub/permission-mode.ts";
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";
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";
4
7
  import { sanitize } from "../hub/envelope.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;
@@ -35,12 +43,7 @@ export function initialConsoleState(panels = false): ConsoleState {
35
43
  return { mode: panels ? "panels" : "stream", panel: 1, selection: 0,
36
44
  input: "", editing: false, history: [], historyIndex: 0, approvals: [], events: [], peers: {}, budget: {}, tasks: [], tasksKnown: false, queue: [], permissionDefaults: [], permissionDefaultsHandled: [], detailOffset: 0, help: false, notice: "" };
37
45
  }
38
- /** Approval frequency does not create a common sandbox across native peers. */
39
- export function permissionBoundary(peer: string): string {
40
- if (peer === "pi" || peer === "local") return `${peer}: inside hub sandbox, path guard and denylist`;
41
- if (peer === "claude" || peer === "codex") return `${peer}: native vendor bounds; no hub sandbox`;
42
- return `${peer}: runs its own tools; NO hub sandbox`;
43
- }
46
+ export { permissionBoundary };
44
47
  function permissionModeRefusal(peer: string, mode: unknown): string | undefined {
45
48
  if (mode === "unmanaged") return `${peer} permission mode is unmanaged; use its native controls`;
46
49
  if (mode === "unverified") return `${peer} permission mode is unverified; start with ahub ${peer} and run a tool first`;
@@ -82,9 +85,9 @@ export function terminalText(value: unknown): string {
82
85
  export type Tone = "info" | "strong" | "success" | "attention" | "failure" | "muted" | "peerClaude" | "peerCodex" | "taskKeyword" | "number" | "issueRef" | "taskRef";
83
86
  export interface Span { text: string; tone?: Tone }
84
87
  export const PALETTE: Readonly<Record<Tone, string>> = Object.freeze({
85
- info: "\x1b[36m", strong: "\x1b[1;36m", success: "\x1b[32m", attention: "\x1b[33m", failure: "\x1b[31m", muted: "\x1b[90m",
86
- peerClaude: "\x1b[94m", peerCodex: "\x1b[96m",
87
- 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",
88
91
  });
89
92
  export function paint(line: Span[], color: boolean): string {
90
93
  return line.map(span => {
@@ -174,7 +177,7 @@ export function resolveColor(flag: string | undefined, env: { isTTY: boolean; TE
174
177
  if (flag === "always") return true;
175
178
  if (flag === "never") return false;
176
179
  if (flag === undefined || flag === "auto") return env.isTTY && env.TERM !== "dumb" && !env.NO_COLOR;
177
- return new Error("usage: ahub console [--panels] [--color=auto|always|never]");
180
+ return new Error("usage: ahub <command> [--color=auto|always|never]");
178
181
  }
179
182
  export function stateTone(state: string): Tone | undefined {
180
183
  if (state === "idle" || state === "approved") return "success";
@@ -184,12 +187,15 @@ export function stateTone(state: string): Tone | undefined {
184
187
  }
185
188
  const span = (text: unknown, tone?: Tone): Span => ({ text: String(text ?? ""), ...(tone ? { tone } : {}) });
186
189
  /** One line of sanitized text: a newline or a tab becomes a space, as wrap() counts a tab (stringWidth("\t") is 0). */
187
- 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
+ };
188
194
  /**
189
195
  * Clip sanitized plain text to the width and keep the tones of the surviving prefix. Widths are measured as paint()
190
196
  * prints them: paint() sanitizes each span again, and a cut that ends a span in `[agent-hub` earns it a `> `.
191
197
  */
192
- function fitLine(line: Span[], columns: number): Span[] {
198
+ export function fitLine(line: Span[], columns: number): Span[] {
193
199
  const clean = line.map(s => ({ ...s, text: flat(s.text) }));
194
200
  if (Bun.stringWidth(clean.map(s => s.text).join("")) <= columns) return clean;
195
201
  const marker = ".".repeat(Math.max(0, Math.min(3, columns)));
@@ -216,7 +222,9 @@ export function fit(value: unknown, columns: number): string {
216
222
  export function wrap(value: unknown, columns: number, hang = 4): string[] {
217
223
  const lines: string[] = [];
218
224
  const half = Math.floor(columns / 2);
219
- const width = (text: string) => Bun.stringWidth(sanitize(text));
225
+ // A bare chunk ending in "--- from" becomes a header marker when table padding follows it.
226
+ const quoteChunk = (text: string) => sanitize(text + " ").slice(0, -1);
227
+ const width = (text: string) => Bun.stringWidth(quoteChunk(text));
220
228
  for (const part of terminalText(value).replace(/\t/g, " ").split("\n")) {
221
229
  const indent = /^\s*/.exec(part)![0];
222
230
  const lead = Math.min(Bun.stringWidth(indent), half);
@@ -224,7 +232,7 @@ export function wrap(value: unknown, columns: number, hang = 4): string[] {
224
232
  const first = lines.length;
225
233
  let start = " ".repeat(lead);
226
234
  let line = start;
227
- const push = () => { lines.push(sanitize(line.trimEnd())); line = start = pad; };
235
+ const push = () => { lines.push(quoteChunk(line.trimEnd())); line = start = pad; };
228
236
  for (const token of part.slice(indent.length).match(/\s+|\S+/g) ?? []) {
229
237
  if (width(line + token) <= columns) { line += token; continue; }
230
238
  if (line !== start) push();
@@ -234,7 +242,7 @@ export function wrap(value: unknown, columns: number, hang = 4): string[] {
234
242
  line += char;
235
243
  }
236
244
  }
237
- if (line !== start || lines.length === first) lines.push(sanitize(line.trimEnd()));
245
+ if (line !== start || lines.length === first) lines.push(quoteChunk(line.trimEnd()));
238
246
  }
239
247
  return lines;
240
248
  }
@@ -308,7 +316,7 @@ export function permissionText(a: Pick<Approval, "id" | "peer" | "title" | "opti
308
316
  const KEYS: [string, string[]][] = [
309
317
  ["Everywhere", ["Tab stream/panels", ": command", "? keys", "Esc back", "q quit"]],
310
318
  ["Command", ["Enter run", "Esc cancel", "Up/Down history", "Ctrl-U clear"]],
311
- ["Panels", ["1-5 panel", "j/k or arrows move", "Enter view", "j/k scroll a view"]],
319
+ ["Panels", ["1-5 panel", "Left/Right panel", "j/k or Up/Down move", "Enter view", "j/k scroll a view"]],
312
320
  ["Approvals", ["a allow (then y)", "d deny", "v view", "[ ] select"]],
313
321
  ["Peers", ["p pause", "r resume", "m permission mode"]],
314
322
  ["Tasks", ["a assign (then y)", "r review"]],
@@ -316,9 +324,9 @@ const KEYS: [string, string[]][] = [
316
324
  ["Events", ["f peer filter", "g kind filter"]],
317
325
  ];
318
326
  /** The `?` key table: one group per panel, its keys kept whole and packed into the width after the group column. */
319
- export function keyTable(columns: number): Span[][] {
327
+ export function keyTable(columns: number, attention = false, characters = false): Span[][] {
320
328
  const lines: Span[][] = []; const pad = 12;
321
- 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[]]] : [])]) {
322
330
  let text = ""; let head = group;
323
331
  const flush = () => { lines.push([span(head.padEnd(pad), head ? "info" : undefined), span(text)]); head = ""; text = ""; };
324
332
  for (const item of items) { if (text && pad + Bun.stringWidth(`${text} ${item}`) > columns) flush(); text = text ? `${text} ${item}` : item; }
@@ -344,7 +352,7 @@ function bound(s: ConsoleState): ConsoleState {
344
352
  if (s.requestDetail && s.requestDetail !== s.approvalId) { s.requestDetail = s.approvalId; s.detailOffset = 0; }
345
353
  return s;
346
354
  }
347
- 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"]);
348
356
  /** A tiny argv parser, never a shell. Quotes group arguments; backslash escapes one character. */
349
357
  export function parseConsoleCommand(input: string): string[] {
350
358
  const words: string[] = []; let word = ""; let quote = ""; let escaped = false; let started = false;
@@ -380,6 +388,7 @@ export const selectedApproval = (s: ConsoleState) => s.approvals.find(a => a.id
380
388
  /** The selected row: the Approvals panel's is the selected request (-1 when none is). */
381
389
  const selection = (s: ConsoleState) => s.panel === 2 ? s.approvals.findIndex(a => a.id === s.approvalId) : s.selection;
382
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);
383
392
  // A notice answers the key before this one; one that pruning raises now stays.
384
393
  let s = pruneApprovals({ ...state, notice: "" }, now); const effects: ConsoleEffect[] = [];
385
394
  const done = () => ({ state: bound(s), effects });
@@ -441,10 +450,54 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
441
450
  } else if (!key.startsWith("\x1b")) s.input += terminalText(key).replace(/\n/g, "");
442
451
  return done();
443
452
  }
453
+ if ((key === "n" || key === "P") && attentionKeyBlocked) return done();
444
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
+ }
445
494
  if (key === "\t") { s.mode = s.mode === "panels" ? "stream" : "panels"; s.detail = undefined; s.requestDetail = undefined; return done(); }
446
495
  if (key === ":") { s.editing = true; return done(); }
447
- if (s.mode === "panels" && /^[1-5]$/.test(key) && !s.optionChoice) { s.panel = Number(key); s.selection = 0; s.detail = undefined; s.requestDetail = undefined; return done(); }
496
+ const switchPanel = (panel: number) => { s.panel = panel; s.selection = 0; s.detail = undefined; s.requestDetail = undefined; return done(); };
497
+ if (s.mode === "panels" && /^[1-5]$/.test(key) && !s.optionChoice) return switchPanel(Number(key));
498
+ // Right and Left are the digit of the next or previous tab, wrapping at both ends (#309); the same guards as the
499
+ // digits apply (the confirm, mode-choice, key-table and editing branches above returned already).
500
+ if (s.mode === "panels" && (key === "\x1b[C" || key === "\x1b[D") && !s.optionChoice) return switchPanel((s.panel + (key === "\x1b[C" ? 0 : 3)) % 5 + 1);
448
501
  if (key === "?") {
449
502
  if (s.mode === "stream") effects.push({ type: "keys" });
450
503
  else s.help = !s.help;
@@ -522,7 +575,10 @@ export function renderConsole(s: ConsoleState, columns: number, rows = 24, now =
522
575
  return renderConsoleLines(s, columns, rows, now).map(line => paint(line, false));
523
576
  }
524
577
  const NOTICE_MS = 10_000;
525
- const TABLES: Record<number, string[]> = {
578
+ export function activeNotice(s: Pick<ConsoleState, "notice" | "noticeAt">, now = Date.now()): boolean {
579
+ return !!s.notice && now - (s.noticeAt ?? now) < NOTICE_MS;
580
+ }
581
+ export const TABLES: Record<number, string[]> = {
526
582
  1: ["PEER", "STATE", "LINK", "Q", "!", "REVIEW", "PAUSE", "QUOTA", "MODEL"],
527
583
  2: ["ID", "PEER", "LEFT", "TITLE"],
528
584
  3: ["ID", "STATE", "OWNER", "REVIEWER", "CLASS", "AGE", "TITLE", "STAGE"],
@@ -531,16 +587,16 @@ const TABLES: Record<number, string[]> = {
531
587
  const showPermissionModes = (s: ConsoleState) => Object.values(s.peers).some(peer => peer.permissionMode && peer.permissionMode !== "ask");
532
588
  const count = (n: unknown) => typeof n === "number" && n ? String(n) : "-";
533
589
  /** One span per column of a panel row; zero counters and unknown values read `-`. */
534
- function cells(s: ConsoleState, row: any, selected: boolean, now: number, stages?: Map<number, ProgressStage>): Span[] {
590
+ export function cells(s: ConsoleState, row: any, selected: boolean, now: number, stages?: Map<number, ProgressStage>): Span[] {
535
591
  const id = (text: unknown, tone: Tone | undefined) => span(text, tone && (selected ? "strong" : tone));
536
592
  if (s.panel === 1) {
537
593
  const b = s.budget[row.id];
538
594
  const paused = b?.paused ? `budget ${relative(b.paused.resetsAt, now)}` : row.paused && typeof row.paused === "object"
539
595
  ? `${row.paused.by ?? "paused"}${typeof row.paused.at === "number" ? ` ${duration(now - row.paused.at)}` : ""}`
540
596
  : row.paused === "manual" ? "user" : row.paused ? String(row.paused).split(":")[0] : "-";
541
- 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)),
542
598
  ...(showPermissionModes(s) ? [span(row.permissionMode && row.permissionMode !== "ask" ? row.permissionMode : "-", row.permissionMode === "never-ask" ? "attention" : undefined)] : []),
543
- 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),
544
600
  span(count(row.queued)), span(count(row.queuedImportant), row.queuedImportant ? "attention" : undefined),
545
601
  span(count(row.needsReview), row.needsReview ? "failure" : undefined), span(paused, paused === "-" ? undefined : "attention"),
546
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(", ") : "-"),
@@ -554,23 +610,26 @@ function cells(s: ConsoleState, row: any, selected: boolean, now: number, stages
554
610
  const stage = stages?.get(row.id);
555
611
  const meter = stage ? "[" + "#".repeat(stage.stage - (stage.changes ? 1 : 0)) + (stage.changes ? "!" : "") + "-".repeat(4 - stage.stage) + "]" : "[----]";
556
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)),
557
- 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))];
558
614
  }
559
- 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")];
560
616
  const [header, ...body] = String(row.text).split("\n");
561
- return [span(header, row.tone), ...(body.length ? [span("\n" + body.join("\n"))] : [])];
617
+ // The header goes through the stream's own tokenizer with the event's tone and kind, on the same tab-flattened
618
+ // text the stream tokenizes, so an event reads the same in both places (#309); body lines stay plain, as in the
619
+ // stream. Colour off concatenates to the same bytes.
620
+ return [...streamTokens((header ?? "").replace(/\t/g, " "), row.tone, row.kind), ...(body.length ? [span("\n" + body.join("\n"))] : [])];
562
621
  }
563
622
  /**
564
623
  * A header row, then the rows, each column starting at the same place on every row: two spaces apart, as wide as its
565
624
  * widest cell or header (Bun.stringWidth, at most a third of the width, wider cells cut with the marker); the last column
566
- * takes the rest.
625
+ * takes the rest. One-shot callers may supply widths for already-wrapped cells; console defaults stay unchanged.
567
626
  */
568
- function table(head: string[], rows: Span[][], columns: number): Span[][] {
627
+ export function table(head: string[], rows: Span[][], columns: number, explicitWidths?: readonly number[]): Span[][] {
569
628
  const cap = Math.max(24, Math.floor(columns / 3));
570
629
  const width = (cell: Span | undefined) => Bun.stringWidth(flat(cell?.text));
571
- const widths = head.map((h, i) => Math.min(cap, rows.reduce((max, row) => Math.max(max, width(row[i])), Bun.stringWidth(h))));
630
+ const widths = explicitWidths ? [...explicitWidths] : head.map((h, i) => Math.min(cap, rows.reduce((max, row) => Math.max(max, width(row[i])), Bun.stringWidth(h))));
572
631
  const flexible = head.includes("STAGE") ? head.indexOf("TITLE") : head.length - 1;
573
- if (head.includes("STAGE")) {
632
+ if (!explicitWidths && head.includes("STAGE")) {
574
633
  // Keep the original columns adjacent; stage meters occupy a fixed right-edge column.
575
634
  const room = () => columns - 2 - widths.reduce((sum, value, index) => sum + (index === flexible ? 0 : value), 0) - 2 * (head.length - 1);
576
635
  for (const key of ["CLASS", "OWNER", "REVIEWER", "AGE", "STATE"]) {
@@ -579,7 +638,9 @@ function table(head: string[], rows: Span[][], columns: number): Span[][] {
579
638
  }
580
639
  widths[flexible] = Math.max(0, room());
581
640
  }
582
- return [head.map(h => span(h, "info")), ...rows].map(row => row.map((cell, i) => {
641
+ const headerCells = explicitWidths ? head.map((h, i) => wrap(h, Math.max(1, widths[i]!), 0)) : undefined;
642
+ const headers = headerCells ? Array.from({ length: Math.max(...headerCells.map(cell => cell.length)) }, (_, n) => head.map((_, i) => span(headerCells[i]![n] ?? "", "info"))) : [head.map(h => span(h, "info"))];
643
+ return [...headers, ...rows].map(row => row.map((cell, i) => {
583
644
  if (i === head.length - 1 && flexible === i) return cell;
584
645
  const text = fit(cell.text, widths[i]!);
585
646
  return { ...cell, text: text + " ".repeat(Math.max(0, widths[i]! - Bun.stringWidth(text)) + (i === head.length - 1 ? 0 : 2)) };
@@ -611,6 +672,35 @@ function header(s: ConsoleState, columns: number): Span[] {
611
672
  }
612
673
  /** The label column is the hub's own: a cut title or option list is marked `(more)` there; Enter shows it whole. */
613
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
+ }
614
704
  export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24, now = Date.now()): Span[][] {
615
705
  const permission = selectedApproval(s);
616
706
  let prompt = s.editing ? `: ${s.input}` : hint(s, columns);
@@ -626,14 +716,14 @@ export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24,
626
716
  if (p.permissionMode && p.permissionMode !== "ask") summary.push(span(` ${p.permissionMode}`, "attention"));
627
717
  if (p.needsReview) summary.push(span(` review${p.needsReview}`, "failure"));
628
718
  if (p.paused) summary.push(span(" paused", "attention"));
629
- 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"));
630
720
  }
631
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(" ");
632
722
  if (quotas) summary.push(span(`${summary.length ? " | " : ""}${quotas}`));
633
723
  const approvals = [span(plural(s.approvals.length, "approval"), s.approvals.length ? "attention" : undefined)];
634
724
  if (permission) approvals.push(span(` | ${permission.peer} ${permission.id}`, "attention"), span(` ${duration(permission.expiresAt - now)} left`, "muted"));
635
725
  else if (s.approvals.length) approvals.push(span(" | none selected", "muted"));
636
- const noticeShown = !!s.notice && now - (s.noticeAt ?? now) < NOTICE_MS;
726
+ const noticeShown = activeNotice(s, now);
637
727
  if (noticeShown) approvals.push(span(" | "), span(s.notice, s.noticeTone ?? "failure"));
638
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;
639
729
  const defaultSource = s.confirm?.type === "permission_default" ? s.confirm.source : undefined;
@@ -649,13 +739,15 @@ export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24,
649
739
  if (!noticeShown && defaultSource === undefined && (counts || !s.approvals.length) && Bun.stringWidth(taskCount.text + " | " + approvals.map(s => s.text).join("")) <= columns) {
650
740
  footer[1] = fitLine([taskCount, span(" | "), ...approvals], columns);
651
741
  }
652
- return [rule, ...footer];
742
+ return decoratePetFooter(s, columns, rows, now, rule, footer, noticeShown);
653
743
  }
654
744
  const progress = taskProgress(s.tasks);
655
- 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;
656
748
  const lines: Span[][] = [];
657
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());
658
- 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));
659
751
  else if (s.detail !== undefined || (s.requestDetail && permission)) {
660
752
  const detail = s.detail !== undefined ? detailLines(s.detail, columns, now) : fieldLines(approvalFields(permission!), columns, now).flat();
661
753
  lines.push(...detail.slice(Math.min(s.detailOffset, Math.max(0, detail.length - height))));
@@ -696,5 +788,5 @@ export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24,
696
788
  }
697
789
  }
698
790
  const body = lines.slice(0, height); while (body.length < height) body.push([]);
699
- return [header(s, columns), rule, ...body, rule, ...footer];
791
+ return [header(s, columns), rule, ...noticeLines, ...body, ...decoratePetFooter(s, columns, rows, now, rule, footer, noticeShown)];
700
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,8 +27,9 @@ 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
- runCommand?: (args: string[], output: (text: string) => void, done: () => void) => (() => void);
32
+ runCommand?: (args: string[], output: (text: string) => void, done: () => void, columns?: number) => (() => void);
27
33
  pollMs?: number;
28
34
  }
29
35
  export const RESTORE_CONSOLE = "\x1b[?1049l\x1b[r\x1b[0m\x1b[?25h";
@@ -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,15 +85,22 @@ 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
- const streamLines = (event: ConsoleEvent) => terminalText(event.text).split("\n").flatMap((text, index) => index === 0 ? wrapStreamTokens(text, columns, event.tone, event.kind) : wrap(text, columns).map(line => [{ text: line }]));
92
+ const streamLines = (event: ConsoleEvent) => terminalText(event.text).replace(/\t/g, " ").split("\n").flatMap((text, index) => {
93
+ // Tabs are flattened before measuring, as wrap() does; commands already wrap cells at their own offsets.
94
+ if (event.kind === "command" && Bun.stringWidth(text) <= columns) return [[{ text }]];
95
+ return index === 0 ? wrapStreamTokens(text, columns, event.tone, event.kind) : wrap(text, columns).map(line => [{ text: line }]);
96
+ });
80
97
  const writeStream = (event: ConsoleEvent) => {
81
- 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
82
99
  for (const line of streamLines(event)) { terminal.write(paint(line, color)); terminal.write("\r\n"); }
83
100
  };
84
101
  /** `record: false` is for the console's own prints (the key table, a request viewed again): not events, so not in Events. */
85
102
  const stream = (event: ConsoleEvent, record = true) => {
103
+ if (event.kind === "command") event = { ...event, text: terminalText(event.text).replace(/\t/g, " ") };
86
104
  if (record) state.events = [...state.events, { ...event, text: terminalText(event.text) }].slice(-1000);
87
105
  if (plain) {
88
106
  const [header, ...body] = terminalText(event.text).split("\n");
@@ -92,15 +110,34 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
92
110
  else { pendingStream.push(event); if (pendingStream.length > 1000) { pendingStream.shift(); droppedStream++; } }
93
111
  };
94
112
  const draw = () => {
95
- 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;
96
122
  if (state.mode === "panels" && !inAlternate) { terminal.write("\x1b[r\x1b[?1049h\x1b[2J"); inAlternate = true; }
97
123
  else if (state.mode === "stream" && inAlternate) {
98
- 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) {
99
133
  if (droppedStream) writeStream({ text: `${plural(droppedStream, "older panel-mode event")} omitted from console memory; inspect hub.log for the full stream.` });
100
134
  for (const event of pendingStream) writeStream(event);
101
135
  pendingStream = []; droppedStream = 0;
102
136
  }
103
- 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
+ }
104
141
  const lines = renderConsoleLines(state, columns, rows);
105
142
  const start = rows - lines.length + 1;
106
143
  for (const [index, line] of lines.entries()) { terminal.write(`\x1b[${start + index};1H\x1b[2K`); terminal.write(paint(line, color)); }
@@ -131,7 +168,12 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
131
168
  if (!active) return;
132
169
  const [status, budget, tasks, queue, ready] = replies;
133
170
  if (status.status?.tasks && status.ok !== false) state.taskCounts = status.status.tasks;
134
- 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
+ }
135
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" });
136
178
  if (budget.budget) state.budget = budget.budget;
137
179
  if (tasks?.ok) { const parsed = JSON.parse(tasks.text); if (Array.isArray(parsed)) {
@@ -145,9 +187,10 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
145
187
  } catch (error) { if (active) { notice(String((error as Error).message)); draw(); } }
146
188
  finally { polling = false; }
147
189
  };
148
- const run = options.runCommand ?? ((args, output, finished) => {
190
+ const run = options.runCommand ?? ((args, output, finished, commandColumns) => {
149
191
  const child = Bun.spawn([process.execPath, fileURLToPath(new URL("./main.ts", import.meta.url)), "--project", options.cwd, ...args], {
150
192
  cwd: options.cwd, stdin: "ignore", stdout: "pipe", stderr: "pipe",
193
+ env: { ...process.env, COLUMNS: String(commandColumns), NO_COLOR: "1" },
151
194
  });
152
195
  const read = async (source: ReadableStream<Uint8Array>) => {
153
196
  const decoder = new TextDecoder(); const reader = source.getReader();
@@ -172,7 +215,7 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
172
215
  if (!action.option) stream({ text: ` ! denial requested for permission ${action.id}`, kind: "permission", tone: "failure" });
173
216
  return;
174
217
  }
175
- 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);
176
219
  if (action.type === "print") return stream({ text: action.text, kind: action.kind, ...(action.tone ? { tone: action.tone } : {}) }, false);
177
220
  if (action.type === "show") {
178
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);
@@ -188,13 +231,14 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
188
231
  stream({ text: `> ${action.args.join(" ")}`, kind: "command" });
189
232
  try {
190
233
  // Output lines sit at column 4, as message bodies do: what a command prints can carry peer text, and columns 0 and 2 are the hub's.
191
- const output = (text: string) => { if (active) { stream({ text: text.replace(/\n$/, "").replace(/^/gm, " "), kind: "command" }); draw(); } };
192
- cancelChild = run(action.args, output, () => { childRunning = false; cancelChild = undefined; if (active) void refresh(); });
234
+ const output = (text: string) => { if (active) { stream({ text: terminalText(text).replace(/\t/g, " ").replace(/\n$/, "").replace(/^/gm, " "), kind: "command" }); draw(); } };
235
+ cancelChild = run(action.args, output, () => { childRunning = false; cancelChild = undefined; if (active) void refresh(); }, Math.max(1, columns - 4));
193
236
  } catch (error) { childRunning = false; notice(String((error as Error).message)); draw(); }
194
237
  };
195
238
  const handle = (key: string) => {
196
239
  if (!active) return;
197
240
  const result = reduceConsole(state, key); state = result.state;
241
+ state.attentionMessages = []; state.attentionSelection = key === "n" ? state.attentionSelection : undefined;
198
242
  if (state.mode === "panels" && (plain || columns < 80 || rows < 24)) { state.mode = "stream"; notice("panels need at least 80x24"); }
199
243
  for (const action of result.effects) void effect(action).catch(error => stop(`Console error: ${String(error.message)}`));
200
244
  draw();
@@ -203,7 +247,10 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
203
247
  client.onPush = msg => {
204
248
  if (!active) return;
205
249
  try {
206
- 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
+ }
207
254
  else if (msg.t === "context") stream({ text: ` ${msg.peer}: ${contextLine(msg.reading, at => new Date(at).toLocaleTimeString())}`, peer: msg.peer, kind: "context" });
208
255
  else if (msg.t === "notice") stream({ text: ` * ${msg.line}`, kind: "notice" });
209
256
  else if (msg.t === "permission") {
@@ -224,17 +271,26 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
224
271
  };
225
272
  client.onClose = (_code, reason) => stop(`Hub connection closed${reason ? `: ${reason}` : ""}`);
226
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
+ }
227
278
  if (plain) { state.mode = "stream"; safeWrite("Console panels need a terminal of at least 80x24; showing the plain stream.\n"); }
228
279
  if (terminal.isTTY) {
229
280
  terminal.raw(true);
230
281
  removers.push(terminal.onData(text => {
231
282
  // A pasted chunk can only edit text, never approve, confirm or run commands. Bracketed-paste delimiters are stripped.
232
- // Under the modal key table it does nothing at all.
283
+ // Under the modal key table it does nothing at all. A read holding nothing but arrow sequences is that many
284
+ // key presses (key repeat, a slow link), not a paste (#309): each arrow reaches the reducer on its own.
233
285
  if (Array.from(text).length > 1 && !["\x1b[A", "\x1b[B", "\x1b[C", "\x1b[D"].includes(text)) {
234
- if (state.help && state.mode === "panels") return;
235
- if (state.confirm?.type === "permission_default") { draw(); return; }
236
- state.confirm = undefined; state.optionChoice = undefined; state.editing = true;
237
- state.input += terminalText(text.replace(/\x1b\[20[01]~/g, "")).replace(/\n/g, " "); draw();
286
+ if (/^(?:\x1b\[[ABCD])+$/.test(text)) { for (const key of text.match(/\x1b\[[ABCD]/g)!) handle(key); }
287
+ else {
288
+ state.attentionMessages = []; state.attentionSelection = undefined;
289
+ if (state.help && state.mode === "panels") { draw(); return; }
290
+ if (state.confirm?.type === "permission_default") { draw(); return; }
291
+ state.confirm = undefined; state.optionChoice = undefined; state.editing = true;
292
+ state.input += terminalText(text.replace(/\x1b\[20[01]~/g, "")).replace(/\n/g, " "); draw();
293
+ }
238
294
  } else handle(text);
239
295
  }));
240
296
  removers.push(terminal.onResize(() => {
@@ -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;