@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.
- package/CHANGELOG.md +21 -0
- package/README.md +21 -7
- package/docs/agent-notes/adapters.md +2 -1
- package/docs/agent-notes/budget.md +1 -1
- package/docs/agent-notes/bus.md +4 -0
- package/docs/agent-notes/daemon.md +5 -2
- package/docs/agent-notes/local-worker.md +2 -0
- package/docs/agent-notes/tasks.md +5 -0
- package/docs/agent-notes/tests.md +5 -2
- package/docs/memory.md +1 -1
- package/docs/operations.md +129 -22
- package/docs/quickstart.md +17 -2
- package/docs/security.md +30 -2
- package/docs/specs/2026-09-19-agent-hub-design.md +37 -3
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +84 -10
- package/docs/verification/2026-10-17-0.12.31.md +71 -0
- package/docs/verification/2026-10-17-418-probes.md +21 -0
- package/docs/verified.json +13 -13
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +66 -19
- package/src/adapters/claude-channel.ts +29 -11
- package/src/adapters/local-worker.ts +2 -2
- package/src/adapters/pi.ts +5 -0
- package/src/cli/attention.ts +5 -1
- package/src/cli/console-state.ts +56 -12
- package/src/cli/console.ts +19 -6
- package/src/cli/identity.ts +3 -2
- package/src/cli/jcode-mcp.ts +1 -1
- package/src/cli/main.ts +8 -1
- package/src/cli/output.ts +13 -2
- package/src/cli/upgrade-interactive.ts +2 -1
- package/src/cli/upgrade-restore-state.ts +133 -0
- package/src/cli/upgrade-runtime.ts +162 -58
- package/src/cli/upgrade.ts +49 -24
- package/src/hub/board.ts +82 -24
- package/src/hub/bus.ts +78 -29
- package/src/hub/check-runner.ts +514 -0
- package/src/hub/child-process.ts +1 -0
- package/src/hub/control-client.ts +5 -1
- package/src/hub/daemon.ts +400 -67
- package/src/hub/delivery-hints.ts +30 -0
- package/src/hub/delivery-journal.ts +185 -12
- package/src/hub/delivery-superseded.ts +52 -0
- package/src/hub/envelope.ts +1 -1
- package/src/hub/host-executable.ts +11 -0
- package/src/hub/hub-tools.ts +9 -1
- package/src/hub/peer-catalog.ts +3 -0
- package/src/hub/routing.ts +35 -16
- package/src/hub/settings.ts +2 -1
- package/src/hub/spec-read.ts +120 -0
- package/src/hub/supervision.ts +21 -6
- package/src/hub/task-sweep.ts +7 -6
- package/src/hub/tasks.ts +173 -34
- package/src/memory/capture.ts +2 -1
- package/src/ui/index.html +12 -9
- package/src/ui/needs.ts +7 -5
- package/src/ui/office-model.ts +4 -1
- package/src/ui/task-board.ts +1 -1
- package/src/ui/task-progress.ts +6 -2
- package/templates/AGENTS.block.md +1 -1
- package/templates/profiles/general.json +1 -1
- package/templates/profiles/research.json +1 -1
- package/templates/profiles/writing.json +1 -1
package/src/cli/console-state.ts
CHANGED
|
@@ -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 =
|
|
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
|
}
|
package/src/cli/console.ts
CHANGED
|
@@ -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
|
|
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
|
|
303
|
-
// key presses (key repeat, a slow link), not a paste (#309): each
|
|
304
|
-
|
|
305
|
-
|
|
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; }
|
package/src/cli/identity.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/cli/jcode-mcp.ts
CHANGED
|
@@ -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.
|
|
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();
|