@staix/agent-hub 0.12.30 → 0.12.31

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 (64) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +21 -7
  3. package/docs/agent-notes/adapters.md +2 -1
  4. package/docs/agent-notes/budget.md +1 -1
  5. package/docs/agent-notes/bus.md +4 -0
  6. package/docs/agent-notes/daemon.md +5 -2
  7. package/docs/agent-notes/local-worker.md +2 -0
  8. package/docs/agent-notes/tasks.md +5 -0
  9. package/docs/agent-notes/tests.md +5 -2
  10. package/docs/memory.md +1 -1
  11. package/docs/operations.md +129 -22
  12. package/docs/quickstart.md +17 -2
  13. package/docs/security.md +30 -2
  14. package/docs/specs/2026-09-19-agent-hub-design.md +37 -3
  15. package/docs/specs/2026-09-20-upgrade-recovery-design.md +84 -10
  16. package/docs/verification/2026-10-17-0.12.31.md +71 -0
  17. package/docs/verification/2026-10-17-418-probes.md +21 -0
  18. package/docs/verified.json +13 -13
  19. package/package.json +1 -1
  20. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  21. package/plugins/agent-hub/server.js +66 -19
  22. package/src/adapters/claude-channel.ts +29 -11
  23. package/src/adapters/local-worker.ts +2 -2
  24. package/src/adapters/pi.ts +5 -0
  25. package/src/cli/attention.ts +5 -1
  26. package/src/cli/console-state.ts +56 -12
  27. package/src/cli/console.ts +19 -6
  28. package/src/cli/identity.ts +3 -2
  29. package/src/cli/jcode-mcp.ts +1 -1
  30. package/src/cli/main.ts +8 -1
  31. package/src/cli/output.ts +13 -2
  32. package/src/cli/upgrade-interactive.ts +2 -1
  33. package/src/cli/upgrade-restore-state.ts +133 -0
  34. package/src/cli/upgrade-runtime.ts +162 -58
  35. package/src/cli/upgrade.ts +49 -24
  36. package/src/hub/board.ts +82 -24
  37. package/src/hub/bus.ts +78 -29
  38. package/src/hub/check-runner.ts +514 -0
  39. package/src/hub/child-process.ts +1 -0
  40. package/src/hub/control-client.ts +5 -1
  41. package/src/hub/daemon.ts +400 -67
  42. package/src/hub/delivery-hints.ts +30 -0
  43. package/src/hub/delivery-journal.ts +185 -12
  44. package/src/hub/delivery-superseded.ts +52 -0
  45. package/src/hub/envelope.ts +1 -1
  46. package/src/hub/host-executable.ts +11 -0
  47. package/src/hub/hub-tools.ts +9 -1
  48. package/src/hub/peer-catalog.ts +3 -0
  49. package/src/hub/routing.ts +35 -16
  50. package/src/hub/settings.ts +2 -1
  51. package/src/hub/spec-read.ts +120 -0
  52. package/src/hub/supervision.ts +21 -6
  53. package/src/hub/task-sweep.ts +7 -6
  54. package/src/hub/tasks.ts +173 -34
  55. package/src/memory/capture.ts +2 -1
  56. package/src/ui/index.html +12 -9
  57. package/src/ui/needs.ts +7 -5
  58. package/src/ui/office-model.ts +4 -1
  59. package/src/ui/task-board.ts +1 -1
  60. package/src/ui/task-progress.ts +6 -2
  61. package/templates/AGENTS.block.md +1 -1
  62. package/templates/profiles/general.json +1 -1
  63. package/templates/profiles/research.json +1 -1
  64. package/templates/profiles/writing.json +1 -1
@@ -2,6 +2,7 @@ import { consoleNeeds, characterAttentionLine, attentionCommand } from "./attent
2
2
  import { CHARACTERS, petFrame, petSize, petFooterRows, type CharacterId, type PetSize, type PetPose } from "../pet/frame.ts";
3
3
  import { PEER_PALETTE } from "../ui/peer-palette.ts";
4
4
  import { PERMISSION_MODES, permissionBoundary } from "../hub/permission-mode.ts";
5
+ import { PLANNER_PEERS } from "../hub/peer-catalog.ts";
5
6
  import { taskProgress } from "../ui/task-progress.ts";
6
7
  import type { ProgressStage } from "../ui/task-progress.ts";
7
8
  import { sanitize } from "../hub/envelope.ts";
@@ -13,9 +14,10 @@ export interface Approval {
13
14
  export interface PermissionDefault { peer: string; mode: "never-ask"; source: string }
14
15
  export interface ConsoleEvent { text: string; peer?: string; kind?: string; tone?: Tone }
15
16
  export interface ConsoleState {
17
+ features?: readonly string[];
16
18
  /** Older embedding callers omit choices and retain the original STAI/off toggle. */
17
19
  petChoices?: readonly CharacterId[]; petCharacter?: CharacterId; petSize?: PetSize; petGlyphs?: "ascii" | "blocks";
18
- attentionFacts?: { kind: "review" | "offline"; id: number; peer?: string }[];
20
+ attentionFacts?: { kind: "review" | "offline" | "stall" | "offer"; id: number; peer?: string }[];
19
21
  attentionFingerprint?: string;
20
22
  petEnabled?: boolean; petBlink?: boolean; attentionMessages?: { id?: string; peer?: string; important: boolean; since?: number }[]; storageBlocked?: boolean; recoveryNeed?: { id?: string; running: boolean; since?: number }; attentionSelection?: number;
21
23
  mode: "stream" | "panels"; panel: number; selection: number;
@@ -315,12 +317,15 @@ function approvalFields(a: Approval): [string, unknown][] {
315
317
  export function permissionText(a: Pick<Approval, "id" | "peer" | "title" | "options">): string {
316
318
  return ` ? ${a.peer} asks permission: ${String(a.title).replace(/\n/g, "\n | ")}\n answer with: ahub permit ${a.id} <${a.options.map(o => `${quoted(o.optionId)} (${quoted(o.name)})`).join(", ")}> | deny`;
317
319
  }
320
+ /** #441: Home/End as terminals send them, plus `<` and `>` for keyboards without those keys. */
321
+ export const FIRST_KEYS = ["<", "\x1b[H", "\x1bOH", "\x1b[1~", "\x1b[7~"];
322
+ export const LAST_KEYS = [">", "\x1b[F", "\x1bOF", "\x1b[4~", "\x1b[8~"];
318
323
  const KEYS: [string, string[]][] = [
319
324
  ["Everywhere", ["Tab stream/panels", ": command", "? keys", "Esc back", "q quit"]],
320
325
  ["Command", ["Enter run", "Esc cancel", "Up/Down history", "Ctrl-U clear"]],
321
- ["Panels", ["1-5 panel", "Left/Right panel", "j/k or Up/Down move", "Enter view", "j/k scroll a view"]],
326
+ ["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", "o pet window"]],
322
327
  ["Approvals", ["a allow (then y)", "d deny", "v view", "[ ] select"]],
323
- ["Peers", ["p pause", "r resume", "m permission mode"]],
328
+ ["Peers", ["p pause", "r resume", "m permission mode", "l make planner (then y)"]],
324
329
  ["Tasks", ["a assign/reviewer (then y)", "u unassign (then y)", "r review (in review)"]],
325
330
  ["Queue", ["r resolve (reason, then y)", "queued=discard, needs_review=retry"]],
326
331
  ["Events", ["f peer filter", "g kind filter"]],
@@ -354,7 +359,7 @@ function bound(s: ConsoleState): ConsoleState {
354
359
  if (s.requestDetail && s.requestDetail !== s.approvalId) { s.requestDetail = s.approvalId; s.detailOffset = 0; }
355
360
  return s;
356
361
  }
357
- const COMMANDS = new Set(["status", "board", "task", "review", "say", "pause", "resume", "budget", "queue", "permit", "permission", "ask", "remember", "route", "sdlc", "turns", "undo", "check-path", "report"]);
362
+ const COMMANDS = new Set(["status", "board", "task", "review", "say", "pause", "resume", "budget", "queue", "permit", "permission", "ask", "remember", "route", "sdlc", "turns", "undo", "check-path", "report", "ui", "pet"]);
358
363
  /** A tiny argv parser, never a shell. Quotes group arguments; backslash escapes one character. */
359
364
  export function parseConsoleCommand(input: string): string[] {
360
365
  const words: string[] = []; let word = ""; let quote = ""; let escaped = false; let started = false;
@@ -374,7 +379,7 @@ export function parseConsoleCommand(input: string): string[] {
374
379
  }
375
380
  export function commandNeedsConfirmation(args: string[]): boolean {
376
381
  const positional = args.filter(arg => arg !== "--yes");
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");
382
+ return (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");
378
383
  }
379
384
  export function panelRows(s: ConsoleState): any[] {
380
385
  if (s.panel === 1) return Object.entries(s.peers).map(([id, peer]) => ({ id, ...peer, budget: s.budget[id] }));
@@ -386,6 +391,15 @@ export function panelRows(s: ConsoleState): any[] {
386
391
  if (s.panel === 4) return s.queue.filter(q => q.state === "needs_review" || q.state === "queued");
387
392
  return s.events.filter(e => (!s.peerFilter || e.peer === s.peerFilter) && (!s.kindFilter || e.kind === s.kindFilter));
388
393
  }
394
+ /** #441: Events follow the newest row while the selection is on the last one; a row the person moved to stays put. */
395
+ export function appendEvent(s: ConsoleState, event: ConsoleEvent, cap = 1000): void {
396
+ if (s.panel !== 5) { s.events = [...s.events, event].slice(-cap); return; }
397
+ const before = panelRows(s), held = before[s.selection], following = !held || s.selection >= before.length - 1;
398
+ s.events = [...s.events, event].slice(-cap);
399
+ const rows = panelRows(s);
400
+ s.selection = following ? Math.max(0, rows.length - 1) : Math.max(0, rows.indexOf(held));
401
+ }
402
+ const newestEvent = (s: ConsoleState) => Math.max(0, panelRows(s).length - 1);
389
403
  export const selectedApproval = (s: ConsoleState) => s.approvals.find(a => a.id === s.approvalId);
390
404
  /** The selected row: approvals and refreshed Tasks/Queue rows are bound by id, never by a replacement row. */
391
405
  const selection = (s: ConsoleState) => {
@@ -412,6 +426,7 @@ function lockedCommandPrompt(lock: NonNullable<ConsoleState["actionLock"]>, args
412
426
  const action = commandFlag(args, "--action") ?? "resolve";
413
427
  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
428
  }
429
+ if (lock.prefix[1] === "cancel") return `cancel task #${lock.id} at revision ${lock.revision}; native turns and worktrees are retained`;
415
430
  const role = commandFlag(args, "--role") ?? "owner";
416
431
  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
432
  const affected = role === "owner" ? [lock.owner, lock.reserved].filter(Boolean).join(" and ") : lock.reviewer ?? "none";
@@ -530,7 +545,7 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
530
545
  }
531
546
  if (key === "\t") { s.mode = s.mode === "panels" ? "stream" : "panels"; s.detail = undefined; s.requestDetail = undefined; return done(); }
532
547
  if (key === ":") { s.editing = true; 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(); };
548
+ 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(); };
534
549
  if (s.mode === "panels" && /^[1-5]$/.test(key) && !s.optionChoice) return switchPanel(Number(key));
535
550
  // Right and Left are the digit of the next or previous tab, wrapping at both ends (#309); the same guards as the
536
551
  // digits apply (the confirm, mode-choice, key-table and editing branches above returned already).
@@ -541,6 +556,19 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
541
556
  return done();
542
557
  }
543
558
  if (key === "\x1b") { s.detail = undefined; s.requestDetail = undefined; s.help = false; s.optionChoice = undefined; return done(); }
559
+ // #452: open the project dashboard or the pet window as a person's console command; both return at once.
560
+ if (s.mode === "panels" && (key === "w" || key === "o") && !s.optionChoice) {
561
+ effects.push({ type: "command", args: key === "w" ? ["ui"] : ["pet", "--window"] });
562
+ return done();
563
+ }
564
+ if (s.mode === "panels" && (FIRST_KEYS.includes(key) || LAST_KEYS.includes(key))) {
565
+ const first = FIRST_KEYS.includes(key);
566
+ if (s.detail !== undefined || s.requestDetail) { if (first) s.detailOffset = 0; return done(); }
567
+ s.selection = first ? 0 : Math.max(0, panelRows(s).length - 1);
568
+ if (s.panel === 2) s.approvalId = s.approvals[s.selection]?.id;
569
+ captureConsoleSelection(s, true);
570
+ return done();
571
+ }
544
572
  if (s.mode === "panels" && ["j", "k", "\x1b[A", "\x1b[B"].includes(key)) {
545
573
  if (s.detail !== undefined || s.requestDetail) { s.detailOffset = Math.max(0, s.detailOffset + (["j", "\x1b[B"].includes(key) ? 1 : -1)); return done(); }
546
574
  s.selection = Math.max(0, Math.min(panelRows(s).length - 1, selection(s) + (["j", "\x1b[B"].includes(key) ? 1 : -1)));
@@ -595,9 +623,25 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
595
623
  else s.modeChoice = peer;
596
624
  return done();
597
625
  }
626
+ // #455: the planner is a setting; the command runs only after y, as a person's terminal command.
627
+ if (s.panel === 1 && key === "l") {
628
+ const peer = String(item);
629
+ if (!(PLANNER_PEERS as readonly string[]).includes(peer)) notify(s, `${peer} cannot plan: the planner is one of ${PLANNER_PEERS.join(", ")}`, now);
630
+ else if (s.peers[peer]?.planner === "setting") notify(s, `${peer} is already the pinned planner`, now, "attention");
631
+ else s.confirm = { type: "command", args: ["settings", "set", "planner", peer], prompt: `make ${peer} the planner` };
632
+ return done();
633
+ }
598
634
  const prefill = s.panel === 1 && key === "p" ? `pause ${item}` : s.panel === 1 && key === "r" ? `resume ${item}` :
599
635
  s.panel === 3 && key === "r" && selected.state === "in_review" ? `review ${item} ` : undefined;
600
636
  if (prefill) { s.input = prefill; s.editing = true; return done(); }
637
+ if (s.panel === 3 && key === "c") {
638
+ if (!s.features?.includes("task_cancel")) { notify(s, "task cancellation requires a newer hub", now); return done(); }
639
+ const revision = selected.flowRevision;
640
+ if (!["proposed", "in_progress", "in_review", "changes_requested", "backlog"].includes(selected.state)) return done();
641
+ if (!Number.isSafeInteger(revision) || revision < 0) { notify(s, "task row actions need flowRevision; upgrade the hub", now); return done(); }
642
+ s.actionLock = { prefix: ["task", "cancel"], id: String(item), revision, state: String(selected.state) };
643
+ s.input = `task cancel ${item} --revision ${revision} --reason `; s.editing = true; return done();
644
+ }
601
645
  if (s.panel === 3 && (key === "a" || key === "u")) {
602
646
  if (!["proposed", "in_progress", "in_review", "changes_requested"].includes(selected.state)) return done();
603
647
  const revision = selected.flowRevision, role = selected.state === "in_review" || key === "u" && !selected.owner && !selected.reserved && selected.reviewer ? "reviewer" : "owner";
@@ -624,7 +668,7 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
624
668
  const values = [...new Set(s.events.map(e => e[field]).filter((x): x is string => !!x))];
625
669
  const next = values[values.indexOf(current ?? "") + 1];
626
670
  if (key === "f") s.peerFilter = next; else s.kindFilter = next;
627
- s.selection = 0; return done();
671
+ s.selection = newestEvent(s); return done();
628
672
  }
629
673
  if (s.mode === "stream" && !key.startsWith("\x1b")) { s.editing = true; s.input = terminalText(key).replace(/\n/g, ""); }
630
674
  return done();
@@ -652,7 +696,7 @@ export function cells(s: ConsoleState, row: any, selected: boolean, now: number,
652
696
  const paused = b?.paused ? `budget ${relative(b.paused.resetsAt, now)}` : row.paused && typeof row.paused === "object"
653
697
  ? `${row.paused.by ?? "paused"}${typeof row.paused.at === "number" ? ` ${duration(now - row.paused.at)}` : ""}`
654
698
  : row.paused === "manual" ? "user" : row.paused ? String(row.paused).split(":")[0] : "-";
655
- return [id(row.id, row.state === "offline" ? undefined : "info"), span(row.rostered === false ? `${row.state} off roster` : row.state, stateTone(row.state)),
699
+ return [id(row.planner ? `${row.id} (planner)` : row.id, row.state === "offline" ? undefined : "info"), span(row.rostered === false ? `${row.state} off roster` : row.state, stateTone(row.state)),
656
700
  ...(showPermissionModes(s) ? [span(row.permissionMode && row.permissionMode !== "ask" ? row.permissionMode : "-", row.permissionMode === "never-ask" ? "attention" : undefined)] : []),
657
701
  span(row.pullOnly && row.id === "kimi" ? "pull-only" : row.toolsOnly ? "tools-only" : row.attached === false ? "detached" : "attached", row.toolsOnly ? "attention" : undefined),
658
702
  span(count(row.queued)), span(count(row.queuedImportant), row.queuedImportant ? "attention" : undefined),
@@ -666,9 +710,9 @@ export function cells(s: ConsoleState, row: any, selected: boolean, now: number,
666
710
  const at = last?.at ?? row.updated ?? row.created;
667
711
  const failed = last?.event === "check failed" || last?.event === "failed";
668
712
  const stage = stages?.get(row.id);
669
- const meter = stage ? "[" + "#".repeat(stage.stage - (stage.changes ? 1 : 0)) + (stage.changes ? "!" : "") + "-".repeat(4 - stage.stage) + "]" : "[----]";
670
- return [id(`#${row.id}`, "info"), span(`${row.state}${failed && row.state !== last.event ? ` ${last.event}` : ""}${row.ready ? " ready" : ""}${stage?.waiting ? " waiting" : ""}`, failed ? "failure" : row.ready || stage?.waiting ? "attention" : stateTone(row.state)),
671
- span(row.owner ?? "-"), span(row.reviewer ?? "-"), span(row.profileClass ? `${row.profileClass} (base ${row.class})` : row.class ?? "-"), span(typeof at === "number" ? duration(now - at) : "-", "muted"), span(row.title), span(row.lifecycle ? `${meter} ${row.lifecycle.workflow ? row.lifecycle.workflow + " " : ""}SDLC ${row.lifecycle.stage}${row.lifecycle.definition ? ` (base ${row.lifecycle.definition.base}) / next: ${row.lifecycle.actor} / entry: ${row.lifecycle.entry?.length ? "blocked" : "ready"} / exit: ${row.lifecycle.exit?.length ? "blocked" : "ready"}` : ""}: ${row.lifecycle.next}` : meter, stage?.changes ? "failure" : stateTone(row.state))];
713
+ const meter = stage?.cancelled ? "[----] cancelled" : stage ? "[" + "#".repeat(stage.stage - (stage.changes ? 1 : 0)) + (stage.changes ? "!" : "") + "-".repeat(4 - stage.stage) + "]" : "[----]";
714
+ return [id(`#${row.id}`, "info"), span(`${row.state}${s.attentionFacts?.some(fact => fact.kind === "stall" && fact.id === row.id) ? " stalled" : ""}${failed && row.state !== last.event ? ` ${last.event}` : ""}${row.ready ? " ready" : ""}${stage?.waiting ? " waiting" : ""}`, failed ? "failure" : row.ready || stage?.waiting ? "attention" : stateTone(row.state)),
715
+ span(row.owner ?? "-"), span(row.reviewer ?? "-"), span(row.profileClass ? `${row.profileClass} (base ${row.class})` : row.class ?? "-"), span(typeof at === "number" ? duration(now - at) : "-", "muted"), span(row.title), span(row.lifecycle ? `${meter} ${row.lifecycle.workflow ? row.lifecycle.workflow + " " : ""}SDLC ${row.lifecycle.stage}${row.lifecycle.definition ? ` (base ${row.lifecycle.definition.base}) / next: ${row.lifecycle.actor} / entry: ${row.lifecycle.entry?.length ? "blocked" : "ready"} / exit: ${row.lifecycle.exit?.length ? "blocked" : "ready"}` : ""}: ${row.lifecycle.next}` : meter, stage?.cancelled ? "muted" : stage?.changes ? "failure" : stateTone(row.state))];
672
716
  }
673
717
  if (s.panel === 4) return [id(row.id, "info"), span(row.peer), span(row.rostered === false ? `${row.state} off roster` : row.state, stateTone(row.state)), span(row.revision ?? "-"), span(typeof row.createdAt === "number" ? duration(now - row.createdAt) : "-", "muted")];
674
718
  const [header, ...body] = String(row.text).split("\n");
@@ -715,7 +759,7 @@ function taskHints(row: any): string[] {
715
759
  function panelContext(s: ConsoleState, row: any): string[] {
716
760
  if (!row) return [];
717
761
  if (s.panel === 1) return ["p pause", "r resume", "m permission mode"];
718
- if (s.panel === 3) return taskHints(row);
762
+ if (s.panel === 3) return [...taskHints(row), ...(s.features?.includes("task_cancel") ? ["c cancel"] : [])];
719
763
  if (s.panel === 4) return [row.state === "queued" ? "r discard" : "r resolve"];
720
764
  return [];
721
765
  }
@@ -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, permissionText, plural, pruneApprovals, reduceConsole, renderConsoleLines, resolveColor, stateTone, streamTokens, wrapStreamTokens, terminalText, wrap, captureConsoleSelection, selectedPanelRow } 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, appendEvent, FIRST_KEYS, LAST_KEYS } 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";
@@ -34,6 +34,17 @@ export interface ConsoleOptions {
34
34
  runCommand?: (args: string[], output: (text: string) => void, done: () => void, columns?: number) => (() => void);
35
35
  pollMs?: number;
36
36
  }
37
+ /** Exact navigation sequences only (#309, #441); anything else in a multi-character read is a paste. */
38
+ const NAVIGATION = ["\x1b[A", "\x1b[B", "\x1b[C", "\x1b[D", ...FIRST_KEYS, ...LAST_KEYS].filter(key => key.startsWith("\x1b"));
39
+ export function navigationPresses(text: string): string[] | undefined {
40
+ const keys: string[] = [];
41
+ for (let at = 0; at < text.length;) {
42
+ const key = NAVIGATION.find(k => text.startsWith(k, at));
43
+ if (!key) return undefined;
44
+ keys.push(key); at += key.length;
45
+ }
46
+ return keys.length ? keys : undefined;
47
+ }
37
48
  export const RESTORE_CONSOLE = "\x1b[?1049l\x1b[r\x1b[0m\x1b[?25h";
38
49
  /** Console-only styling uses event structure; tail text and message bodies are never parsed for meaning. */
39
50
  export function eventTone(e: BusEvent): Tone | undefined {
@@ -103,7 +114,7 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
103
114
  /** `record: false` is for the console's own prints (the key table, a request viewed again): not events, so not in Events. */
104
115
  const stream = (event: ConsoleEvent, record = true) => {
105
116
  if (event.kind === "command") event = { ...event, text: terminalText(event.text).replace(/\t/g, " ") };
106
- if (record) state.events = [...state.events, { ...event, text: terminalText(event.text) }].slice(-1000);
117
+ if (record) appendEvent(state, { ...event, text: terminalText(event.text) });
107
118
  if (plain) {
108
119
  const [header, ...body] = terminalText(event.text).split("\n");
109
120
  terminal.write(paint([...(color ? streamTokens(header ?? "", event.tone, event.kind) : [{ text: header ?? "" }]), ...body.map(text => ({ text: "\n" + text }))], color) + "\n");
@@ -183,6 +194,7 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
183
194
  const [status, budget, tasks, queue, ready] = replies;
184
195
  captureConsoleSelection(state);
185
196
  if (status.status?.tasks && status.ok !== false) state.taskCounts = status.status.tasks;
197
+ if (status.status) state.features = Array.isArray(status.status.features) ? status.status.features : [];
186
198
  if (status.status?.peers) state.peers = Object.fromEntries(Object.entries(status.status.peers).map(([peer, value]) => [peer, { ...(value as object), ...status.status.roleInfo?.[peer] }]));
187
199
  if (status.status && status.ok !== false) {
188
200
  state.attentionFacts = attentionFacts(status.status.attentionItems);
@@ -299,10 +311,11 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
299
311
  terminal.raw(true);
300
312
  removers.push(terminal.onData(text => {
301
313
  // A pasted chunk can only edit text, never approve, confirm or run commands. Bracketed-paste delimiters are stripped.
302
- // Under the modal key table it does nothing at all. A read holding nothing but arrow sequences is that many
303
- // key presses (key repeat, a slow link), not a paste (#309): each arrow reaches the reducer on its own.
304
- if (Array.from(text).length > 1 && !["\x1b[A", "\x1b[B", "\x1b[C", "\x1b[D"].includes(text)) {
305
- if (/^(?:\x1b\[[ABCD])+$/.test(text)) { for (const key of text.match(/\x1b\[[ABCD]/g)!) handle(key); }
314
+ // Under the modal key table it does nothing at all. A read holding nothing but arrow or Home/End sequences is
315
+ // that many key presses (key repeat, a slow link), not a paste (#309, #441): each reaches the reducer on its own.
316
+ const presses = navigationPresses(text);
317
+ if (Array.from(text).length > 1 && presses?.length !== 1) {
318
+ if (presses) { for (const key of presses) handle(key); }
306
319
  else {
307
320
  state.attentionMessages = []; state.attentionSelection = undefined;
308
321
  if (state.help && state.mode === "panels") { draw(); return; }
@@ -82,6 +82,7 @@ export function cliCommandLabel(command: string, args: readonly string[]): strin
82
82
  return (/^[a-z][a-z0-9-]*$/.test(command) && !/\s/.test(command)) || ["--help", "--version"].includes(command) ? command : "unknown";
83
83
  }
84
84
 
85
- export function peerCommandRefusal(peer: string, command: string): string {
86
- return `${peer} cannot run ahub ${command}; the person runs it in ahub console or a terminal`;
85
+ export function peerCommandRefusal(peer: string, command: string, env: Readonly<Record<string, string | undefined>> = process.env): string {
86
+ const markers = ["CLAUDECODE", "CLAUDE_CODE_SESSION_ID", "AGENTHUB_PEER_ID", "CODEX_THREAD_ID"].filter(name => env[name] !== undefined);
87
+ return `${peer} cannot run ahub ${command}; the person runs it in ahub console or a terminal. Refused because inherited agent markers${markers.length ? ` (${markers.join(", ")})` : ""} identify this shell as an agent. A ! command in an agent session runs as that agent; use a separate terminal outside the agent session`;
87
88
  }
@@ -21,7 +21,7 @@ const map = (doc: Document): Document => (doc.mcpServers ?? doc.servers ?? {}) a
21
21
  const owned = (entry: unknown, cwd: string): entry is Document => object(entry) && object(entry.env) && entry.env.AGENTHUB_MODE === "tools" && entry.env.AGENTHUB_PEER_ID === "jcode" && entry.env.AGENTHUB_PROJECT_DIR === cwd;
22
22
 
23
23
  export function jcodeMcpEntry(cwd: string, stateDir: string, bundle: string) {
24
- return { command: "bun", args: ["run", bundle], shared: false, env: { AGENTHUB_MODE: "tools", AGENTHUB_PEER_ID: "jcode", AGENTHUB_STATE_DIR: stateDir, AGENTHUB_PROJECT_DIR: cwd } };
24
+ return { command: "bun", args: ["run", bundle], shared: false, env: { AGENTHUB_MODE: "tools", AGENTHUB_DELIVERY_ONLY: "true", AGENTHUB_PEER_ID: "jcode", AGENTHUB_STATE_DIR: stateDir, AGENTHUB_PROJECT_DIR: cwd } };
25
25
  }
26
26
  /** The native loader reads later project files last. Never shadow a foreign definition. */
27
27
  function inspect(cwd: string): Document {
package/src/cli/main.ts CHANGED
@@ -353,6 +353,7 @@ function fail(message: string): never {
353
353
  async function taskOp(op: string, a: Record<string, unknown>, throwOnError = false, connection?: ControlClient): Promise<string> {
354
354
  const hub = connection ?? await connect();
355
355
  try {
356
+ if (op === "hub_task_cancel" && !hub.features.includes("task_cancel")) fail("this daemon does not support task cancellation; upgrade it first");
356
357
  const res = await hub.request({ t: "task", op, args: a }, op === "github_import" ? 60_000 : 30_000);
357
358
  if (!res.ok) { if (throwOnError) throw new Error(res.error); audit("refused"); fail(res.error); }
358
359
  return res.text;
@@ -1189,6 +1190,12 @@ const commands: Record<string, () => Promise<void> | void> = {
1189
1190
  finally { hub.close(); }
1190
1191
  return;
1191
1192
  }
1193
+ if (sub === "cancel") {
1194
+ const { one, rest: positional } = takeFlags(rest, ["--reason", "--superseded-by", "--revision"], []);
1195
+ const revision = one["--revision"] === undefined ? undefined : Number(one["--revision"]);
1196
+ if (positional.length !== 1 || !/^[1-9]\d*$/.test(positional[0]!) || !one["--reason"]?.trim() || (revision !== undefined && (!/^\d+$/.test(one["--revision"]!) || !Number.isSafeInteger(revision)))) fail("usage: ahub task cancel <id> --reason <text> [--superseded-by <ref>] [--revision N]");
1197
+ return console.log(await taskOp("hub_task_cancel", { id: Number(positional[0]), reason: one["--reason"], ...(one["--superseded-by"] === undefined ? {} : { superseded_by: one["--superseded-by"] }), ...(revision === undefined ? {} : { revision }) }));
1198
+ }
1192
1199
  if (sub === "show") return console.log(await taskOp("task_show", { id: rest[0] }));
1193
1200
  if (sub === "escalate") return console.log(await taskOp("task_escalate", { id: rest[0] }));
1194
1201
  if (sub === "assign") {
@@ -1214,7 +1221,7 @@ const commands: Record<string, () => Promise<void> | void> = {
1214
1221
  appendRecords(project.id, [{ schema: RESEARCH_SCHEMA, kind: "label", project: projectKey(project.id), task: id, createdAt: record!.createdAt!, label, at: new Date().toISOString() }]);
1215
1222
  return console.log(`task #${id} (approved ${record!.approvedAt}) labelled ${label} in the research records`);
1216
1223
  }
1217
- if (sub !== "propose" || rest.length < 1) fail("usage: ahub task propose [<class>] <title...> | show <id> | assign <id> <peer> | unassign <id> | escalate <id>");
1224
+ if (sub !== "propose" || rest.length < 1) fail("usage: ahub task propose [<class>] <title...> | show <id> | assign <id> <peer> | unassign <id> | cancel <id> --reason <text> | escalate <id>");
1218
1225
  // `--class` is the explicit form. A first word that is a class name is still taken as the class (the documented
1219
1226
  // short form), but said out loud: "review the auth module" would otherwise be filed as class review, silently.
1220
1227
  const urgent = rest.includes("--urgent"), backlog = rest.includes("--backlog");
package/src/cli/output.ts CHANGED
@@ -7,9 +7,12 @@ import { formatReport, formatTaskReport, type Report, type TaskReport } from "..
7
7
 
8
8
  export interface DoctorCheck { section: "Tools" | "Hub" | "Config" | "Models" | "Memory"; level: "ok" | "warn" | "fail" | "unknown"; name: string; detail: string }
9
9
  export interface StatusOutput {
10
+ taskStalls?: readonly { task: number; cause: { kind: string; peer?: string; dependency?: number; code: string } }[];
11
+ pendingTaskOffers?: readonly { task: number; peer: string }[];
12
+ isolatedChecks?: { pending?: number; orphans?: string[]; unknownReceipts?: readonly string[]; maintenanceUnavailable?: boolean };
10
13
  memoryProfile?: { profile?: string; generation?: string; state?: string; agentMemVersion?: string; skippedRows?: number; error?: string };
11
14
  taskWip?: Record<string, { wip: number; wipLimit: number }>;
12
- pid?: number; controlPort?: number; cwd?: string; version?: string; peers: Record<string, any>; roleInfo?: Record<string, any>; budget?: Record<string, any>;
15
+ pid?: number; controlPort?: number; cwd?: string; version?: string; peers: Record<string, any>; roleInfo?: Record<string, any>; budget?: Record<string, any>; planner?: { peers?: string[]; source?: string };
13
16
  tasks?: Record<string, number>; models?: { backends?: any[] }; permissionDefaults?: any[]; deliveryError?: string; crash?: string[]; switchyard?: string;
14
17
  }
15
18
  export interface BudgetOutput { budget: Record<string, any>; gate?: number }
@@ -185,14 +188,20 @@ export function renderStatus(data: StatusOutput, columns?: number, now = Date.no
185
188
  if (data.roleInfo) data = { ...data, peers: Object.fromEntries(Object.entries(data.peers).map(([peer, value]) => [peer, { ...value, ...data.roleInfo?.[peer] }])) };
186
189
  const out = lines(`agent-hub${data.version ? ` ${data.version}` : ""} pid ${data.pid ?? "unknown"} control 127.0.0.1:${data.controlPort ?? "unknown"}`, columns, "info");
187
190
  if (data.cwd) out.push(...lines(data.cwd, columns));
191
+ for (const stall of data.taskStalls ?? []) out.push(...detail(`task #${stall.task} stalled`, `${stall.cause.kind}${stall.cause.peer ? ` ${stall.cause.peer}` : ""}${stall.cause.dependency ? ` #${stall.cause.dependency}` : ""}: ${stall.cause.code}; configure the peer, ahub task assign ${stall.task} <peer>, or ahub task cancel ${stall.task} --reason <text>`, columns, "attention"));
192
+ for (const offer of data.pendingTaskOffers ?? []) out.push(...detail("task offer", `open ${offer.peer}: task #${offer.task} is waiting for it`, columns, "attention"));
188
193
  if (data.memoryProfile) {
189
194
  const memory = data.memoryProfile;
190
195
  const profile = memory.profile === "generation" ? `generation ${memory.generation?.slice(0, 8) ?? "unknown"}` : memory.profile ?? "unbound";
191
196
  out.push(...detail("memory", `${profile}; ${memory.state ?? "unknown"}; agent-mem ${memory.agentMemVersion ?? "unknown"}${memory.skippedRows ? `; malformed legacy rows skipped: ${memory.skippedRows}` : ""}${memory.error ? `; ${memory.error}` : ""}`, columns, memory.state === "unavailable" ? "attention" : undefined));
192
197
  }
198
+ if (data.planner) out.push(...detail("planner", `${data.planner.peers?.join(", ") || "nobody"} (${data.planner.source ?? "unknown"})${data.planner.source === "automatic" || data.planner.source === "none" ? "; pin it with ahub settings set planner <peer>" : ""}`, columns, data.planner.source === "none" ? "attention" : undefined));
193
199
  if (data.deliveryError) out.push(...detail("delivery storage", `${data.deliveryError}; dispatch is stopped`, columns, "failure"));
194
200
  for (const pending of data.permissionDefaults ?? []) out.push(...detail("permission default", `${pending.peer}: never-ask from ${pending.source}, stays ask until a person confirms in ahub console`, columns, "attention"));
195
201
  for (const crash of data.crash ?? []) out.push(...detail("crash recovery", crash, columns, "attention"));
202
+ for (const warning of data.isolatedChecks?.orphans ?? []) out.push(...detail("isolated check warning", warning, columns, "attention"));
203
+ for (const id of data.isolatedChecks?.unknownReceipts ?? []) out.push(...detail("check receipt", `${id}: unknown; receipt storage unavailable`, columns, "attention"));
204
+ if (data.isolatedChecks?.maintenanceUnavailable) out.push(...detail("check maintenance", "unavailable; saved run outcomes are unchanged", columns, "attention"));
196
205
  const s = initialConsoleState(); s.panel = 1; s.peers = data.peers; s.budget = data.budget ?? {};
197
206
  const head = Object.values(data.peers).some(p => p.permissionMode && p.permissionMode !== "ask") ? ["PEER", "STATE", "MODE", ...TABLES[1]!.slice(2)] : [...TABLES[1]!];
198
207
  head.push("CONTEXT");
@@ -206,7 +215,9 @@ export function renderStatus(data: StatusOutput, columns?: number, now = Date.no
206
215
  if (p.flowRoles) out.push(...detail(`${id} flow roles`, p.flowRoles.join(", ") || "none", columns));
207
216
  if (p.catalogRoles) { out.push(...detail(`${id} catalog roles`, Array.isArray(p.catalogRoles.value) ? p.catalogRoles.value.join(", ") || "none" : "unconfirmed", columns)); if (p.catalogRoles.pending !== undefined) out.push(...detail(`${id} pending roles`, p.catalogRoles.pending.join(", ") || "none", columns, "attention")); }
208
217
  if (p.liveAccepted?.length) { settling = true; out.push(...detail("settling", p.liveAccepted.map((v: string) => shortId(v, columns, full)).join(", "), columns, "attention")); }
209
- if (p.heldBy) {
218
+ if (p.heldRows?.length) {
219
+ for (const row of p.heldRows) out.push(...detail("held", `${id}: needs_review ${shortId(row.id, columns, full)}; from ${row.senders.join(",")}; kind ${row.kinds.join(",")}; age ${duration(Math.max(0, now - row.createdAt))}${row.context ? `; ${row.context}` : ""}; ahub queue show ${row.id}`, columns, "failure"));
220
+ } else if (p.heldBy) {
210
221
  const note = flat(p.holdNote ?? "");
211
222
  const command = `ahub queue resolve ${p.heldBy} --action completed|retry|discard --reason <text>`;
212
223
  const normalized = note.startsWith(`held by needs_review ${p.heldBy}`)
@@ -1,3 +1,4 @@
1
+ import { restoreReceiptStatus } from "./upgrade-restore-state.ts";
1
2
  import { recoveryArgv } from "../hub/recovery-store.ts";
2
3
  import { cancellableWait, nextChoices, publicOperation, type Inspection, type PlannedProject, type RecoveryOperation, type RecoveryPeer, type UpgradePlan } from "./upgrade.ts";
3
4
 
@@ -108,7 +109,7 @@ export function operationLines(op: RecoveryOperation, runner: number | "unknown"
108
109
  const held = runner === "unknown" ? "; whether a runner holds it cannot be read" : runner ? `; runner ${runner} is working` : op.phase === "running" ? "; no runner holds it (the last one stopped mid-step)" : "";
109
110
  const lines = [`${op.plan.kind} to ${op.plan.version}: operation ${op.id}`, ` ${op.phase} at "${stepLabel(op.step)}", updated ${ago(now - op.updatedAt)} ago${held}`];
110
111
  for (const p of op.projects) {
111
- const effects = Object.entries(p.terminals).map(([key, value]) => value === "pending" || value === "failed" ? `${key} ${value}` : key);
112
+ const effects = Object.entries(p.terminals).map(([key, value]) => key.startsWith("restored:") ? `${key} ${restoreReceiptStatus(value)}` : value === "pending" || value === "failed" ? `${key} ${value}` : key);
112
113
  lines.push(` ${p.id} ${p.phase}${effects.length ? ` (${effects.join(", ")})` : ""}${p.restarts?.length ? ` restarts ${p.restarts.length}` : ""}`);
113
114
  }
114
115
  // The error ends with the commands status would print; the menu below offers them instead.
@@ -0,0 +1,133 @@
1
+ import type { TerminalBinding } from "./terminal-recovery.ts";
2
+
3
+ export type RestoreReceipt =
4
+ | { state: "pending" }
5
+ | { state: "waiting"; origin: "pending" | "failed"; binding: TerminalBinding }
6
+ | { state: "done"; binding: TerminalBinding }
7
+ | { state: "failed" };
8
+ export interface RestoreEvidence {
9
+ target: "running" | "stopped" | "changed";
10
+ launcher: "live" | "gone" | "unknown" | "no-record-yet" | "replaced" | "older-instance";
11
+ session: "planned" | "other" | "none";
12
+ allowOther: boolean;
13
+ /** The original or verified selected terminal when a bare receipt first acquires a binding. */
14
+ binding?: TerminalBinding;
15
+ }
16
+ export type RestoreAction = "poll" | "settle" | "revalidate" | "block" | "fail" | "create";
17
+ export interface RestoreTransition { next: RestoreReceipt; action: RestoreAction }
18
+ export type RestoreReceiptStatus = "unstarted" | "pending" | "waiting:pending" | "waiting:failed" | "done" | "failed";
19
+
20
+ function malformed(): never { throw new Error("invalid restore receipt"); }
21
+ const object = (value: unknown): value is Record<string, unknown> => !!value && typeof value === "object" && !Array.isArray(value);
22
+ const text = (value: unknown): value is string => typeof value === "string" && value.length > 0;
23
+ function terminalBinding(value: unknown): TerminalBinding {
24
+ if (!object(value) || typeof value.peer !== "string" || !["codex", "claude", "pi"].includes(value.peer)) malformed();
25
+ for (const key of ["handle", "incarnationId", "worktreeId", "projectRoot", "sessionId"]) if (!text(value[key])) malformed();
26
+ for (const key of ["sessionFile", "backend", "model"]) if (value[key] !== undefined && typeof value[key] !== "string") malformed();
27
+ const launch = value.launch;
28
+ const validLaunch = (v: unknown) => object(v) && text(v.packageEntrypoint) && text(v.command)
29
+ && Array.isArray(v.argv) && v.argv.every(arg => typeof arg === "string")
30
+ && object(v.env) && Object.values(v.env).every(entry => typeof entry === "string");
31
+ if (!validLaunch(launch) || value.launchMetadata !== undefined && !validLaunch(value.launchMetadata)) malformed();
32
+ return { ...value, launchMetadata: value.launchMetadata ?? launch } as unknown as TerminalBinding;
33
+ }
34
+
35
+ /** Decode legacy representations only here. Absence is unstarted, never pending or failed. */
36
+ export function decodeRestoreReceipt(value: unknown): RestoreReceipt | undefined {
37
+ if (value === undefined) return undefined;
38
+ if (value === "pending" || value === "failed") return { state: value };
39
+ if (!object(value)) malformed();
40
+ if (Object.hasOwn(value, "state")) {
41
+ const allowed = value.state === "waiting" ? ["state", "origin", "binding"] : value.state === "done" ? ["state", "binding"] : ["state"];
42
+ if (Object.keys(value).some(key => !allowed.includes(key))) malformed();
43
+ switch (value.state) {
44
+ case "pending": case "failed": return { state: value.state };
45
+ case "waiting": {
46
+ if (value.origin !== "pending" && value.origin !== "failed") malformed();
47
+ return { state: "waiting", origin: value.origin, binding: terminalBinding(value.binding) };
48
+ }
49
+ case "done": return { state: "done", binding: terminalBinding(value.binding) };
50
+ default: return malformed();
51
+ }
52
+ }
53
+ if (Object.hasOwn(value, "attachmentReceipt")) malformed();
54
+ return { state: "done", binding: terminalBinding(value) };
55
+ }
56
+
57
+ export function restoreReceiptStatus(value: unknown): RestoreReceiptStatus {
58
+ const receipt = decodeRestoreReceipt(value);
59
+ return !receipt ? "unstarted" : receipt.state === "waiting" ? `waiting:${receipt.origin}` : receipt.state;
60
+ }
61
+ export function canChooseFresh(value: unknown): boolean {
62
+ const receipt = decodeRestoreReceipt(value);
63
+ return receipt?.state === "failed" || receipt?.state === "waiting" && receipt.origin === "failed";
64
+ }
65
+
66
+ /** Pure decision only: settlement/revalidation must succeed before the runtime persists its proposed next receipt. */
67
+ export function restoreTransition(receipt: RestoreReceipt, evidence: RestoreEvidence): RestoreTransition {
68
+ const keep = (action: RestoreAction): RestoreTransition => ({ next: receipt, action });
69
+ if (evidence.target !== "running") return keep("block");
70
+ // A chosen waiting terminal cannot be replaced by another identity, even if a session has attached meanwhile.
71
+ if (receipt.state === "waiting" && ["unknown", "replaced", "older-instance"].includes(evidence.launcher)) return keep("block");
72
+ if (evidence.session === "other" && (!evidence.allowOther || receipt.state === "done")) return keep("block");
73
+ if (evidence.session === "planned" || evidence.session === "other" && evidence.allowOther) {
74
+ if (receipt.state === "done") return keep("revalidate");
75
+ const binding = receipt.state === "waiting" ? receipt.binding : evidence.binding;
76
+ return binding ? { next: { state: "done", binding }, action: "settle" } : keep("block");
77
+ }
78
+ if (receipt.state === "done") return keep("block");
79
+ if (evidence.launcher === "unknown" || evidence.launcher === "replaced" || evidence.launcher === "older-instance") return keep("block");
80
+ if (receipt.state === "waiting" && (evidence.launcher === "live" || evidence.launcher === "no-record-yet")) return keep("poll");
81
+ const origin = receipt.state === "waiting" ? receipt.origin : receipt.state;
82
+ if (evidence.launcher === "live") return evidence.binding
83
+ ? { next: { state: "waiting", origin, binding: evidence.binding }, action: "poll" }
84
+ : keep("block");
85
+ // Bare pending/failed have no chosen saved terminal: never infer that an absent record is an outstanding bound wait.
86
+ return { next: { state: "failed" }, action: origin === "pending" ? "fail" : "create" };
87
+ }
88
+
89
+ /** Checkable compact documentation, enumerated through the same pure decision function. */
90
+ export function renderRestoreTransitionTable(): string {
91
+ const launch = { packageEntrypoint: "table", command: "table", argv: [], env: {} };
92
+ const binding: TerminalBinding = { peer: "codex", handle: "selected", incarnationId: "selected", worktreeId: "selected", projectRoot: "table", sessionId: "planned", launch, launchMetadata: launch };
93
+ const receipts: RestoreReceipt[] = [{ state: "pending" }, { state: "waiting", origin: "pending", binding }, { state: "waiting", origin: "failed", binding }, { state: "done", binding }, { state: "failed" }];
94
+ const launchers: RestoreEvidence["launcher"][] = ["live", "gone", "unknown", "no-record-yet", "replaced", "older-instance"];
95
+ const result = (receipt: RestoreReceipt, evidence: RestoreEvidence, unchanged = false) => {
96
+ const next = restoreTransition(receipt, evidence);
97
+ const status = restoreReceiptStatus(next.next);
98
+ return `${next.action} -> ${unchanged && status === restoreReceiptStatus(receipt) ? "unchanged" : status}${canChooseFresh(next.next) && !(unchanged && status === restoreReceiptStatus(receipt)) ? "; fresh" : ""}`;
99
+ };
100
+ const table = (title: string, sessions: readonly { session: RestoreEvidence["session"]; allowOther: boolean }[]) => [
101
+ title,
102
+ "",
103
+ `| Receipt | ${launchers.join(" | ")} |`,
104
+ `|---|${launchers.map(() => "---").join("|")}|`,
105
+ ...receipts.map(receipt => `| ${restoreReceiptStatus(receipt)} | ${launchers.map(launcher => {
106
+ const variants = sessions.map(session => ({ session: session.session, result: result(receipt, { target: "running", launcher, ...session, binding }) }));
107
+ const outcomes = [...new Set(variants.map(value => value.result))];
108
+ return outcomes.length === 1 ? outcomes[0]! : [...new Set(variants.map(value => `${value.session}: ${value.result}`))].join("; ");
109
+ }).join(" | ")} |`),
110
+ ].join("\n");
111
+ const priority = (targets: readonly RestoreEvidence["target"][], sessions: readonly { session: RestoreEvidence["session"]; allowOther: boolean }[]) => {
112
+ const outcomes = new Set<string>();
113
+ for (const receipt of receipts) for (const target of targets) for (const launcher of launchers) for (const session of sessions) {
114
+ outcomes.add(result(receipt, { target, launcher, ...session, binding }, true));
115
+ }
116
+ return [...outcomes].join(" / ");
117
+ };
118
+ const allSessions = (["planned", "other", "none"] as const).flatMap(session => [false, true].map(allowOther => ({ session, allowOther })));
119
+ return [
120
+ "Cells show action -> next receipt status. Fresh-session eligibility is marked fresh; peer/operation-specific restrictions still apply. Exact terminal revalidation and settlement must succeed before persisting a proposed done receipt.",
121
+ "",
122
+ "| Priority condition | Decision |",
123
+ "|---|---|",
124
+ `| Target stopped or changed | ${priority(["stopped", "changed"], allSessions)} |`,
125
+ `| Running target, other session not allowed | ${priority(["running"], [{ session: "other", allowOther: false }])} |`,
126
+ "| Absent receipt | unstarted; initial launch is handled by the runtime outside this receipt table |",
127
+ "",
128
+ table("Running target, no attached session (either allowOther value):", [{ session: "none", allowOther: false }, { session: "none", allowOther: true }]),
129
+ "",
130
+ table("Running target, planned session or explicitly allowed other session (different decisions are labeled):", [{ session: "planned", allowOther: false }, { session: "planned", allowOther: true }, { session: "other", allowOther: true }]),
131
+ ].join("\n");
132
+ }
133
+ export const RESTORE_TRANSITION_TABLE = renderRestoreTransitionTable();