@staix/agent-hub 0.12.45 → 0.14.0

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 (108) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +85 -116
  3. package/docs/agent-notes/README.md +6 -0
  4. package/docs/agent-notes/adapters.md +10 -7
  5. package/docs/agent-notes/budget.md +7 -1
  6. package/docs/agent-notes/bus.md +6 -4
  7. package/docs/agent-notes/daemon.md +14 -2
  8. package/docs/agent-notes/local-worker.md +2 -1
  9. package/docs/agent-notes/tasks.md +16 -3
  10. package/docs/agent-notes/tests.md +5 -3
  11. package/docs/dogfood.md +95 -0
  12. package/docs/events.md +14 -1
  13. package/docs/operations.md +290 -27
  14. package/docs/profiles.md +2 -2
  15. package/docs/quickstart.md +13 -3
  16. package/docs/sdlc.md +59 -8
  17. package/docs/security.md +26 -13
  18. package/docs/smoke.md +9 -4
  19. package/docs/specs/2026-09-19-agent-hub-design.md +797 -8
  20. package/docs/verification/2026-10-19-reviewer-isolation.md +2 -0
  21. package/docs/verification/2026-10-22-code-facts.md +78 -0
  22. package/docs/verification/2026-10-22-codex-reviewer-isolation.md +61 -0
  23. package/docs/verification/2026-10-22-kimi-qwen-compat.md +79 -0
  24. package/docs/verification/2026-10-23-0.13.0.md +89 -0
  25. package/docs/verification/2026-10-23-0.14.0.md +69 -0
  26. package/docs/verification/dogfood/2026-10-22.md +3109 -0
  27. package/docs/verified.json +22 -17
  28. package/package.json +1 -1
  29. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  30. package/plugins/agent-hub/server.js +702 -347
  31. package/src/adapters/agy/entry.ts +5 -4
  32. package/src/adapters/claude-channel.ts +20 -9
  33. package/src/adapters/codex-appserver.ts +18 -2
  34. package/src/adapters/codex-review-isolation.ts +29 -11
  35. package/src/adapters/compat.ts +140 -0
  36. package/src/adapters/definitions/builtin-metadata.ts +3 -2
  37. package/src/adapters/definitions/native-cli-doctor.ts +12 -5
  38. package/src/adapters/definitions/native-cli-entry-codex.ts +13 -0
  39. package/src/adapters/definitions/native-cli-entry-jcode.ts +9 -6
  40. package/src/adapters/definitions/native-cli-jcode-mcp.ts +9 -2
  41. package/src/adapters/definitions/native-cli-preview-common.ts +2 -0
  42. package/src/adapters/definitions/native-cli-preview-jcode.ts +8 -2
  43. package/src/adapters/definitions/native-cli-preview-qwen.ts +2 -1
  44. package/src/adapters/definitions/qwen-native.ts +7 -3
  45. package/src/adapters/definitions/qwen.ts +2 -1
  46. package/src/adapters/jcode.ts +72 -31
  47. package/src/adapters/kimi-review.ts +6 -3
  48. package/src/adapters/registry-types.ts +3 -1
  49. package/src/adapters/registry.ts +1 -0
  50. package/src/adapters/reviewer-print.ts +34 -7
  51. package/src/cli/agents.ts +40 -0
  52. package/src/cli/console-review.ts +54 -0
  53. package/src/cli/console-state.ts +124 -29
  54. package/src/cli/console-symbols.ts +74 -0
  55. package/src/cli/console-usage.ts +89 -0
  56. package/src/cli/console.ts +26 -1
  57. package/src/cli/help.ts +17 -6
  58. package/src/cli/identity.ts +29 -6
  59. package/src/cli/init.ts +109 -11
  60. package/src/cli/main.ts +127 -11
  61. package/src/cli/output.ts +91 -3
  62. package/src/cli/preview.ts +1 -1
  63. package/src/cli/sdlc-release-guard.ts +82 -0
  64. package/src/cli/status-lines.ts +1 -1
  65. package/src/hub/action-class.ts +362 -0
  66. package/src/hub/action-policy.ts +106 -0
  67. package/src/hub/board.ts +65 -9
  68. package/src/hub/budget.ts +71 -2
  69. package/src/hub/bus.ts +43 -3
  70. package/src/hub/checks.ts +80 -31
  71. package/src/hub/code-facts.ts +223 -0
  72. package/src/hub/config-apply.ts +10 -1
  73. package/src/hub/config-trust.ts +7 -4
  74. package/src/hub/daemon.ts +319 -34
  75. package/src/hub/dogfood-read.ts +199 -0
  76. package/src/hub/dogfood.ts +163 -0
  77. package/src/hub/envelope.ts +1 -1
  78. package/src/hub/events.ts +11 -2
  79. package/src/hub/hub-tools.ts +26 -3
  80. package/src/hub/inbox-polling.ts +25 -0
  81. package/src/hub/instances.ts +87 -5
  82. package/src/hub/manager.ts +2 -2
  83. package/src/hub/profiles.ts +7 -2
  84. package/src/hub/release-worktree.ts +56 -0
  85. package/src/hub/review-brief.ts +36 -0
  86. package/src/hub/sdlc-metrics.ts +260 -0
  87. package/src/hub/sdlc-queue.ts +31 -9
  88. package/src/hub/sdlc-release-authorization.ts +173 -0
  89. package/src/hub/sdlc.ts +54 -14
  90. package/src/hub/settings.ts +5 -2
  91. package/src/hub/snapshots.ts +93 -8
  92. package/src/hub/spec-read.ts +31 -0
  93. package/src/hub/symbol-normalizer.ts +26 -0
  94. package/src/hub/task-code-group.ts +52 -0
  95. package/src/hub/task-code-symbols.ts +90 -0
  96. package/src/hub/task-sweep.ts +3 -2
  97. package/src/hub/tasks.ts +455 -67
  98. package/src/hub/test-integrity.ts +162 -0
  99. package/src/hub/ui.ts +3 -0
  100. package/src/hub/usage-rollup.ts +419 -0
  101. package/src/local/sandbox.ts +5 -3
  102. package/src/ui/index.html +274 -2
  103. package/templates/AGENTS.block.md +1 -1
  104. package/templates/profiles/dev.json +6 -1
  105. package/templates/profiles/general.json +6 -1
  106. package/templates/profiles/research.json +6 -1
  107. package/templates/profiles/writing.json +6 -1
  108. package/templates/release-guard.block.md +6 -0
@@ -7,6 +7,9 @@ import { taskProgress } from "../ui/task-progress.ts";
7
7
  import type { ProgressStage } from "../ui/task-progress.ts";
8
8
  import { sanitize } from "../hub/envelope.ts";
9
9
  import type { SettingRow, SettingValue } from "../hub/settings.ts";
10
+ import { symbolFields, overlapFields } from "./console-symbols.ts";
11
+ import { choicePrompt, reviewChoiceFor, reviewHint, reviewRefusal, verdictGuide, verdictInput, type ReviewChoice, type Verdict } from "./console-review.ts";
12
+ import { USAGE_DROP, USAGE_HEAD, usageCells, usageDetail, usagePeers, usageTaskLines, type UsageState } from "./console-usage.ts";
10
13
 
11
14
  export interface Approval {
12
15
  id: string; peer: string; title: string; expiresAt: number;
@@ -45,10 +48,16 @@ export interface ConsoleState {
45
48
  tasks: any[]; tasksKnown?: boolean; taskCounts?: Record<string, number>; queue: any[]; detail?: Detail; detailOffset: number; help: boolean; notice: string; noticeAt?: number; noticeTone?: Tone;
46
49
  peerFilter?: string; kindFilter?: string; project?: string;
47
50
  taskSelection?: number; queueSelection?: string;
51
+ /** #683 U3: the Usage panel (7): what the `usage` op last answered, and the task that was selected in Tasks when the panel was opened. */
52
+ usage?: UsageState; usageTask?: number;
48
53
  actionLock?: { prefix: string[]; id: string; revision: number; state: string; owner?: string; reserved?: string; reviewer?: string; waiting?: boolean; syntax?: "planning" | "ownerless-return"; revisionField?: "planningRevision" | "flowRevision" };
49
54
  confirm?: { type: "permission"; id: string; option: string; peer: string } | { type: "command"; args: string[]; prompt?: string; lock?: ConsoleState["actionLock"] } | { type: "permission_default"; peer: string; source: string };
50
55
  optionChoice?: string;
51
56
  modeChoice?: string;
57
+ /** #711: `r` on a task in review opens this choice (a approve, c request changes) the way `m` opens the permission-mode choice. */
58
+ reviewChoice?: ReviewChoice;
59
+ /** The guide line above the input after a verdict was chosen; it goes with the input line. */
60
+ reviewGuide?: string;
52
61
  permissionDefaults: PermissionDefault[];
53
62
  permissionDefaultsHandled: string[];
54
63
  }
@@ -84,7 +93,7 @@ export function syncPermissionDefaults(s: ConsoleState, rows: unknown): boolean
84
93
  s.permissionDefaultsHandled = s.permissionDefaultsHandled.filter(fingerprint => current.has(fingerprint));
85
94
  const pending = s.confirm;
86
95
  if (pending?.type === "permission_default" && !s.permissionDefaults.some(row => row.peer === pending.peer && row.source === pending.source)) s.confirm = undefined;
87
- if (s.settingsEdit || s.confirm || s.editing || s.input || s.modeChoice || s.optionChoice || s.help) return false;
96
+ if (s.settingsEdit || s.confirm || s.editing || s.input || s.modeChoice || s.reviewChoice || s.optionChoice || s.help) return false;
88
97
  const next = s.permissionDefaults.find(row => !s.permissionDefaultsHandled.includes(permissionDefaultFingerprint(s, row)));
89
98
  if (!next) return false;
90
99
  s.confirm = { type: "permission_default", peer: next.peer, source: next.source };
@@ -310,8 +319,19 @@ function fieldLines(fields: [string, unknown][], columns: number, now: number):
310
319
  });
311
320
  });
312
321
  }
313
- const detailLines = (detail: Detail, columns: number, now: number): Span[][] =>
314
- typeof detail === "string" ? wrap(detail, columns).map(text => [span(text)]) : fieldLines(Object.entries(detail), columns, now).flat();
322
+ /**
323
+ * A task opened from Tasks also gets its "changed symbols" (#684 2b), after its plan so the two read together; a private task gets none. `codeSymbols` is the hub's
324
+ * raw observation and `symbolOverlaps` is the hub pair projection; formatted blocks replace both raw fields.
325
+ */
326
+ function taskFields(detail: Record<string, unknown>): [string, unknown][] {
327
+ const fields = Object.entries(detail).filter(([key]) => key !== "codeSymbols" && key !== "symbolOverlaps");
328
+ const block = [...symbolFields(detail), ...overlapFields(detail)];
329
+ if (!block.length) return fields;
330
+ const at = fields.findIndex(([key]) => key === "plan");
331
+ return at < 0 ? [...fields, ...block] : [...fields.slice(0, at + 1), ...block, ...fields.slice(at + 1)];
332
+ }
333
+ const detailLines = (detail: Detail, columns: number, now: number, task = false): Span[][] =>
334
+ typeof detail === "string" ? wrap(detail, columns).map(text => [span(text)]) : fieldLines(task ? taskFields(detail) : Object.entries(detail), columns, now).flat();
315
335
  const allowOptions = (a: Approval) => a.options.filter(o => o.kind.startsWith("allow"));
316
336
  /** A pushed request as the console keeps it: every agent-supplied field a string, so no draw can throw on one. */
317
337
  export function approvalFrom(msg: any): Approval {
@@ -341,10 +361,11 @@ const KEYS: [string, string[]][] = [
341
361
  ["Panels", ["1-5 panel", "Left/Right panel", "j/k or Up/Down move", "Home/< first, End/> last", "Enter view", "j/k scroll a view", "w web UI (outside Peers)", "o pet window"]],
342
362
  ["Approvals", ["a allow (then y)", "d deny", "v view", "[ ] select"]],
343
363
  ["Peers", ["p pause", "r resume", "m permission mode", "w WIP limit", "l make planner (then y)"]],
344
- ["Tasks", ["b backlog/open", "n add backlog", "a assign/reserve (then y)", "u unassign (then y)", "r review (in review)", "p pull, e plan (backlog)", "R return to backlog", "c cancel (then y)"]],
345
- ["Queue", ["r resolve (reason, then y)", "queued=discard, needs_review=retry"]],
364
+ ["Tasks", ["b backlog/open", "n add backlog", "a assign/reserve (then y)", "u unassign (then y)", "r approve or request changes (in review; a or c, then a note)", "p pull, e plan (backlog)", "R return to backlog", "c cancel (then y)"]],
365
+ ["Queue", ["r resolve (reason, then y)", "queued=discard, needs_review=retry", "D discard the peer's whole queued backlog (reason, then y)"]],
346
366
  ["Events", ["f peer filter", "g kind filter"]],
347
367
  ["Settings", ["6 Settings panel", "e edit", "u undo", "preview, reason, then y"]],
368
+ ["Usage", ["7 Usage panel", "Enter view a peer", "tokens today/7d/lifetime with sources", "task from Tasks (3)"]],
348
369
  ];
349
370
  /** The `?` key table: one group per panel, its keys kept whole and packed into the width after the group column. */
350
371
  export function keyTable(columns: number, attention = false, characters = false): Span[][] {
@@ -395,7 +416,7 @@ export function parseConsoleCommand(input: string): string[] {
395
416
  }
396
417
  export function commandNeedsConfirmation(args: string[]): boolean {
397
418
  const positional = args.filter(arg => arg !== "--yes");
398
- return (args[0] === "backlog" && args[1] !== "list") || (args[0] === "task" && args[1] === "propose" && args.includes("--backlog")) || (positional[0] === "permission" && positional[2] === "never-ask") || (args[0] === "task" && ["assign", "unassign", "cancel"].includes(args[1] ?? "")) || (args[0] === "queue" && args[1] === "resolve") || (args[0] === "permit" && args[2] !== "deny");
419
+ return (args[0] === "backlog" && args[1] !== "list") || (args[0] === "task" && args[1] === "propose" && args.includes("--backlog")) || (positional[0] === "permission" && positional[2] === "never-ask") || (args[0] === "task" && ["assign", "unassign", "cancel"].includes(args[1] ?? "")) || (args[0] === "queue" && (args[1] === "resolve" || args[1] === "discard")) || (args[0] === "permit" && args[2] !== "deny");
399
420
  }
400
421
  export function panelRows(s: ConsoleState): any[] {
401
422
  if (s.panel === 1) return Object.entries(s.peers).map(([id, peer]) => ({ id, ...peer, budget: s.budget[id] }));
@@ -406,6 +427,7 @@ export function panelRows(s: ConsoleState): any[] {
406
427
  return s.tasks.filter(t => states.includes(t.state)).sort((a, b) => states.indexOf(a.state) - states.indexOf(b.state));
407
428
  }
408
429
  if (s.panel === 6) return [...(s.settingsRows ?? [])].sort((a, b) => a.group.localeCompare(b.group) || a.key.localeCompare(b.key));
430
+ if (s.panel === 7) return usagePeers(s.usage, s.budget, s.peers);
409
431
  if (s.panel === 4) return s.queue.filter(q => q.state === "needs_review" || q.state === "queued");
410
432
  return s.events.filter(e => (!s.peerFilter || e.peer === s.peerFilter) && (!s.kindFilter || e.kind === s.kindFilter));
411
433
  }
@@ -462,7 +484,7 @@ function lockedCommandPrompt(lock: NonNullable<ConsoleState["actionLock"]>, args
462
484
  return `unassign ${role} ${affected} on task #${lock.id} at revision ${lock.revision} -> ${resulting}${guidance}; native work and uncertain deliveries are not stopped or resolved`;
463
485
  }
464
486
  export function reduceConsole(state: ConsoleState, key: string, now = Date.now()): { state: ConsoleState; effects: ConsoleEffect[] } {
465
- const attentionKeyBlocked = !!(state.settingsEdit || state.confirm || state.optionChoice || state.modeChoice || state.help || state.editing || state.input);
487
+ const attentionKeyBlocked = !!(state.settingsEdit || state.confirm || state.optionChoice || state.modeChoice || state.reviewChoice || state.help || state.editing || state.input);
466
488
  // A notice answers the key before this one; one that pruning raises now stays.
467
489
  let s = pruneApprovals({ ...state, notice: "" }, now); const effects: ConsoleEffect[] = [];
468
490
  const done = () => ({ state: bound(s), effects });
@@ -533,19 +555,47 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
533
555
  }
534
556
  return done();
535
557
  }
558
+ if (s.reviewChoice) {
559
+ const choice = s.reviewChoice;
560
+ if (key === "\x1b") { s.reviewChoice = undefined; return done(); }
561
+ const verdict: Verdict | undefined = key === "a" ? "approved" : key === "c" ? "changes_requested" : undefined;
562
+ if (verdict) {
563
+ s.reviewChoice = undefined;
564
+ // The task may have left review while the choice was open: say so instead of prefilling a command the hub will refuse.
565
+ const current = s.tasks.find(task => task.id === choice.id);
566
+ if (current && current.state !== "in_review") notify(s, `#${choice.id} is no longer in review (${current.state})`, now);
567
+ else { s.input = verdictInput(choice.id, verdict); s.reviewGuide = verdictGuide(choice.id, verdict); s.editing = true; }
568
+ }
569
+ return done(); // any other key leaves the choice open
570
+ }
536
571
  if (s.help && s.mode === "panels" && !["?", "\x1b", "q"].includes(key)) return done(); // the key table is modal
537
572
  if (s.editing || s.input) {
538
- if (key === "\x1b") { s.input = ""; s.editing = false; s.actionLock = undefined; }
573
+ if (key === "\x1b") { s.input = ""; s.editing = false; s.actionLock = undefined; s.reviewGuide = undefined; }
539
574
  else if (key === "\x7f" || key === "\b") s.input = Array.from(s.input).slice(0, -1).join("");
540
575
  else if (key === "\x15") s.input = "";
541
576
  else if (key === "\x1b[A" || key === "\x1b[B") {
542
577
  s.historyIndex = Math.max(0, Math.min(s.history.length, s.historyIndex + (key === "\x1b[A" ? -1 : 1)));
543
578
  s.input = s.history[s.historyIndex] ?? "";
544
579
  } else if (key === "\r" || key === "\n") {
545
- const input = s.input, lock = s.actionLock; s.input = ""; s.editing = false; s.actionLock = undefined;
580
+ const input = s.input, lock = s.actionLock; s.input = ""; s.editing = false; s.actionLock = undefined; s.reviewGuide = undefined;
546
581
  if (!input.trim()) return done();
547
582
  try {
548
583
  const parsed = parseConsoleCommand(input);
584
+ if (parsed[0] === "queue" && parsed[1] === "discard") {
585
+ const reasonAt = parsed.indexOf("--reason"), peer = commandFlag(parsed, "--peer"), state = commandFlag(parsed, "--state") ?? "queued";
586
+ // The reason is every word after --reason up to the next flag, so a reason typed without quotes ("agy backlog") is whole, not cut to its first word.
587
+ const words: string[] = [];
588
+ for (let i = reasonAt + 1; reasonAt >= 0 && i < parsed.length && !parsed[i]!.startsWith("--"); i++) words.push(parsed[i]!);
589
+ const reason = words.join(" ").trim();
590
+ if (!peer || !reason) throw new Error("queue discard needs --peer and --reason (it is recorded on every discarded delivery)");
591
+ const count = s.queue.filter(row => row.peer === peer && row.state === state).length;
592
+ const args = ["queue", "discard", "--peer", peer, ...(parsed.includes("--state") ? ["--state", state] : []), "--reason", reason, "--yes"];
593
+ s.history = [...s.history, input].slice(-100); s.historyIndex = s.history.length;
594
+ s.confirm = { type: "command", args, prompt: `discard all ${count} ${state} deliver${count === 1 ? "y" : "ies"} of ${peer}; no task or native turn is cancelled, other peers are untouched` };
595
+ return done();
596
+ }
597
+ const reviewProblem = reviewRefusal(parsed); // an empty changes note never reaches the hub (#711)
598
+ if (reviewProblem) throw new Error(reviewProblem);
549
599
  const args = parsed[0] === "permission" ? [...parsed.filter(arg => arg !== "--yes"), ...(parsed.includes("--yes") ? ["--yes"] : [])] : parsed;
550
600
  if (args[0] === "queue" && args[1] === "resolve") {
551
601
  const reason = args.indexOf("--reason"), action = commandFlag(args, "--action");
@@ -614,11 +664,15 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
614
664
  }
615
665
  if (key === "\t") { s.mode = s.mode === "panels" ? "stream" : "panels"; s.detail = undefined; s.requestDetail = undefined; return done(); }
616
666
  if (key === ":") { s.editing = true; return done(); }
617
- const switchPanel = (panel: number) => { s.panel = panel; s.selection = panel === 5 ? newestEvent(s) : 0; s.taskSelection = undefined; s.queueSelection = undefined; s.detail = undefined; s.requestDetail = undefined; return done(); };
618
- if (s.mode === "panels" && /^[1-6]$/.test(key) && !s.optionChoice) return switchPanel(Number(key));
667
+ const switchPanel = (panel: number) => {
668
+ // The task selected in Tasks is the one the Usage panel shows; switchPanel clears that selection, so it is read first (#683 U3).
669
+ // With no valid selected row (an empty Tasks panel) the old task is cleared, never kept: the panel must not show a task that is no longer the selection.
670
+ if (panel === 7 && s.panel === 3) { const task = selectedPanelRow(s); s.usageTask = task && Number.isSafeInteger(Number(task.id)) && Number(task.id) > 0 ? Number(task.id) : undefined; }
671
+ s.panel = panel; s.selection = panel === 5 ? newestEvent(s) : 0; s.taskSelection = undefined; s.queueSelection = undefined; s.detail = undefined; s.requestDetail = undefined; return done(); };
672
+ if (s.mode === "panels" && /^[1-7]$/.test(key) && !s.optionChoice) return switchPanel(Number(key));
619
673
  // Right and Left are the digit of the next or previous tab, wrapping at both ends (#309); the same guards as the
620
674
  // digits apply (the confirm, mode-choice, key-table and editing branches above returned already).
621
- if (s.mode === "panels" && (key === "\x1b[C" || key === "\x1b[D") && !s.optionChoice) return switchPanel(s.panel === 6 ? key === "\x1b[C" ? 1 : 5 : (s.panel + (key === "\x1b[C" ? 0 : 3)) % 5 + 1);
675
+ if (s.mode === "panels" && (key === "\x1b[C" || key === "\x1b[D") && !s.optionChoice) return switchPanel(s.panel >= 6 ? key === "\x1b[C" ? 1 : 5 : (s.panel + (key === "\x1b[C" ? 0 : 3)) % 5 + 1);
622
676
  if (s.mode === "panels" && s.panel === 1 && key === "w") {
623
677
  const peer = selectedPanelRow(s)?.id;
624
678
  if (!peer || s.detail !== undefined) { notify(s, "select a peer row to edit its planner WIP limit", now); return done(); }
@@ -702,6 +756,7 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
702
756
  if (s.mode === "panels" && row && s.detail === undefined && (key === "\r" || key === "\n")) {
703
757
  if (s.panel === 3 || s.panel === 4) effects.push({ type: "show", panel: s.panel, id: String(row.id) });
704
758
  else if (s.panel === 2) { s.requestDetail = row.id; s.detailOffset = 0; } // the row is the selected request
759
+ else if (s.panel === 7) { s.detail = { usagePeer: row.id }; s.detailOffset = 0; } // rendered live from s.usage, so a refresh updates it
705
760
  else { s.detail = s.panel === 5 ? String(row.text) : { ...row }; s.detailOffset = 0; }
706
761
  return done();
707
762
  }
@@ -732,8 +787,13 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
732
787
  else s.confirm = { type: "command", args: ["settings", "set", "planner", peer], prompt: `make ${peer} the planner` };
733
788
  return done();
734
789
  }
735
- const prefill = s.panel === 1 && key === "p" ? `pause ${item}` : s.panel === 1 && key === "r" ? `resume ${item}` :
736
- s.panel === 3 && key === "r" && selected.state === "in_review" ? `review ${item} ` : undefined;
790
+ // #711: `r` on a task in review asks which verdict first, from the row or from the open detail, so the person is never left to guess the next word.
791
+ if (s.panel === 3 && key === "r" && selected.state === "in_review") {
792
+ const choice = reviewChoiceFor({ ...selected, reviewer: selected.reviewer ?? s.tasks.find(task => task.id === item)?.reviewer, signals: selected.signals ?? s.tasks.find(task => task.id === item)?.signals });
793
+ if (choice) s.reviewChoice = choice;
794
+ return done();
795
+ }
796
+ const prefill = s.panel === 1 && key === "p" ? `pause ${item}` : s.panel === 1 && key === "r" ? `resume ${item}` : undefined;
737
797
  if (prefill) { s.input = prefill; s.editing = true; return done(); }
738
798
  if (s.panel === 3 && key === "c") {
739
799
  if (!s.features?.includes("task_cancel")) { notify(s, "task cancellation requires a newer hub", now); return done(); }
@@ -754,6 +814,12 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
754
814
  s.input = key === "a" ? `task assign ${item} ${role === "reviewer" ? "--role reviewer " : ""}--revision ${revision} ` : `task unassign ${item} --role ${role} --revision ${revision}`;
755
815
  s.editing = true; return done();
756
816
  }
817
+ // #713: D clears the whole queued backlog of the selected row's peer. Nothing runs until a reason is typed and y confirms the count.
818
+ if (s.panel === 4 && key === "D") {
819
+ const peer = String(selected.peer ?? "");
820
+ if (!/^[a-z][a-z0-9-]{0,31}$/.test(peer)) return done();
821
+ s.input = `queue discard --peer ${peer} --reason `; s.editing = true; return done();
822
+ }
757
823
  if (s.panel === 4 && key === "r") {
758
824
  if (!["queued", "needs_review"].includes(selected.state)) return done();
759
825
  const revision = selected.revision;
@@ -787,6 +853,7 @@ export const TABLES: Record<number, string[]> = {
787
853
  3: ["ID", "STATE", "OWNER", "REVIEWER", "CLASS", "AGE", "TITLE", "STAGE"],
788
854
  4: ["ID", "PEER", "STATE", "REV", "AGE"],
789
855
  6: ["GROUP", "KEY", "VALUE", "SOURCE", "APPLIES"],
856
+ 7: USAGE_HEAD,
790
857
  };
791
858
  const showPermissionModes = (s: ConsoleState) => Object.values(s.peers).some(peer => peer.permissionMode && peer.permissionMode !== "ask");
792
859
  const count = (n: unknown) => typeof n === "number" && n ? String(n) : "-";
@@ -810,6 +877,10 @@ export function cells(s: ConsoleState, row: any, selected: boolean, now: number,
810
877
  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(", ") : "-"),
811
878
  span(row.servedBy ?? row.requestedModel ?? "-")];
812
879
  }
880
+ if (s.panel === 7) {
881
+ const text = usageCells(s.usage, row.id, s.budget[row.id], at => relative(at, now));
882
+ return text.map((value, i) => i === 0 ? id(value, "info") : span(value, value === "-" || value === "unknown" || value === "no records" ? "muted" : i === 4 ? "attention" : undefined));
883
+ }
813
884
  if (s.panel === 2) return [id(row.id, "attention"), span(row.peer, "attention"), span(duration(row.expiresAt - now), "muted"), span(row.title)];
814
885
  if (s.panel === 3 && s.backlogView) {
815
886
  const waits = waitingDeps(s, row);
@@ -884,7 +955,7 @@ function taskHints(row: any): string[] {
884
955
  const review = row.state === "in_review";
885
956
  const hints = [review ? "a reviewer" : "a assign"];
886
957
  if (review ? row.reviewer : row.owner || row.reserved) hints.push("u unassign");
887
- if (review) hints.push("r review");
958
+ if (review) hints.push(reviewHint(row.reviewer));
888
959
  if (["proposed", "in_progress"].includes(row.state)) hints.push("R return");
889
960
  return hints;
890
961
  }
@@ -894,7 +965,7 @@ function panelContext(s: ConsoleState, row: any): string[] {
894
965
  if (!row) return [];
895
966
  if (s.panel === 6) return ["e edit", ...(s.settingsUndo ? ["u undo"] : [])];
896
967
  if (s.panel === 1) return ["p pause", "r resume", "m permission mode", "w WIP limit"];
897
- if (s.panel === 4) return [row.state === "queued" ? "r discard" : "r resolve"];
968
+ if (s.panel === 4) return [row.state === "queued" ? "r discard" : "r resolve"]; // D (the peer's whole backlog) is in the ? key table: it must not push j/k off an 80-column footer
898
969
  return [];
899
970
  }
900
971
  /** The footer's key hint: only keys that act in this mode, panel and state. */
@@ -903,7 +974,9 @@ function hint(s: ConsoleState, columns: number): string {
903
974
  if (s.mode === "stream") return [...pending, ...(pending.length ? ["v view"] : []), ...(s.approvals.length > (pending.length ? 1 : 0) ? ["[ ] select"] : []), "Tab panels", ": command", "? keys", "q quit"].join(" ");
904
975
  if (s.help) return "? or Esc close q quit";
905
976
  const approvals = s.panel === 2 ? pending : [];
906
- if (s.detail !== undefined || s.requestDetail) return [...approvals, "j/k scroll", "Esc back", "? keys", "q quit"].join(" ");
977
+ // An open task detail names the review action too (#711): the detail is where the person reads the task, so it is where they decide.
978
+ const shown = s.panel === 3 && s.detail && typeof s.detail === "object" && (s.detail as { state?: unknown }).state === "in_review" ? reviewHint((s.detail as { reviewer?: unknown }).reviewer) : undefined;
979
+ if (s.detail !== undefined || s.requestDetail) return [...approvals, ...(shown ? [shown] : []), "j/k scroll", "Esc back", "? keys", "q quit"].join(" ");
907
980
  const rows = panelRows(s); const row = selectedPanelRow(s);
908
981
  const text = [...approvals, ...panelContext(s, row), ...(s.panel === 5 ? ["f peer", "g kind"] : []), ...(rows.length ? ["j/k move"] : []), ...(row ? ["Enter view"] : []), "? keys", ": command", "Tab stream", "q quit"].join(" ");
909
982
  if (s.panel === 3 && Bun.stringWidth(text) > columns) {
@@ -918,14 +991,22 @@ function hint(s: ConsoleState, columns: number): string {
918
991
  /** The project, then the tabs: the active one in brackets, pending approvals and held deliveries counted. */
919
992
  function header(s: ConsoleState, columns: number): Span[] {
920
993
  const counts = [0, s.approvals.length, 0, s.queue.filter(q => q.state === "needs_review").length, 0];
921
- const tabs: Span[] = [];
922
- for (const [i, name] of ["Peers", "Approvals", "Tasks", "Queue", "Events", "Settings"].entries()) {
923
- const active = i + 1 === s.panel; const tone = active ? "strong" : "info";
924
- tabs.push(span(active ? " [" : " ", tone), span(`${i + 1} ${name}`, tone));
925
- if (counts[i]) tabs.push(span(` ${counts[i]}`, i === 1 ? "attention" : "failure"));
926
- tabs.push(span(active ? "]" : " ", tone));
927
- }
928
- return fitLine([span(fit(s.project ?? "agent-hub", Math.max(0, columns - Bun.stringWidth(tabs.map(t => t.text).join("")))), "info"), ...tabs], columns);
994
+ const project = s.project ?? "agent-hub";
995
+ // Seven tabs fill an 80-column line, which would squeeze the project name out: below that, one space before a tab instead of two (#683 U3).
996
+ const build = (lead: string) => {
997
+ const tabs: Span[] = [];
998
+ for (const [i, name] of ["Peers", "Approvals", "Tasks", "Queue", "Events", "Settings", "Usage"].entries()) {
999
+ const active = i + 1 === s.panel; const tone = active ? "strong" : "info";
1000
+ tabs.push(span(active ? lead.slice(1) + "[" : lead, tone), span(`${i + 1} ${name}`, tone));
1001
+ if (counts[i]) tabs.push(span(` ${counts[i]}`, i === 1 ? "attention" : "failure"));
1002
+ tabs.push(span(active ? "]" : " ", tone));
1003
+ }
1004
+ return tabs;
1005
+ };
1006
+ const width = (tabs: Span[]) => Bun.stringWidth(tabs.map(t => t.text).join(""));
1007
+ let tabs = build(" ");
1008
+ if (columns - width(tabs) < Math.min(Bun.stringWidth(project), 8)) tabs = build(" ");
1009
+ return fitLine([span(fit(project, Math.max(0, columns - width(tabs))), "info"), ...tabs], columns);
929
1010
  }
930
1011
  /** The label column is the hub's own: a cut title or option list is marked `(more)` there; Enter shows it whole. */
931
1012
  const more = (line: Span[], text?: string): Span[] => [span("(more)".padEnd(Bun.stringWidth(line[0]!.text)), "attention"), ...(text === undefined ? line.slice(1) : [span(text)])];
@@ -968,6 +1049,7 @@ export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24,
968
1049
  prompt = edit.phase === "value" ? `value for ${edit.key} (inherit removes): ${edit.text}` : edit.phase === "preview" ? "checking setting preview; Esc cancels" : edit.phase === "reason" ? `reason: ${edit.text}` : `${edit.undo ? "undo last setting" : `set ${edit.key}`} (${edit.reason})? y/N`;
969
1050
  }
970
1051
  if (s.modeChoice) prompt = `permission ${s.modeChoice}: 1 ask 2 ask-when-needed 3 never-ask Esc cancel`;
1052
+ if (s.reviewChoice) prompt = choicePrompt(s.reviewChoice);
971
1053
  const choice = s.optionChoice ? s.approvals.find(a => a.id === s.optionChoice) : undefined;
972
1054
  if (choice) prompt = `allow request ${choice.id} (${choice.peer}) with: ${allowOptions(choice).map((o, i) => `${i + 1} ${quoted(o.name)}`).join(" ")} Esc cancel`;
973
1055
  const summary: Span[] = [];
@@ -994,7 +1076,7 @@ export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24,
994
1076
  const defaultSource = s.confirm?.type === "permission_default" ? s.confirm.source : undefined;
995
1077
  if (s.confirm?.type === "permission_default") prompt = `enable never-ask default for ${s.confirm.peer}? y/n Esc defer`;
996
1078
  const footer = [fitLine(neverPeer ? [span(permissionBoundary(neverPeer, undefined, createProjectAdapterCatalog()), "attention")] : summary, columns),
997
- fitLine(defaultSource !== undefined ? [span(`source ${quoted(defaultSource.split(/[\\/]/).at(-1))}; stays ask until y`, "attention")] : approvals, columns),
1079
+ fitLine(defaultSource !== undefined ? [span(`source ${quoted(defaultSource.split(/[\\/]/).at(-1))}; stays ask until y`, "attention")] : s.editing && s.reviewGuide ? [span(s.reviewGuide, "info")] : approvals, columns),
998
1080
  fitLine([span(prompt, s.confirm || s.optionChoice ? "attention" : undefined)], columns)];
999
1081
  const rule = [span("-".repeat(Math.max(0, columns)), "muted")];
1000
1082
  if (s.mode === "stream") {
@@ -1020,7 +1102,9 @@ export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24,
1020
1102
  }
1021
1103
  else if (s.help) lines.push(...keyTable(columns, !!s.petEnabled || !!s.petChoices?.length, !!s.petChoices?.length));
1022
1104
  else if (s.detail !== undefined || (s.requestDetail && permission)) {
1023
- const detail = s.detail !== undefined ? detailLines(s.detail, columns, now) : fieldLines(approvalFields(permission!), columns, now).flat();
1105
+ const usagePeer = s.panel === 7 && s.detail && typeof s.detail === "object" ? (s.detail as { usagePeer?: unknown }).usagePeer : undefined;
1106
+ const detail = typeof usagePeer === "string" ? fieldLines(Object.entries(usageDetail(s.usage, usagePeer, s.budget[usagePeer], at => relative(at, now))), columns, now).flat()
1107
+ : s.detail !== undefined ? detailLines(s.detail, columns, now, s.panel === 3) : fieldLines(approvalFields(permission!), columns, now).flat();
1024
1108
  lines.push(...detail.slice(Math.min(s.detailOffset, Math.max(0, detail.length - height))));
1025
1109
  }
1026
1110
  else {
@@ -1031,10 +1115,15 @@ export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24,
1031
1115
  const fits = Math.max(1, height - 5); // table header, one row, rule, one title line, deny
1032
1116
  if (allow.length > fits) allow.splice(fits - 1, Infinity, more(allow[0]!, `${plural(allow.length - fits + 1, "more option")}: Enter shows them all`));
1033
1117
  const tail = [...allow, ...deny];
1034
- const capacity = Math.max(1, s.panel === 2 ? Math.min(Math.floor(height / 2) - 1, height - 3 - tail.length) : height - (s.panel === 3 ? 2 : 1)); const start = Math.max(0, at - capacity + 1);
1118
+ // The Usage panel's selected-task lines are reserved before the peer table is sized, so many peers can never push them off a short terminal (#683 U3).
1119
+ // At most half the body goes to them; a longer list is cut with a count. The status line and the table header take the other two rows.
1120
+ let taskLines = s.panel === 7 ? usageTaskLines(s.usage, s.usageTask) : [];
1121
+ const taskRoom = Math.max(1, Math.floor(height / 2));
1122
+ if (taskLines.length > taskRoom) taskLines = [...taskLines.slice(0, taskRoom - 1), { text: ` ... ${taskLines.length - taskRoom + 1} more`, tone: "muted" as const }];
1123
+ const capacity = Math.max(1, s.panel === 2 ? Math.min(Math.floor(height / 2) - 1, height - 3 - tail.length) : s.panel === 7 ? height - 2 - taskLines.length : height - (s.panel === 3 ? 2 : 1)); const start = Math.max(0, at - capacity + 1);
1035
1124
  const stages = new Map(progress.stages.map(stage => [stage.id, stage]));
1036
1125
  const cellRows = head ? data.map((row, i) => cells(s, row, i === at, now, stages)) : [];
1037
- const rowsOf = head ? s.panel === 1 ? optionalTable(head, cellRows, columns) : s.panel === 3 && s.backlogView ? optionalTable(head, cellRows, columns, ["AGE", "CLASS", "DEPS", "PRI"]) : table(head, cellRows, columns) : undefined;
1126
+ const rowsOf = head ? s.panel === 7 ? optionalTable(head, cellRows, columns, USAGE_DROP) : s.panel === 1 ? optionalTable(head, cellRows, columns) : s.panel === 3 && s.backlogView ? optionalTable(head, cellRows, columns, ["AGE", "CLASS", "DEPS", "PRI"]) : table(head, cellRows, columns) : undefined;
1038
1127
  if (s.panel === 6) {
1039
1128
  const row = selectedPanelRow(s);
1040
1129
  const info = s.settingsProblem ?? (!s.settingsKnown ? "settings loading..." : row?.restartReason ?? row?.note ?? row?.label ?? "Settings");
@@ -1045,6 +1134,11 @@ export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24,
1045
1134
  for (const pending of s.retainedConfig) lines.push(fitLine([span(`${pending.key}: ${pending.reason}`, "attention")], columns));
1046
1135
  }
1047
1136
  }
1137
+ if (s.panel === 7) {
1138
+ const note = s.usage?.problem ? `usage unavailable: ${s.usage.problem}` : !s.usage?.life ? "usage loading..." : s.usage.behind ? "usage still catching up with the event log: numbers are partial and refresh in a few seconds"
1139
+ : `usage through ${s.usage.at ? relative(s.usage.at, now) : "now"}: today is the UTC day, 7d the last seven UTC days`;
1140
+ lines.push(fitLine([span(note, s.usage?.problem ? "failure" : !s.usage?.life || s.usage.behind ? "attention" : "info")], columns));
1141
+ }
1048
1142
  if (s.panel === 3 && s.backlogView) {
1049
1143
  lines.push(fitLine([span(s.tasksKnown === false ? "backlog loading..." : `backlog ${data.length}`, s.tasksKnown === false ? "attention" : "info")], columns));
1050
1144
  } else if (s.panel === 3) {
@@ -1065,6 +1159,7 @@ export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24,
1065
1159
  lines.push(fitLine([span(selected ? "> " : " ", selected ? "strong" : undefined), ...(rowsOf?.[i + 1] ?? cells(s, data[i], selected, now))], columns));
1066
1160
  }
1067
1161
  if (!data.length) lines.push([span(" (empty)")]);
1162
+ if (s.panel === 7) for (const line of taskLines) lines.push(fitLine([span(" " + line.text, line.tone)], columns));
1068
1163
  if (title.length) {
1069
1164
  const room = Math.max(1, height - lines.length - 1 - tail.length);
1070
1165
  if (title.length > room) { title.length = room; title[room - 1] = more(title[room - 1]!); }
@@ -0,0 +1,74 @@
1
+ import { planSymbolMatches } from "../hub/review-brief.ts";
2
+
3
+ // #684 2b: the "changed symbols" block of the console's task detail. Pure data to fields, no terminal code. The hub's own observation of the changed
4
+ // symbols (`codeSymbols`, PR #708) is read as it is; nothing is parsed from code here and nothing is asked of the hub beyond `task_show`.
5
+ // Advisory only: ripwire does not report deleted symbols, so these lists never establish that a plan was or was not followed. The plan names are `plan.symbols`,
6
+ // the same list `withCodeSymbols` reads as `planned` in the review brief (daemon.ts passes `task.plan?.symbols`), not a second source.
7
+
8
+ export interface ChangedSymbol { id: string; path: string; name: string; kind: string }
9
+ export interface SymbolBlock { state: "known" | "unknown"; complete: boolean; head?: string; matching: ChangedSymbol[]; unplanned: ChangedSymbol[]; untouched: string[] }
10
+
11
+ const MAX_LINES = 40;
12
+ const isPrivate = (task: any): boolean =>
13
+ !task || (Array.isArray(task.signals) && task.signals.some((s: unknown) => s === "pii" || s === "pii_path")) || task.title === "[pii]";
14
+ const symbolsOf = (task: any): ChangedSymbol[] | undefined => {
15
+ const received = task?.codeSymbols;
16
+ if (!received || received.state !== "known" || !Array.isArray(received.symbols)) return undefined;
17
+ // Only the public receipt (`publicTaskCodeSymbols`, re-screened on every read) is shown. A stored receipt still carries `repositoryKey` and was never screened for
18
+ // the current policy: a task handed over in that shape reads as unknown, whatever feeds this renderer today (the renderer holds the rule itself).
19
+ if (Object.hasOwn(received, "repositoryKey")) return undefined;
20
+ const out: ChangedSymbol[] = [];
21
+ for (const s of received.symbols) if (s && typeof s.id === "string" && typeof s.path === "string" && typeof s.name === "string" && typeof s.kind === "string") out.push({ id: s.id, path: s.path, name: s.name, kind: s.kind });
22
+ return out;
23
+ };
24
+
25
+ /** Each changed symbol as matching the plan or unplanned, and each planned name nothing changed under as untouched. Unknown without code facts. */
26
+ export function classifySymbols(task: any): SymbolBlock {
27
+ const changed = symbolsOf(task);
28
+ if (!changed) return { state: "unknown", complete: false, matching: [], unplanned: [], untouched: [] };
29
+ const plan: string[] = Array.isArray(task?.plan?.symbols) ? task.plan.symbols.filter((v: unknown): v is string => typeof v === "string") : [];
30
+ const matching = changed.filter(s => plan.some(p => planSymbolMatches(p, s.name)));
31
+ const unplanned = changed.filter(s => !plan.some(p => planSymbolMatches(p, s.name)));
32
+ const untouched = plan.filter(p => !changed.some(s => planSymbolMatches(p, s.name)));
33
+ return { state: "known", complete: task.codeSymbols.complete === true, ...(typeof task.codeSymbols.head === "string" ? { head: task.codeSymbols.head.slice(0, 8) } : {}), matching, unplanned, untouched };
34
+ }
35
+
36
+ /**
37
+ * The detail fields for a task (`symbols` and `symbols note`), or none for a private one. Values are objects of single words (`{ matching: name, in: path }`) so the console's field
38
+ * renderer quotes every agent-written string: a symbol name cannot pass for a hub label. Capped, with the count of what is left out.
39
+ */
40
+ export function symbolFields(task: any): [string, unknown][] {
41
+ // Only a task as `task_show` shapes it (a numeric id and a state): any other detail an agent or a test hands the Tasks panel keeps its own layout.
42
+ if (!task || typeof task.id !== "number" || typeof task.state !== "string" || isPrivate(task)) return [];
43
+ const block = classifySymbols(task);
44
+ if (block.state === "unknown") return [["symbols", "changed symbols: unknown (no code facts)"]];
45
+ const items: Record<string, string>[] = [
46
+ ...block.matching.map(s => ({ matching: s.name, in: s.path })), ...block.unplanned.map(s => ({ unplanned: s.name, in: s.path })), ...block.untouched.map(name => ({ untouched: name })),
47
+ ];
48
+ const shown = items.slice(0, MAX_LINES);
49
+ const note = [`changed symbols: ${block.matching.length} matching, ${block.unplanned.length} unplanned, ${block.untouched.length} untouched${block.head ? ` at ${block.head}` : ""}`,
50
+ block.complete ? "" : "incomplete: some changed symbols were not listed, so an untouched one may have changed",
51
+ "advisory: deleted symbols are not reported"].filter(Boolean).join("; ");
52
+ return [["symbols", shown.length ? shown : "none observed"], ["symbols note", items.length > shown.length ? `${note}; ${items.length - shown.length} more not shown` : note]];
53
+ }
54
+
55
+
56
+ /** Render the hub's read-only pair verdicts; never derive disjointness from a local receipt or plan. */
57
+ export function overlapFields(task: any): [string, unknown][] {
58
+ if (!task || typeof task.id !== "number" || typeof task.state !== "string" || isPrivate(task) || !Array.isArray(task.symbolOverlaps)) return [];
59
+ if (!task.symbolOverlaps.length) return [["overlaps", "none"]];
60
+ const items: Record<string, unknown>[] = [];
61
+ let invalid = 0;
62
+ for (const row of task.symbolOverlaps) {
63
+ if (!row || !Number.isSafeInteger(row.task) || row.task < 1 || !Array.isArray(row.files)
64
+ || !row.files.every((file: unknown) => typeof file === "string") || typeof row.sameFileDifferentSymbols !== "boolean") { invalid++; continue; }
65
+ items.push({ task: row.task, files: row.files.slice(0, MAX_LINES),
66
+ overlap: row.sameFileDifferentSymbols ? "same file, different symbols" : "overlap",
67
+ ...(row.files.length > MAX_LINES ? { omitted: `${row.files.length - MAX_LINES} files not shown` } : {}) });
68
+ }
69
+ if (!items.length) return [["overlaps", "unknown (invalid overlap data)"]];
70
+ const note = ["observed names only, deleted definitions may be missing",
71
+ items.length > MAX_LINES ? `${items.length - MAX_LINES} more not shown` : "",
72
+ invalid ? `${invalid} invalid rows withheld` : ""].filter(Boolean).join("; ");
73
+ return [["overlaps", items.slice(0, MAX_LINES)], ["overlap note", note]];
74
+ }
@@ -0,0 +1,89 @@
1
+ import type { Counter, LimitsResult, UsageResult, UsageRow } from "../hub/usage-rollup.ts";
2
+
3
+ // #683 U3: what the console's Usage panel shows, as plain strings. No terminal code and no state import here: console-state.ts renders it.
4
+ // Every number names its source, an unknown counter reads `unknown` (never 0), and a private task is `#id [pii]` with no title.
5
+
6
+ /** What the console keeps from the `usage` op: one query per period, the limits history and, for the task selected in Tasks, its tokens per peer. */
7
+ export interface UsageState {
8
+ today?: UsageResult; week?: UsageResult; life?: UsageResult; limits?: LimitsResult;
9
+ /** The selected task's tokens per peer, with the task id it was asked for. */
10
+ task?: UsageResult; taskId?: number;
11
+ at?: number; problem?: string;
12
+ /** The hub's roll-up was still behind the event log when it answered: the numbers are partial (a later poll catches up). */
13
+ behind?: boolean;
14
+ }
15
+ export interface UsagePeerRow { id: string }
16
+
17
+ const TAG: Record<Counter["source"], string> = { native: "native", count: "hub", manual: "manual", partial: "at least", unknown: "unknown" };
18
+ const compact = (n: number): string => n >= 1e7 ? `${Math.round(n / 1e6)}M` : n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e4 ? `${Math.round(n / 1e3)}k` : String(n);
19
+ /** `1.2M native`, `77 hub`, or `unknown`: a counter nobody reported is never shown as a number. */
20
+ export const counterText = (c: Counter | undefined, exact = false): string => !c || c.value === null ? "unknown" : `${exact ? c.value : compact(c.value)} ${TAG[c.source]}`;
21
+ /** A row's tokens: the provider's total when it reported one, otherwise what the hub counted, otherwise unknown. */
22
+ export const rowTokens = (row: UsageRow | undefined): Counter =>
23
+ row && row.total.value !== null ? row.total : row && row.counted.value !== null ? row.counted : { value: null, source: "unknown" };
24
+ const periodText = (row: UsageRow | undefined, exact = false): string => row ? counterText(rowTokens(row), exact) : "no records";
25
+
26
+ const rowOf = (result: UsageResult | undefined, peer: string): UsageRow | undefined => result?.rows.find(row => row.key === peer);
27
+
28
+ /** Every peer the panel can say something about: peers with usage in any period, and peers with a quota reading. */
29
+ export function usagePeers(usage: UsageState | undefined, budget: Record<string, unknown>, peers: Record<string, unknown>): UsagePeerRow[] {
30
+ const ids = new Set<string>([...Object.keys(peers), ...Object.keys(budget)]);
31
+ for (const result of [usage?.today, usage?.week, usage?.life]) for (const row of result?.rows ?? []) ids.add(row.key);
32
+ return [...ids].sort().map(id => ({ id }));
33
+ }
34
+
35
+ interface Budget { windows?: { id: string; used: number; resetsAt?: number }[]; paused?: { resetsAt: number } }
36
+ const windowText = (w: { id: string; used: number; resetsAt?: number }, rel: (at: number) => string) =>
37
+ `${w.id} ${Math.round(w.used * 100)}%${typeof w.resetsAt === "number" ? ` ${rel(w.resetsAt)}` : ""}`;
38
+ const lastResets = (limits: LimitsResult | undefined, peer: string, count: number) =>
39
+ (limits?.history ?? []).filter(h => h.type === "quota_reset" && h.peer === peer).slice(-count).reverse();
40
+
41
+ /** One table row: PEER, TODAY, 7D, LIFETIME, PAUSE, QUOTA, LAST RESET (PAUSE before QUOTA: a pause must survive a narrow terminal, the long quota text is what gives way). */
42
+ export function usageCells(usage: UsageState | undefined, peer: string, budget: Budget | undefined, rel: (at: number) => string): string[] {
43
+ const reset = lastResets(usage?.limits, peer, 1)[0];
44
+ // The table is tight at 80 columns: a time still to come reads `3h00m`, not `in 3h00m` (the detail view spells it out).
45
+ const ahead = (at: number) => rel(at).replace(/^in /, "");
46
+ return [peer, periodText(rowOf(usage?.today, peer)), periodText(rowOf(usage?.week, peer)), periodText(rowOf(usage?.life, peer)),
47
+ budget?.paused ? ahead(budget.paused.resetsAt) : "-",
48
+ budget?.windows?.length ? budget.windows.map(w => windowText(w, ahead)).join(", ") : "-",
49
+ reset ? `${reset.window} ${rel(Date.parse(reset.at))}` : "-"];
50
+ }
51
+ export const USAGE_HEAD = ["PEER", "TODAY", "7D", "LIFETIME", "PAUSE", "QUOTA", "LAST RESET"];
52
+ /** Only the last reset gives way to width; the others are cut shorter instead, so the 7D column and the pause are never dropped. */
53
+ export const USAGE_DROP = ["LAST RESET"];
54
+
55
+ /** The token split of one period: input, output, cache read, cache write, the total and the hub's own count, each with its source. */
56
+ export function splitText(row: UsageRow | undefined): string {
57
+ if (!row) return "no records";
58
+ return [`total ${counterText(row.total, true)}`, `input ${counterText(row.input, true)}`, `output ${counterText(row.output, true)}`,
59
+ `cache read ${counterText(row.cacheRead, true)}`, `cache write ${counterText(row.cacheWrite, true)}`, `hub count ${counterText(row.counted, true)}`].join(", ");
60
+ }
61
+ export const taskLabel = (usage: UsageState | undefined): string =>
62
+ usage?.taskId === undefined ? "" : `#${usage.taskId}${usage.task?.taskPii ? " [pii]" : ""}`;
63
+
64
+ /** The detail of one peer, as labelled one-line-per-field text. Values are hub-built from ids and numbers only. */
65
+ export function usageDetail(usage: UsageState | undefined, peer: string, budget: Budget | undefined, rel: (at: number) => string): Record<string, string> {
66
+ const resets = lastResets(usage?.limits, peer, 3).map(h => `${h.window} ${rel(Date.parse(h.at))}`);
67
+ const out: Record<string, string> = {
68
+ peer,
69
+ quota: budget?.windows?.length ? budget.windows.map(w => windowText(w, rel)).join(", ") : "no reading",
70
+ pause: budget?.paused ? `budget pause, resumes ${rel(budget.paused.resetsAt)}` : "none",
71
+ "last resets": resets.length ? resets.join(", ") : "none recorded",
72
+ today: splitText(rowOf(usage?.today, peer)),
73
+ "last 7 days": splitText(rowOf(usage?.week, peer)),
74
+ lifetime: splitText(rowOf(usage?.life, peer)),
75
+ };
76
+ if (usage?.taskId !== undefined) out[`task ${taskLabel(usage)}`] = splitText(rowOf(usage.task, peer));
77
+ return out;
78
+ }
79
+
80
+ /** The lines under the table: the task selected in Tasks and its tokens per peer. */
81
+ export function usageTaskLines(usage: UsageState | undefined, selectedTask: number | undefined): { text: string; tone?: "muted" | "info" | "failure" }[] {
82
+ if (selectedTask === undefined) return [{ text: "task: none selected; pick one in Tasks (3), then open Usage (7)", tone: "muted" }];
83
+ if (usage?.problem) return [];
84
+ if (usage?.taskId !== selectedTask || !usage.task) return [{ text: `task #${selectedTask}: loading...`, tone: "muted" }];
85
+ const rows = usage.task.rows;
86
+ const head = { text: `task ${taskLabel(usage)}: tokens per peer, all time`, tone: "info" as const };
87
+ if (!rows.length) return [head, { text: " no usage recorded for this task", tone: "muted" }];
88
+ return [head, ...rows.map(row => ({ text: ` ${row.key} ${counterText(rowTokens(row))}` }))];
89
+ }
@@ -4,6 +4,7 @@ import { StringDecoder } from "node:string_decoder";
4
4
  import type { ControlClient } from "../hub/control-client.ts";
5
5
  import { contextLine } from "./status-lines.ts";
6
6
  import { renderTailEvent } from "./tail-render.ts";
7
+ import { reviewResult } from "./console-review.ts";
7
8
  import { syncPermissionDefaults, permissionDefaultText, approvalFrom, initialConsoleState, keyTable, notify, paint, permissionText, plural, pruneApprovals, reduceConsole, renderConsoleLines, resolveColor, stateTone, streamTokens, wrapStreamTokens, terminalText, wrap, captureConsoleSelection, selectedPanelRow, appendEvent, FIRST_KEYS, LAST_KEYS } from "./console-state.ts";
8
9
  import type { ConsoleEffect, ConsoleEvent, Detail, Tone } from "./console-state.ts";
9
10
  import type { BusEvent } from "../hub/bus.ts";
@@ -192,6 +193,15 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
192
193
  ...(panels ? [client.request({ t: "task", op: "hub_task_list", args: {} }, 3000), client.request({ t: "queue", op: "list" }, 3000), client.request({ t: "task", op: "hub_task_list", args: { ready: true } }, 3000)] : []),
193
194
  ...(panels && (state.panel === 1 || state.panel === 6) ? [client.request({ t: "settings_get" }, 3000)] : []),
194
195
  ]);
196
+ // #683 U3: the Usage panel asks the hub's roll-up only while it is open (UTC day, 7 UTC days, lifetime, the limits history and the selected task).
197
+ // The task is read at the time of the request, so a reply for a task that is no longer selected is dropped below.
198
+ const usageTask = panels && state.panel === 7 ? state.usageTask : undefined;
199
+ const usageReplies = panels && state.panel === 7 ? await (async () => {
200
+ const startOfDay = Date.UTC(new Date().getUTCFullYear(), new Date().getUTCMonth(), new Date().getUTCDate());
201
+ return Promise.all([client.request({ t: "usage", by: "peer", since: startOfDay }, 5000), client.request({ t: "usage", by: "peer", since: startOfDay - 6 * 86_400_000 }, 5000),
202
+ client.request({ t: "usage", by: "peer" }, 5000), client.request({ t: "usage", op: "limits", limit: 1000 }, 5000),
203
+ ...(usageTask !== undefined ? [client.request({ t: "usage", by: "peer", task: usageTask }, 5000)] : [])]);
204
+ })() : undefined;
195
205
  if (!active) return;
196
206
  const [status, budget, tasks, queue, ready, settings] = replies;
197
207
  captureConsoleSelection(state);
@@ -217,6 +227,12 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
217
227
  }
218
228
  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" });
219
229
  if (budget.budget) state.budget = budget.budget;
230
+ if (usageReplies) {
231
+ const [today, week, life, limits, task] = usageReplies, failed = usageReplies.find(reply => reply?.ok === false);
232
+ if (failed || !today?.usage || !week?.usage || !life?.usage || !limits?.limits) state.usage = { ...state.usage, problem: String(failed?.error ?? "usage is unavailable") };
233
+ else state.usage = { today: today.usage, week: week.usage, life: life.usage, limits: limits.limits, at: Date.now(), ...(usageReplies.some(reply => reply?.behind) ? { behind: true } : {}),
234
+ ...(usageTask !== undefined && task?.usage && state.usageTask === usageTask ? { task: task.usage, taskId: usageTask } : {}) };
235
+ }
220
236
  if (tasks?.ok) { const parsed = JSON.parse(tasks.text); if (Array.isArray(parsed)) {
221
237
  const readyIds = new Set(ready?.ok ? JSON.parse(ready.text).map((task: any) => task.id) : []);
222
238
  state.tasks = parsed.map(task => ({ ...task, ready: readyIds.has(task.id) }));
@@ -304,7 +320,16 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
304
320
  let diagnostic = "";
305
321
  // 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.
306
322
  const output = (text: string) => { if (active) { diagnostic = (diagnostic + terminalText(text)).slice(-2000); stream({ text: terminalText(text).replace(/\t/g, " ").replace(/\n$/, "").replace(/^/gm, " "), kind: "command" }); draw(); } };
307
- cancelChild = run(action.args, output, (code = 0) => { childRunning = false; cancelChild = undefined; if (active) { if (code) notice(diagnostic.trim() || `command exited ${code}`); void refresh(); } }, Math.max(1, columns - 4));
323
+ cancelChild = run(action.args, output, (code = 0) => {
324
+ childRunning = false; cancelChild = undefined;
325
+ if (active) {
326
+ // #711: a review's result is a notice in panels mode too (where the stream is not on screen): `#372 approved`, or the CLI's own refusal verbatim.
327
+ const review = reviewResult(action.args, code, diagnostic.trim());
328
+ if (review) notify(state, review.text, Date.now(), review.tone === "success" ? "success" : "failure");
329
+ else if (code) notice(diagnostic.trim() || `command exited ${code}`);
330
+ void refresh(); draw();
331
+ }
332
+ }, Math.max(1, columns - 4));
308
333
  } catch (error) { childRunning = false; notice(String((error as Error).message)); draw(); }
309
334
  };
310
335
  const handle = (key: string) => {