@staix/agent-hub 0.12.29 → 0.12.30

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 (62) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +79 -219
  3. package/docs/agent-notes/README.md +12 -0
  4. package/docs/agent-notes/adapters.md +1 -1
  5. package/docs/agent-notes/bus.md +3 -1
  6. package/docs/agent-notes/daemon.md +10 -0
  7. package/docs/agent-notes/local-worker.md +2 -2
  8. package/docs/agent-notes/tasks.md +9 -0
  9. package/docs/agent-notes/tests.md +1 -1
  10. package/docs/memory.md +70 -152
  11. package/docs/operations.md +199 -14
  12. package/docs/plans/2026-10-16-amem-failure-registry.md +101 -0
  13. package/docs/plans/2026-10-16-amem-p0-baseline.md +110 -0
  14. package/docs/plans/2026-10-16-amem-p2-read-tools.md +28 -0
  15. package/docs/plans/amem-cases.json +482 -0
  16. package/docs/quickstart.md +9 -2
  17. package/docs/security.md +13 -1
  18. package/docs/smoke.md +11 -0
  19. package/docs/specs/2026-09-19-agent-hub-design.md +105 -4
  20. package/docs/verification/2026-10-17-0.12.30.md +70 -0
  21. package/docs/verified.json +13 -13
  22. package/package.json +5 -2
  23. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  24. package/plugins/agent-hub/server.js +64 -44
  25. package/src/cli/console-state.ts +92 -20
  26. package/src/cli/console.ts +22 -5
  27. package/src/cli/help.ts +3 -2
  28. package/src/cli/identity.ts +1 -1
  29. package/src/cli/main.js +2 -2
  30. package/src/cli/main.ts +36 -14
  31. package/src/cli/memory.ts +87 -187
  32. package/src/cli/output.ts +6 -0
  33. package/src/cli/recovery-package.ts +59 -10
  34. package/src/cli/upgrade-runtime.ts +54 -17
  35. package/src/cli/upgrade.ts +8 -0
  36. package/src/hub/board.ts +2 -1
  37. package/src/hub/bus.ts +33 -12
  38. package/src/hub/daemon.ts +134 -27
  39. package/src/hub/envelope.ts +30 -0
  40. package/src/hub/git.ts +2 -38
  41. package/src/hub/hub-tools.ts +5 -1
  42. package/src/hub/manager.ts +1 -1
  43. package/src/hub/memory-candidates.ts +103 -0
  44. package/src/hub/memory-fail.ts +25 -0
  45. package/src/hub/memory-observations.ts +47 -0
  46. package/src/hub/memory-plan-guard.ts +33 -0
  47. package/src/hub/memory-policy.ts +42 -0
  48. package/src/hub/memory-profile-check.ts +170 -0
  49. package/src/hub/memory-profile.ts +192 -0
  50. package/src/hub/memory-staleness.ts +55 -0
  51. package/src/hub/path-policy.ts +8 -0
  52. package/src/hub/project.ts +4 -37
  53. package/src/hub/read-tools.ts +127 -0
  54. package/src/hub/tasks.ts +201 -41
  55. package/src/local/deny.ts +4 -17
  56. package/src/memory/brief.ts +8 -5
  57. package/src/memory/capture.ts +2 -1
  58. package/src/memory/recall.ts +50 -110
  59. package/src/pi/process-signature.ts +2 -42
  60. package/src/memory/import.ts +0 -90
  61. package/src/memory/store.ts +0 -805
  62. package/src/memory/sync.ts +0 -145
@@ -29,7 +29,9 @@ export interface ConsoleState {
29
29
  approvals: Approval[]; events: ConsoleEvent[]; peers: Record<string, any>; budget: Record<string, any>;
30
30
  tasks: any[]; tasksKnown?: boolean; taskCounts?: Record<string, number>; queue: any[]; detail?: Detail; detailOffset: number; help: boolean; notice: string; noticeAt?: number; noticeTone?: Tone;
31
31
  peerFilter?: string; kindFilter?: string; project?: string;
32
- confirm?: { type: "permission"; id: string; option: string; peer: string } | { type: "command"; args: string[] } | { type: "permission_default"; peer: string; source: string };
32
+ taskSelection?: number; queueSelection?: string;
33
+ actionLock?: { prefix: string[]; id: string; revision: number; state: string; owner?: string; reserved?: string; reviewer?: string; waiting?: boolean };
34
+ confirm?: { type: "permission"; id: string; option: string; peer: string } | { type: "command"; args: string[]; prompt?: string } | { type: "permission_default"; peer: string; source: string };
33
35
  optionChoice?: string;
34
36
  modeChoice?: string;
35
37
  permissionDefaults: PermissionDefault[];
@@ -319,8 +321,8 @@ const KEYS: [string, string[]][] = [
319
321
  ["Panels", ["1-5 panel", "Left/Right panel", "j/k or Up/Down move", "Enter view", "j/k scroll a view"]],
320
322
  ["Approvals", ["a allow (then y)", "d deny", "v view", "[ ] select"]],
321
323
  ["Peers", ["p pause", "r resume", "m permission mode"]],
322
- ["Tasks", ["a assign (then y)", "r review"]],
323
- ["Queue", ["r resolve (reason, then y)"]],
324
+ ["Tasks", ["a assign/reviewer (then y)", "u unassign (then y)", "r review (in review)"]],
325
+ ["Queue", ["r resolve (reason, then y)", "queued=discard, needs_review=retry"]],
324
326
  ["Events", ["f peer filter", "g kind filter"]],
325
327
  ];
326
328
  /** The `?` key table: one group per panel, its keys kept whole and packed into the width after the group column. */
@@ -372,7 +374,7 @@ export function parseConsoleCommand(input: string): string[] {
372
374
  }
373
375
  export function commandNeedsConfirmation(args: string[]): boolean {
374
376
  const positional = args.filter(arg => arg !== "--yes");
375
- return (positional[0] === "permission" && positional[2] === "never-ask") || (args[0] === "task" && args[1] === "assign") || (args[0] === "queue" && args[1] === "resolve") || (args[0] === "permit" && args[2] !== "deny");
377
+ return (positional[0] === "permission" && positional[2] === "never-ask") || (args[0] === "task" && ["assign", "unassign"].includes(args[1] ?? "")) || (args[0] === "queue" && args[1] === "resolve") || (args[0] === "permit" && args[2] !== "deny");
376
378
  }
377
379
  export function panelRows(s: ConsoleState): any[] {
378
380
  if (s.panel === 1) return Object.entries(s.peers).map(([id, peer]) => ({ id, ...peer, budget: s.budget[id] }));
@@ -385,8 +387,38 @@ export function panelRows(s: ConsoleState): any[] {
385
387
  return s.events.filter(e => (!s.peerFilter || e.peer === s.peerFilter) && (!s.kindFilter || e.kind === s.kindFilter));
386
388
  }
387
389
  export const selectedApproval = (s: ConsoleState) => s.approvals.find(a => a.id === s.approvalId);
388
- /** The selected row: the Approvals panel's is the selected request (-1 when none is). */
389
- const selection = (s: ConsoleState) => s.panel === 2 ? s.approvals.findIndex(a => a.id === s.approvalId) : s.selection;
390
+ /** The selected row: approvals and refreshed Tasks/Queue rows are bound by id, never by a replacement row. */
391
+ const selection = (s: ConsoleState) => {
392
+ if (s.panel === 2) return s.approvals.findIndex(a => a.id === s.approvalId);
393
+ if (s.panel === 3 && s.taskSelection !== undefined) return panelRows(s).findIndex(row => row.id === s.taskSelection);
394
+ if (s.panel === 4 && s.queueSelection !== undefined) return panelRows(s).findIndex(row => row.id === s.queueSelection);
395
+ return s.selection;
396
+ };
397
+ export const selectedPanelRow = (s: ConsoleState): any | undefined => panelRows(s)[selection(s)];
398
+ export function captureConsoleSelection(s: ConsoleState, byIndex = false): void {
399
+ const row = byIndex ? panelRows(s)[s.selection] : selectedPanelRow(s);
400
+ if (s.panel === 3 && row) s.taskSelection = Number(row.id);
401
+ if (s.panel === 4 && row) s.queueSelection = String(row.id);
402
+ }
403
+ const commandFlag = (args: string[], name: string) => { const at = args.indexOf(name); return at < 0 ? undefined : args[at + 1]; };
404
+ function validateLockedCommand(lock: NonNullable<ConsoleState["actionLock"]>, args: string[]): void {
405
+ const revisions = args.flatMap((arg, index) => arg === "--revision" ? [args[index + 1]] : []);
406
+ if (args[0] !== lock.prefix[0] || args[1] !== lock.prefix[1] || args[2] !== lock.id || revisions.length !== 1 || revisions[0] !== String(lock.revision)) {
407
+ throw new Error("the selected id and revision cannot be edited; start again from the row");
408
+ }
409
+ }
410
+ function lockedCommandPrompt(lock: NonNullable<ConsoleState["actionLock"]>, args: string[]): string {
411
+ if (lock.prefix[0] === "queue") {
412
+ const action = commandFlag(args, "--action") ?? "resolve";
413
+ return `${action} delivery ${lock.id} at revision ${lock.revision}${action === "completed" ? "; this asserts external effects" : action === "discard" ? "; no task or native turn is cancelled" : ""}`;
414
+ }
415
+ const role = commandFlag(args, "--role") ?? "owner";
416
+ if (lock.prefix[1] === "assign") return `assign ${role} on task #${lock.id} at revision ${lock.revision}${lock.waiting && role === "owner" ? " as a reservation until dependencies approve" : ""}`;
417
+ const affected = role === "owner" ? [lock.owner, lock.reserved].filter(Boolean).join(" and ") : lock.reviewer ?? "none";
418
+ const resulting = role === "owner" ? "proposed" : lock.state;
419
+ const guidance = role === "reviewer" && lock.state !== "in_review" ? "; later completion without a reviewer may approve directly" : lock.state === "in_review" ? `; use ahub review ${lock.id}` : "";
420
+ return `unassign ${role} ${affected} on task #${lock.id} at revision ${lock.revision} -> ${resulting}${guidance}; native work and uncertain deliveries are not stopped or resolved`;
421
+ }
390
422
  export function reduceConsole(state: ConsoleState, key: string, now = Date.now()): { state: ConsoleState; effects: ConsoleEffect[] } {
391
423
  const attentionKeyBlocked = !!(state.confirm || state.optionChoice || state.modeChoice || state.help || state.editing || state.input);
392
424
  // A notice answers the key before this one; one that pruning raises now stays.
@@ -427,24 +459,29 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
427
459
  }
428
460
  if (s.help && s.mode === "panels" && !["?", "\x1b", "q"].includes(key)) return done(); // the key table is modal
429
461
  if (s.editing || s.input) {
430
- if (key === "\x1b") { s.input = ""; s.editing = false; }
462
+ if (key === "\x1b") { s.input = ""; s.editing = false; s.actionLock = undefined; }
431
463
  else if (key === "\x7f" || key === "\b") s.input = Array.from(s.input).slice(0, -1).join("");
432
464
  else if (key === "\x15") s.input = "";
433
465
  else if (key === "\x1b[A" || key === "\x1b[B") {
434
466
  s.historyIndex = Math.max(0, Math.min(s.history.length, s.historyIndex + (key === "\x1b[A" ? -1 : 1)));
435
467
  s.input = s.history[s.historyIndex] ?? "";
436
468
  } else if (key === "\r" || key === "\n") {
437
- const input = s.input; s.input = ""; s.editing = false;
469
+ const input = s.input, lock = s.actionLock; s.input = ""; s.editing = false; s.actionLock = undefined;
438
470
  if (!input.trim()) return done();
439
471
  try {
440
472
  const parsed = parseConsoleCommand(input);
441
473
  const args = parsed[0] === "permission" ? [...parsed.filter(arg => arg !== "--yes"), ...(parsed.includes("--yes") ? ["--yes"] : [])] : parsed;
442
474
  if (args[0] === "queue" && args[1] === "resolve") {
443
- const reason = args.indexOf("--reason");
475
+ const reason = args.indexOf("--reason"), action = commandFlag(args, "--action");
444
476
  if (reason < 0 || !args[reason + 1]?.trim()) throw new Error("queue resolve requires --reason");
477
+ if (!["completed", "retry", "discard"].includes(action ?? "")) throw new Error("queue resolve requires --action completed|retry|discard");
478
+ }
479
+ if (lock) {
480
+ validateLockedCommand(lock, args);
481
+ if (lock.prefix[0] === "task" && !["owner", "reviewer"].includes(commandFlag(args, "--role") ?? "owner")) throw new Error("role must be owner or reviewer");
445
482
  }
446
483
  s.history = [...s.history, input].slice(-100); s.historyIndex = s.history.length;
447
- if (commandNeedsConfirmation(args)) s.confirm = { type: "command", args };
484
+ if (commandNeedsConfirmation(args)) s.confirm = { type: "command", args, ...(lock ? { prompt: lockedCommandPrompt(lock, args) } : {}) };
448
485
  else effects.push({ type: "command", args });
449
486
  } catch (error) { notify(s, String((error as Error).message), now); }
450
487
  } else if (!key.startsWith("\x1b")) s.input += terminalText(key).replace(/\n/g, "");
@@ -482,7 +519,7 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
482
519
  const selected = rows.findIndex(row => row.id === item.id);
483
520
  if (selected >= 0) { s.panel = 3; s.selection = selected; navigated = true; }
484
521
  }
485
- if (navigated) { s.detail = undefined; s.requestDetail = undefined; }
522
+ if (navigated) { captureConsoleSelection(s, true); s.detail = undefined; s.requestDetail = undefined; }
486
523
  else {
487
524
  const command = attentionCommand(item);
488
525
  const text = `${item.kind}${item.peer ? ` ${item.peer}` : ""}${item.id ? ` ${item.id}` : ""}: ${command}`;
@@ -493,7 +530,7 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
493
530
  }
494
531
  if (key === "\t") { s.mode = s.mode === "panels" ? "stream" : "panels"; s.detail = undefined; s.requestDetail = undefined; return done(); }
495
532
  if (key === ":") { s.editing = true; return done(); }
496
- const switchPanel = (panel: number) => { s.panel = panel; s.selection = 0; s.detail = undefined; s.requestDetail = undefined; return done(); };
533
+ const switchPanel = (panel: number) => { s.panel = panel; s.selection = 0; s.taskSelection = undefined; s.queueSelection = undefined; s.detail = undefined; s.requestDetail = undefined; return done(); };
497
534
  if (s.mode === "panels" && /^[1-5]$/.test(key) && !s.optionChoice) return switchPanel(Number(key));
498
535
  // Right and Left are the digit of the next or previous tab, wrapping at both ends (#309); the same guards as the
499
536
  // digits apply (the confirm, mode-choice, key-table and editing branches above returned already).
@@ -508,6 +545,7 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
508
545
  if (s.detail !== undefined || s.requestDetail) { s.detailOffset = Math.max(0, s.detailOffset + (["j", "\x1b[B"].includes(key) ? 1 : -1)); return done(); }
509
546
  s.selection = Math.max(0, Math.min(panelRows(s).length - 1, selection(s) + (["j", "\x1b[B"].includes(key) ? 1 : -1)));
510
547
  if (s.panel === 2) s.approvalId = s.approvals[s.selection]?.id;
548
+ captureConsoleSelection(s, true);
511
549
  return done();
512
550
  }
513
551
  if (key === "[" || key === "]") {
@@ -537,10 +575,11 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
537
575
  if (key === "v" && s.mode === "stream") { effects.push({ type: "print", text: permissionText(approval), kind: "permission", tone: "attention" }); return done(); }
538
576
  if (key === "v") { s.requestDetail = approval.id; s.detailOffset = 0; return done(); }
539
577
  }
540
- const row = panelRows(s)[selection(s)];
578
+ const row = selectedPanelRow(s);
541
579
  // With a detail open the keys act on the item it shows (its id), never on what a refresh moved under the selection;
542
580
  // a detail that is only a message names no item.
543
- const item = s.detail === undefined ? row?.id : typeof s.detail === "object" ? s.detail.id : undefined;
581
+ const selected = s.detail === undefined ? row : typeof s.detail === "object" ? s.detail : undefined;
582
+ const item = selected?.id;
544
583
  if (s.mode === "panels" && row && s.detail === undefined && (key === "\r" || key === "\n")) {
545
584
  if (s.panel === 3 || s.panel === 4) effects.push({ type: "show", panel: s.panel, id: String(row.id) });
546
585
  else if (s.panel === 2) { s.requestDetail = row.id; s.detailOffset = 0; } // the row is the selected request
@@ -557,9 +596,28 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
557
596
  return done();
558
597
  }
559
598
  const prefill = s.panel === 1 && key === "p" ? `pause ${item}` : s.panel === 1 && key === "r" ? `resume ${item}` :
560
- s.panel === 3 && key === "a" ? `task assign ${item} ` : s.panel === 3 && key === "r" ? `review ${item} ` :
561
- s.panel === 4 && key === "r" ? `queue resolve ${item} --action retry --reason ` : undefined;
599
+ s.panel === 3 && key === "r" && selected.state === "in_review" ? `review ${item} ` : undefined;
562
600
  if (prefill) { s.input = prefill; s.editing = true; return done(); }
601
+ if (s.panel === 3 && (key === "a" || key === "u")) {
602
+ if (!["proposed", "in_progress", "in_review", "changes_requested"].includes(selected.state)) return done();
603
+ const revision = selected.flowRevision, role = selected.state === "in_review" || key === "u" && !selected.owner && !selected.reserved && selected.reviewer ? "reviewer" : "owner";
604
+ if (!Number.isSafeInteger(revision) || revision < 0) { notify(s, "task row actions need flowRevision; upgrade the hub", now); return done(); }
605
+ if (key === "u" && (role === "owner" && !selected.owner && !selected.reserved || role === "reviewer" && !selected.reviewer)) { notify(s, `task #${item}: no ${role} to unassign`, now); return done(); }
606
+ const listed = s.tasks.find(task => task.id === item), ready = listed?.ready ?? selected.ready;
607
+ const waiting = selected.state === "proposed" && ready !== true && Array.isArray(selected.deps) && selected.deps.length > 0;
608
+ s.actionLock = { prefix: ["task", key === "a" ? "assign" : "unassign"], id: String(item), revision, state: String(selected.state), ...(selected.owner ? { owner: String(selected.owner) } : {}), ...(selected.reserved ? { reserved: String(selected.reserved) } : {}), ...(selected.reviewer ? { reviewer: String(selected.reviewer) } : {}), waiting };
609
+ s.input = key === "a" ? `task assign ${item} ${role === "reviewer" ? "--role reviewer " : ""}--revision ${revision} ` : `task unassign ${item} --role ${role} --revision ${revision}`;
610
+ s.editing = true; return done();
611
+ }
612
+ if (s.panel === 4 && key === "r") {
613
+ if (!["queued", "needs_review"].includes(selected.state)) return done();
614
+ const revision = selected.revision;
615
+ if (!Number.isSafeInteger(revision) || revision < 0) { notify(s, "queue row actions need a delivery revision; upgrade the hub", now); return done(); }
616
+ const action = selected.state === "queued" ? "discard" : "retry";
617
+ s.actionLock = { prefix: ["queue", "resolve"], id: String(item), revision, state: String(selected.state) };
618
+ s.input = `queue resolve ${item} --action ${action} --revision ${revision} --reason `;
619
+ s.editing = true; return done();
620
+ }
563
621
  }
564
622
  if (s.mode === "panels" && s.panel === 5 && (key === "f" || key === "g")) {
565
623
  const field = key === "f" ? "peer" : "kind"; const current = key === "f" ? s.peerFilter : s.kindFilter;
@@ -646,6 +704,21 @@ export function table(head: string[], rows: Span[][], columns: number, explicitW
646
704
  return { ...cell, text: text + " ".repeat(Math.max(0, widths[i]! - Bun.stringWidth(text)) + (i === head.length - 1 ? 0 : 2)) };
647
705
  }));
648
706
  }
707
+ function taskHints(row: any): string[] {
708
+ if (!["proposed", "in_progress", "in_review", "changes_requested"].includes(row.state)) return [];
709
+ const review = row.state === "in_review";
710
+ const hints = [review ? "a reviewer" : "a assign"];
711
+ if (review ? row.reviewer : row.owner || row.reserved) hints.push("u unassign");
712
+ if (review) hints.push("r review");
713
+ return hints;
714
+ }
715
+ function panelContext(s: ConsoleState, row: any): string[] {
716
+ if (!row) return [];
717
+ if (s.panel === 1) return ["p pause", "r resume", "m permission mode"];
718
+ if (s.panel === 3) return taskHints(row);
719
+ if (s.panel === 4) return [row.state === "queued" ? "r discard" : "r resolve"];
720
+ return [];
721
+ }
649
722
  /** The footer's key hint: only keys that act in this mode, panel and state. */
650
723
  function hint(s: ConsoleState, columns: number): string {
651
724
  const pending = selectedApproval(s) ? ["a allow", "d deny"] : [];
@@ -653,9 +726,8 @@ function hint(s: ConsoleState, columns: number): string {
653
726
  if (s.help) return "? or Esc close q quit";
654
727
  const approvals = s.panel === 2 ? pending : [];
655
728
  if (s.detail !== undefined || s.requestDetail) return [...approvals, "j/k scroll", "Esc back", "? keys", "q quit"].join(" ");
656
- const rows = panelRows(s); const row = rows[selection(s)];
657
- const context = !row ? [] : s.panel === 1 ? ["p pause", "r resume", "m permission mode"] : s.panel === 3 ? ["a assign", "r review"] : s.panel === 4 ? ["r resolve"] : [];
658
- const text = [...approvals, ...context, ...(s.panel === 5 ? ["f peer", "g kind"] : []), ...(rows.length ? ["j/k move"] : []), ...(row ? ["Enter view"] : []), "? keys", ": command", "Tab stream", "q quit"].join(" ");
729
+ const rows = panelRows(s); const row = selectedPanelRow(s);
730
+ 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(" ");
659
731
  return s.panel === 1 && Bun.stringWidth(text) > columns ? text.replace("m permission mode", "m mode").replace("j/k move", "j/k").replace(": command", ": cmd") : text;
660
732
  }
661
733
  /** The project, then the tabs: the active one in brackets, pending approvals and held deliveries counted. */
@@ -705,7 +777,7 @@ export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24,
705
777
  const permission = selectedApproval(s);
706
778
  let prompt = s.editing ? `: ${s.input}` : hint(s, columns);
707
779
  if (s.confirm?.type === "permission") prompt = `allow ${quoted(s.confirm.option)} for ${s.confirm.peer} (request ${s.confirm.id})? y/N`;
708
- if (s.confirm?.type === "command") prompt = `${s.confirm.args.join(" ")}? y/N`;
780
+ if (s.confirm?.type === "command") prompt = `${s.confirm.prompt ?? s.confirm.args.join(" ")}? y/N`;
709
781
  if (s.modeChoice) prompt = `permission ${s.modeChoice}: 1 ask 2 ask-when-needed 3 never-ask Esc cancel`;
710
782
  const choice = s.optionChoice ? s.approvals.find(a => a.id === s.optionChoice) : undefined;
711
783
  if (choice) prompt = `allow request ${choice.id} (${choice.peer}) with: ${allowOptions(choice).map((o, i) => `${i + 1} ${quoted(o.name)}`).join(" ")} Esc cancel`;
@@ -4,7 +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 { syncPermissionDefaults, permissionDefaultText, approvalFrom, initialConsoleState, keyTable, notify, paint, panelRows, permissionText, plural, pruneApprovals, reduceConsole, renderConsoleLines, resolveColor, stateTone, streamTokens, wrapStreamTokens, terminalText, wrap } from "./console-state.ts";
7
+ import { syncPermissionDefaults, permissionDefaultText, approvalFrom, initialConsoleState, keyTable, notify, paint, permissionText, plural, pruneApprovals, reduceConsole, renderConsoleLines, resolveColor, stateTone, streamTokens, wrapStreamTokens, terminalText, wrap, captureConsoleSelection, selectedPanelRow } 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
10
  import { consoleNeeds, attentionFacts } from "./attention.ts";
@@ -82,7 +82,7 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
82
82
  let lastAct = 0; let lastBell = -Infinity;
83
83
  let columns = options.columns ?? terminal.columns; let rows = options.rows ?? terminal.rows;
84
84
  let active = true; let inAlternate = false; let plain = !terminal.isTTY || columns < 80 || rows < 24;
85
- let polling = false; let childRunning = false; let cancelChild: (() => void) | undefined;
85
+ let polling = false; let refreshPending = false; let childRunning = false; let cancelChild: (() => void) | undefined;
86
86
  const priorPush = client.onPush; const priorClose = client.onClose; const priorRaw = !!terminal.isRaw;
87
87
  const removers: (() => void)[] = []; const timers: ReturnType<typeof setInterval>[] = [];
88
88
  let resolveDone: () => void = () => {}; const done = new Promise<void>(resolve => { resolveDone = resolve; });
@@ -159,8 +159,20 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
159
159
  safeWrite(`${reason ? reason + "\n" : ""}Left console; the hub keeps running (ahub kill stops it).\n`);
160
160
  resolveDone();
161
161
  };
162
+ const reconcileDetail = () => {
163
+ if (state.detail === undefined || typeof state.detail !== "object" || (state.panel !== 3 && state.panel !== 4)) return;
164
+ const id = state.detail.id;
165
+ if (id === undefined || id === null) return;
166
+ const key = state.panel === 3 ? "flowRevision" : "revision", rows = state.panel === 3 ? state.tasks : state.queue;
167
+ const fresh = rows.find(row => String(row.id) === String(id));
168
+ if (!fresh || state.detail[key] !== fresh[key]) {
169
+ state.detail = undefined; state.detailOffset = 0;
170
+ notice(`detail ${id} changed or is no longer available`);
171
+ }
172
+ };
162
173
  const refresh = async () => {
163
- if (polling || !active) return; polling = true;
174
+ if (polling || !active) { if (active) refreshPending = true; return; }
175
+ polling = true;
164
176
  try {
165
177
  const panels = state.mode === "panels" && !plain;
166
178
  const replies = await Promise.all([
@@ -169,6 +181,7 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
169
181
  ]);
170
182
  if (!active) return;
171
183
  const [status, budget, tasks, queue, ready] = replies;
184
+ captureConsoleSelection(state);
172
185
  if (status.status?.tasks && status.ok !== false) state.taskCounts = status.status.tasks;
173
186
  if (status.status?.peers) state.peers = Object.fromEntries(Object.entries(status.status.peers).map(([peer, value]) => [peer, { ...(value as object), ...status.status.roleInfo?.[peer] }]));
174
187
  if (status.status && status.ok !== false) {
@@ -184,10 +197,14 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
184
197
  state.tasksKnown = true;
185
198
  } }
186
199
  if (queue?.ok && state.mode === "panels" && Array.isArray(queue.deliveries)) state.queue = queue.deliveries;
200
+ reconcileDetail();
187
201
  const error = replies.find(reply => reply.ok === false)?.error; if (error) notice(String(error));
188
202
  draw();
189
203
  } catch (error) { if (active) { notice(String((error as Error).message)); draw(); } }
190
- finally { polling = false; }
204
+ finally {
205
+ polling = false;
206
+ if (refreshPending) { refreshPending = false; void refresh(); }
207
+ }
191
208
  };
192
209
  const run = options.runCommand ?? ((args, output, finished, commandColumns) => {
193
210
  const child = Bun.spawn([process.execPath, fileURLToPath(new URL("./main.ts", import.meta.url)), "--project", options.cwd, ...(options.liveTest ? ["--live-test"] : []), ...args], {
@@ -223,7 +240,7 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
223
240
  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);
224
241
  // A late reply is for the panel and row it was asked for: once the person has moved on, it would show one item
225
242
  // while the keys act on another, so it is dropped.
226
- if (!active || state.mode !== "panels" || state.panel !== action.panel || state.help || state.detail !== undefined || String(panelRows(state)[state.selection]?.id) !== action.id) return;
243
+ if (!active || state.mode !== "panels" || state.panel !== action.panel || state.help || state.detail !== undefined || String(selectedPanelRow(state)?.id) !== action.id) return;
227
244
  state.detailOffset = 0;
228
245
  state.detail = result.ok === false ? String(result.error) : action.panel === 3 ? labeled(String(result.text)) : result.delivery ?? "delivery not found";
229
246
  draw(); return;
package/src/cli/help.ts CHANGED
@@ -65,7 +65,7 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
65
65
  ["ahub pause|resume <peer>", "hold a peer's deliveries in its queue / release them"],
66
66
  ["ahub queue list [--peer <id>] [--json] [--color=auto|always|never]", "inspect durable deliveries"],
67
67
  ["ahub queue show <delivery-id> [--json] [--color=auto|always|never]", "inspect one delivery and revision"],
68
- ["ahub queue resolve <delivery-id> --action completed|retry|discard --reason <text>", "release a delivery held as needs_review: mark it completed, retry it or discard it"],
68
+ ["ahub queue resolve <delivery-id> --action completed|retry|discard --reason <text> [--revision N]", "resolve a queued or needs_review delivery; --revision binds the displayed row instead of a fresh show"],
69
69
  ]],
70
70
  ["Tasks and review", [
71
71
  ["ahub board import github --label <label> [--milestone <milestone>] [--repo <owner/repo>]", "person only: read bounded open issues into deduplicated Backlog; never writes GitHub"],
@@ -78,7 +78,8 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
78
78
  ["ahub backlog pull|return <id>", "person only: pull backlog into To Do or return ownerless proposed work; --backlog on task propose stages unoffered work"],
79
79
  ["ahub task show|escalate <id>", "full task with history (PII text included) / hand it to the next peer in escalate_to"],
80
80
  ["ahub task role <id> <role|none>", "person only: change bounded catalog guidance on an open task; routing and authority are unchanged"],
81
- ["ahub task assign <id> <peer> [--role owner|reviewer]", "name the owner (default) or reviewer; a task that still waits reserves its owner"],
81
+ ["ahub task assign <id> <peer> [--role owner|reviewer] [--revision N]", "name the owner (default) or reviewer; a task that still waits reserves its owner; --revision refuses stale console actions"],
82
+ ["ahub task unassign <id> [--role owner|reviewer] [--revision N]", "person only: remove the owner/reservation or reviewer without routing a replacement or stopping native work"],
82
83
  ["ahub task label <id> ok|regressed|reverted|incomplete|wrong|abandoned", "your later verdict on an approved task, kept in the research records (research must be on)"],
83
84
  ["ahub review <id> approved|changes_requested [note...] [--unmet <item>]...", "give a review verdict on a task"],
84
85
  ["ahub route explain <id>", "why a task went where it went, its reserved owner included"],
@@ -77,7 +77,7 @@ export function cliCommandLabel(command: string, args: readonly string[]): strin
77
77
  if (!["upgrade", "restart", "recovery", "recovery-run", "help", "--help", "--version", "version", "projects", "ui", "manager", "setup", "init", "daemon", "up", "console", "claude", "codex", "kimi", "qwen", "jcode", "pi", "local", "stop", "models", "say", "queue", "tail", "watch", "budget", "board", "task", "review", "remember", "ask", "route", "pause", "resume", "permit", "permission", "settings", "roster", "status", "pet", "logs", "export", "report", "research", "memory", "goal", "bench", "sdlc", "role", "facts", "check-path", "turns", "undo", "kill", "reset", "doctor"].includes(command)) return "unknown";
78
78
  if (["task", "queue", "budget", "route", "recovery", "models", "projects", "research", "memory", "goal", "bench"].includes(command)) {
79
79
  const sub = command === "bench" || command === "memory" || command === "goal" ? args.find((arg) => arg !== "--json") : args[0];
80
- if (sub && ["propose", "show", "assign", "escalate", "label", "list", "resolve", "resume", "set", "execution", "explain", "status", "abort", "dispose", "setup", "start", "stop", "remove", "export", "backfill", "addons", "run", "report", "compare", "search", "graph", "promote", "demote", "forget", "supersede", "dedupe", "rebuild", "sync", "machine", "import", "suggestions", "confirm", "reject", "new", "link", "state", "done", "take"].includes(sub)) return `${command} ${sub}`;
80
+ if (sub && ["propose", "show", "assign", "unassign", "escalate", "label", "list", "resolve", "resume", "set", "execution", "explain", "status", "abort", "dispose", "setup", "start", "stop", "remove", "export", "backfill", "addons", "run", "report", "compare", "search", "graph", "promote", "demote", "forget", "supersede", "dedupe", "decay", "rebuild", "sync", "machine", "import", "suggestions", "confirm", "reject", "new", "link", "state", "done", "take"].includes(sub)) return `${command} ${sub}`;
81
81
  }
82
82
  return (/^[a-z][a-z0-9-]*$/.test(command) && !/\s/.test(command)) || ["--help", "--version"].includes(command) ? command : "unknown";
83
83
  }
package/src/cli/main.js CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env bun
2
2
  // Keep this entrypoint plain JavaScript: Node must fail before loading TypeScript or bun:sqlite.
3
3
  const version = /^(\d+)\.(\d+)\.(\d+)$/.exec(process.versions.bun ?? "");
4
- if (!version || !(Number(version[1]) > 1 || (Number(version[1]) === 1 && Number(version[2]) >= 3))) {
5
- console.error("ahub: Bun >=1.3.0 is required. Install Bun: https://bun.sh");
4
+ if (!version || !(Number(version[1]) > 1 || (Number(version[1]) === 1 && (Number(version[2]) > 4 || (Number(version[2]) === 4 && Number(version[3]) >= 2))))) {
5
+ console.error("ahub: Bun >=1.4.2 is required. Install Bun: https://bun.sh");
6
6
  process.exit(1);
7
7
  }
8
8
 
package/src/cli/main.ts CHANGED
@@ -59,6 +59,8 @@ import { armRuns, benchCompare, benchCsv, benchReport, formatCompare, formatRepo
59
59
  import { benchPreflight, defaultIo, runBench } from "./bench.ts";
60
60
  import { ADDONS, isAddon, type AddonName } from "../hub/tooling.ts";
61
61
  import { goalCommand, memoryCommand } from "./memory.ts";
62
+ import { selectedMemoryProfile } from "../hub/memory-profile.ts";
63
+ import { checkMemoryProfiles, formatMemoryProfileCheck } from "../hub/memory-profile-check.ts";
62
64
  import { addonReport, appendRecords, formatAddons, formatResearch, LABELS, labelTarget, RESEARCH_SCHEMA, projectKey, readStores, researchReport, taskRecords, toCsv, type Label, type TaskRecord } from "../hub/research.ts";
63
65
  import { summarize, summarizeByTask } from "../hub/report.ts";
64
66
  import { hasTree, planUndo, repoOf, restore, Turns } from "../hub/snapshots.ts";
@@ -975,17 +977,24 @@ const commands: Record<string, () => Promise<void> | void> = {
975
977
  return;
976
978
  }
977
979
  const [id, ...flags] = rest;
978
- if (!id || !["show", "resolve"].includes(operation)) fail("usage: ahub queue show <id> | resolve <id> --action completed|retry|discard --reason <text>");
979
- const shown = await hub.request({ t: "queue", op: "show", id });
980
- if (!shown.ok) fail(shown.error);
980
+ if (!id || !["show", "resolve"].includes(operation)) fail("usage: ahub queue show <id> | resolve <id> --action completed|retry|discard --reason <text> [--revision N]");
981
981
  if (operation === "show") {
982
982
  if (flags.length) fail("usage: ahub queue show <id>");
983
+ const shown = await hub.request({ t: "queue", op: "show", id });
984
+ if (!shown.ok) fail(shown.error);
983
985
  if (options.json) return console.log(JSON.stringify(shown.delivery, null, 2));
984
986
  return printOutput(renderQueueShow(shown.delivery, options.columns, Date.now()), options.color);
985
987
  }
986
- const { one, rest: extra } = takeFlags(args.slice(2), ["--action", "--reason"], []);
987
- if (extra.length || !["completed", "retry", "discard"].includes(one["--action"] ?? "") || !one["--reason"]?.trim()) fail("usage: ahub queue resolve <id> --action completed|retry|discard --reason <text>");
988
- const result = await hub.request({ t: "queue", op: "resolve", id, revision: shown.delivery.revision, action: one["--action"], reason: one["--reason"] });
988
+ const { one, rest: extra } = takeFlags(flags, ["--action", "--reason", "--revision"], []);
989
+ const rawRevision = one["--revision"], parsedRevision = rawRevision === undefined ? undefined : Number(rawRevision);
990
+ if (extra.length || !["completed", "retry", "discard"].includes(one["--action"] ?? "") || !one["--reason"]?.trim() || (rawRevision !== undefined && (!/^\d+$/.test(rawRevision) || !Number.isSafeInteger(parsedRevision)))) fail("usage: ahub queue resolve <id> --action completed|retry|discard --reason <text> [--revision N]");
991
+ let revision = parsedRevision;
992
+ if (revision === undefined) {
993
+ const shown = await hub.request({ t: "queue", op: "show", id });
994
+ if (!shown.ok) fail(shown.error);
995
+ revision = shown.delivery.revision;
996
+ }
997
+ const result = await hub.request({ t: "queue", op: "resolve", id, revision, action: one["--action"], reason: one["--reason"] });
989
998
  if (!result.ok) fail(result.error);
990
999
  console.log(JSON.stringify(result.delivery, null, 2));
991
1000
  } finally { hub.close(); }
@@ -1183,10 +1192,16 @@ const commands: Record<string, () => Promise<void> | void> = {
1183
1192
  if (sub === "show") return console.log(await taskOp("task_show", { id: rest[0] }));
1184
1193
  if (sub === "escalate") return console.log(await taskOp("task_escalate", { id: rest[0] }));
1185
1194
  if (sub === "assign") {
1186
- const { one, rest: positional } = takeFlags(rest, ["--role"], []);
1187
- const role = one["--role"] ?? "owner";
1188
- if (positional.length !== 2 || !["owner", "reviewer"].includes(role)) fail("usage: ahub task assign <id> <peer> [--role owner|reviewer]");
1189
- return console.log(await taskOp("task_assign", { id: positional[0], peer: positional[1], ...(role === "reviewer" ? { role } : {}) }));
1195
+ const { one, rest: positional } = takeFlags(rest, ["--role", "--revision"], []);
1196
+ const role = one["--role"] ?? "owner", rawRevision = one["--revision"], revision = rawRevision === undefined ? undefined : Number(rawRevision);
1197
+ if (positional.length !== 2 || !["owner", "reviewer"].includes(role) || (rawRevision !== undefined && (!/^\d+$/.test(rawRevision) || !Number.isSafeInteger(revision)))) fail("usage: ahub task assign <id> <peer> [--role owner|reviewer] [--revision N]");
1198
+ return console.log(await taskOp(revision === undefined ? "task_assign" : "task_assign_checked", { id: positional[0], peer: positional[1], ...(role === "reviewer" ? { role } : {}), ...(revision === undefined ? {} : { revision }) }));
1199
+ }
1200
+ if (sub === "unassign") {
1201
+ const { one, rest: positional } = takeFlags(rest, ["--role", "--revision"], []);
1202
+ const role = one["--role"] ?? "owner", rawRevision = one["--revision"], revision = rawRevision === undefined ? undefined : Number(rawRevision);
1203
+ if (positional.length !== 1 || !["owner", "reviewer"].includes(role) || (rawRevision !== undefined && (!/^\d+$/.test(rawRevision) || !Number.isSafeInteger(revision)))) fail("usage: ahub task unassign <id> [--role owner|reviewer] [--revision N]");
1204
+ return console.log(await taskOp("task_unassign", { id: positional[0], role, ...(revision === undefined ? {} : { revision }) }));
1190
1205
  }
1191
1206
  if (sub === "label") {
1192
1207
  // #247: a person's later verdict on an approved task (the identity gate keeps it human-only), for research records.
@@ -1199,7 +1214,7 @@ const commands: Record<string, () => Promise<void> | void> = {
1199
1214
  appendRecords(project.id, [{ schema: RESEARCH_SCHEMA, kind: "label", project: projectKey(project.id), task: id, createdAt: record!.createdAt!, label, at: new Date().toISOString() }]);
1200
1215
  return console.log(`task #${id} (approved ${record!.approvedAt}) labelled ${label} in the research records`);
1201
1216
  }
1202
- if (sub !== "propose" || rest.length < 1) fail("usage: ahub task propose [<class>] <title...> | show <id> | assign <id> <peer> | escalate <id>");
1217
+ if (sub !== "propose" || rest.length < 1) fail("usage: ahub task propose [<class>] <title...> | show <id> | assign <id> <peer> | unassign <id> | escalate <id>");
1203
1218
  // `--class` is the explicit form. A first word that is a class name is still taken as the class (the documented
1204
1219
  // short form), but said out loud: "review the auth module" would otherwise be filed as class review, silently.
1205
1220
  const urgent = rest.includes("--urgent"), backlog = rest.includes("--backlog");
@@ -1411,7 +1426,7 @@ const commands: Record<string, () => Promise<void> | void> = {
1411
1426
  printOutput(renderStatus({ ...status, budget: quota.budget ?? {} }, options.columns, Date.now(), options.full), options.color);
1412
1427
  if (status.needs) { const pet = loadPetConfig(cwd); if (pet.enabled) console.log(renderPet(petView(status), false, pet.character)); }
1413
1428
  // #258: open long-term tasks and who holds them, as of this machine's last memory sync.
1414
- if (existsSync(join(hubHome(), "memory", "index.db"))) {
1429
+ if (status.memoryProfile ? status.memoryProfile.state === "ready" : existsSync(join(hubHome(), "memory", "index.db"))) {
1415
1430
  try { const lines = await goalCommand(["list"], cwd); if (!lines.startsWith("no open")) console.log(`\nlong-term tasks\n${lines}`); } catch { /* memory unreadable: status goes on */ }
1416
1431
  }
1417
1432
  } finally { hub.close(); }
@@ -1487,7 +1502,14 @@ const commands: Record<string, () => Promise<void> | void> = {
1487
1502
  },
1488
1503
  memory: async () => {
1489
1504
  // #258: the hub's own memory on this machine; reads are anyone's, changes a person's.
1490
- try { console.log(await memoryCommand(args, cwd)); } catch (e) { fail((e as Error).message); }
1505
+ if (args.filter(arg => arg !== "--json")[0] === "profile-check") {
1506
+ if (args.some(arg => arg !== "profile-check" && arg !== "--json")) fail("usage: ahub memory profile-check [--json]");
1507
+ const result = await checkMemoryProfiles(selectedMemoryProfile());
1508
+ console.log(args.includes("--json") ? JSON.stringify(result, null, 2) : formatMemoryProfileCheck(result));
1509
+ if (!result.ready) process.exitCode = 1;
1510
+ return;
1511
+ }
1512
+ try { console.log(await memoryCommand(args, cwd, { person: caller.role === "console" })); } catch (e) { fail((e as Error).message); }
1491
1513
  },
1492
1514
  goal: async () => {
1493
1515
  // #258: long-term tasks; a link goes through the project's hub, so Tasks validates the board task.
@@ -1496,7 +1518,7 @@ const commands: Record<string, () => Promise<void> | void> = {
1496
1518
  try { const r = await hub.request({ t: "goal_link", goal, task: Number(task) }); if (!r.ok) throw new Error(String(r.error)); return String(r.text); }
1497
1519
  finally { hub.close(); }
1498
1520
  };
1499
- try { console.log(await goalCommand(args, cwd, { link })); } catch (e) { fail((e as Error).message); }
1521
+ try { console.log(await goalCommand(args, cwd, { link, person: caller.role === "console" })); } catch (e) { fail((e as Error).message); }
1500
1522
  },
1501
1523
  research: () => {
1502
1524
  // #247: measures from the opt-in research records; `export` writes them out, `backfill` builds them from events.jsonl.