@staix/agent-hub 0.12.29 → 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 (95) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +92 -218
  3. package/docs/agent-notes/README.md +12 -0
  4. package/docs/agent-notes/adapters.md +3 -2
  5. package/docs/agent-notes/budget.md +1 -1
  6. package/docs/agent-notes/bus.md +7 -1
  7. package/docs/agent-notes/daemon.md +14 -1
  8. package/docs/agent-notes/local-worker.md +4 -2
  9. package/docs/agent-notes/tasks.md +14 -0
  10. package/docs/agent-notes/tests.md +6 -3
  11. package/docs/memory.md +70 -152
  12. package/docs/operations.md +317 -25
  13. package/docs/plans/2026-10-16-amem-failure-registry.md +101 -0
  14. package/docs/plans/2026-10-16-amem-p0-baseline.md +110 -0
  15. package/docs/plans/2026-10-16-amem-p2-read-tools.md +28 -0
  16. package/docs/plans/amem-cases.json +482 -0
  17. package/docs/quickstart.md +24 -2
  18. package/docs/security.md +42 -2
  19. package/docs/smoke.md +11 -0
  20. package/docs/specs/2026-09-19-agent-hub-design.md +142 -7
  21. package/docs/specs/2026-09-20-upgrade-recovery-design.md +84 -10
  22. package/docs/verification/2026-10-17-0.12.30.md +70 -0
  23. package/docs/verification/2026-10-17-0.12.31.md +71 -0
  24. package/docs/verification/2026-10-17-418-probes.md +21 -0
  25. package/docs/verified.json +13 -13
  26. package/package.json +5 -2
  27. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  28. package/plugins/agent-hub/server.js +129 -62
  29. package/src/adapters/claude-channel.ts +29 -11
  30. package/src/adapters/local-worker.ts +2 -2
  31. package/src/adapters/pi.ts +5 -0
  32. package/src/cli/attention.ts +5 -1
  33. package/src/cli/console-state.ts +145 -29
  34. package/src/cli/console.ts +40 -10
  35. package/src/cli/help.ts +3 -2
  36. package/src/cli/identity.ts +4 -3
  37. package/src/cli/jcode-mcp.ts +1 -1
  38. package/src/cli/main.js +2 -2
  39. package/src/cli/main.ts +43 -14
  40. package/src/cli/memory.ts +87 -187
  41. package/src/cli/output.ts +19 -2
  42. package/src/cli/recovery-package.ts +59 -10
  43. package/src/cli/upgrade-interactive.ts +2 -1
  44. package/src/cli/upgrade-restore-state.ts +133 -0
  45. package/src/cli/upgrade-runtime.ts +204 -63
  46. package/src/cli/upgrade.ts +57 -24
  47. package/src/hub/board.ts +83 -24
  48. package/src/hub/bus.ts +108 -38
  49. package/src/hub/check-runner.ts +514 -0
  50. package/src/hub/child-process.ts +1 -0
  51. package/src/hub/control-client.ts +5 -1
  52. package/src/hub/daemon.ts +529 -89
  53. package/src/hub/delivery-hints.ts +30 -0
  54. package/src/hub/delivery-journal.ts +185 -12
  55. package/src/hub/delivery-superseded.ts +52 -0
  56. package/src/hub/envelope.ts +31 -1
  57. package/src/hub/git.ts +2 -38
  58. package/src/hub/host-executable.ts +11 -0
  59. package/src/hub/hub-tools.ts +14 -2
  60. package/src/hub/manager.ts +1 -1
  61. package/src/hub/memory-candidates.ts +103 -0
  62. package/src/hub/memory-fail.ts +25 -0
  63. package/src/hub/memory-observations.ts +47 -0
  64. package/src/hub/memory-plan-guard.ts +33 -0
  65. package/src/hub/memory-policy.ts +42 -0
  66. package/src/hub/memory-profile-check.ts +170 -0
  67. package/src/hub/memory-profile.ts +192 -0
  68. package/src/hub/memory-staleness.ts +55 -0
  69. package/src/hub/path-policy.ts +8 -0
  70. package/src/hub/peer-catalog.ts +3 -0
  71. package/src/hub/project.ts +4 -37
  72. package/src/hub/read-tools.ts +127 -0
  73. package/src/hub/routing.ts +35 -16
  74. package/src/hub/settings.ts +2 -1
  75. package/src/hub/spec-read.ts +120 -0
  76. package/src/hub/supervision.ts +21 -6
  77. package/src/hub/task-sweep.ts +7 -6
  78. package/src/hub/tasks.ts +370 -71
  79. package/src/local/deny.ts +4 -17
  80. package/src/memory/brief.ts +8 -5
  81. package/src/memory/capture.ts +3 -1
  82. package/src/memory/recall.ts +50 -110
  83. package/src/pi/process-signature.ts +2 -42
  84. package/src/ui/index.html +12 -9
  85. package/src/ui/needs.ts +7 -5
  86. package/src/ui/office-model.ts +4 -1
  87. package/src/ui/task-board.ts +1 -1
  88. package/src/ui/task-progress.ts +6 -2
  89. package/templates/AGENTS.block.md +1 -1
  90. package/templates/profiles/general.json +1 -1
  91. package/templates/profiles/research.json +1 -1
  92. package/templates/profiles/writing.json +1 -1
  93. package/src/memory/import.ts +0 -90
  94. package/src/memory/store.ts +0 -805
  95. package/src/memory/sync.ts +0 -145
@@ -108,7 +108,7 @@ export class LocalPeer extends BasePeer {
108
108
  await this.requireBudget(envs, "model_calls");
109
109
  if (generation !== this.turn) throw new Error("route belongs to an ended turn");
110
110
  if (signal.aborted) throw new Error("turn cancelled before model request");
111
- const tools = judge ? undefined : [...TOOL_SCHEMAS, ...(this.opts.taskTool ? [...profileTaskTools(this.opts.profile, this.opts.sdlcStages), ...CONDUCTOR_TOOLS].map(asFunction) : [])];
111
+ const tools = judge ? undefined : [...TOOL_SCHEMAS, ...(this.opts.taskTool ? [...profileTaskTools(this.opts.profile, this.opts.sdlcStages).filter(tool => tool.name !== "hub_delivery_retry"), ...CONDUCTOR_TOOLS].map(asFunction) : [])];
112
112
  const result = await this.opts.omni.chat({ model, messages, ...(tools ? { tools } : {}), ...(judge ? { max_tokens: maxTokens ?? 2048 } : {}) }, { signal, ...(pii ? { onCampusOnly: true } : {}) });
113
113
  this.recordUsage(result, model, task);
114
114
  if (generation !== this.turn) throw new Error("route belongs to an ended turn");
@@ -378,7 +378,7 @@ export class LocalPeer extends BasePeer {
378
378
  const route = policy?.route ?? this.opts.route;
379
379
  const fixedModel = policy?.fixedModel ?? this.opts.fixedModel;
380
380
  const task = policy?.task === undefined ? undefined : Number(policy.task);
381
- const tools = [...TOOL_SCHEMAS, ...(this.opts.taskTool ? [...profileTaskTools(this.opts.profile, this.opts.sdlcStages), ...CONDUCTOR_TOOLS].map(asFunction) : [])];
381
+ const tools = [...TOOL_SCHEMAS, ...(this.opts.taskTool ? [...profileTaskTools(this.opts.profile, this.opts.sdlcStages).filter(tool => tool.name !== "hub_delivery_retry"), ...CONDUCTOR_TOOLS].map(asFunction) : [])];
382
382
  const signal = this.abort!.signal;
383
383
  const messages: ChatMessage[] = [{ role: "system", content: system(this.opts.cwd, this.opts.preamble) }, ...this.history, ...turnMsgs];
384
384
  const routed = await this.hubCall(route, messages, policy, signal);
@@ -146,6 +146,11 @@ export class PiPeer extends BasePeer {
146
146
  if (this.opts.mode === "headless") return !this.proc || this.proc.exitCode !== null || this.proc.signalCode !== null;
147
147
  return this.stopping && !this.ownerClaimed && !!this.ownerPid && processLiveness(this.ownerPid, this.ownerSignature) === "gone";
148
148
  }
149
+ /** Stable across turns, invalidated when the verified native owner/session changes. Not caller supplied. */
150
+ get sessionGeneration(): string | undefined {
151
+ return this.ownerClaimed && this.ownerPid && this.ownerSignature ? `${this.ownerPid}:${this.ownerSignature}:${this.sessionId ?? ""}` : undefined;
152
+ }
153
+
149
154
  recoveryMetadata(): Record<string, unknown> {
150
155
  const exists = !!this.sessionFile && existsSync(this.sessionFile);
151
156
  if (exists) { this.persistedOnce = true; this.verifiedEmptyResume = undefined; }
@@ -9,7 +9,7 @@ export interface AttentionState {
9
9
  storageBlocked?: boolean;
10
10
  recoveryNeed?: { id?: string; running: boolean; since?: number };
11
11
  queue?: readonly { id: string; peer?: string; state: string }[];
12
- attentionFacts?: { kind: "review" | "offline"; id: number; peer?: string }[];
12
+ attentionFacts?: { kind: "review" | "offline" | "stall" | "offer"; id: number; peer?: string }[];
13
13
  }
14
14
  /** Allowlisted adapter between console-owned facts and the shared body-free model. */
15
15
  export function consoleNeeds(state: AttentionState, now: number): Needs {
@@ -42,6 +42,8 @@ export function attentionFacts(raw: unknown): NonNullable<AttentionState["attent
42
42
  if (!Array.isArray(raw)) return [];
43
43
  return raw.flatMap<NonNullable<AttentionState["attentionFacts"]>[number]>(row => {
44
44
  if (!row || typeof row !== "object" || !Number.isSafeInteger(row.id) || row.id < 1) return [];
45
+ if (row.kind === "stall" && row.level === "act") return [{ kind: "stall" as const, id: row.id }];
46
+ if (row.kind === "offer" && row.level === "act" && typeof row.peer === "string" && /^[A-Za-z0-9_.:-]{1,128}$/.test(row.peer)) return [{ kind: "offer" as const, id: row.id, peer: row.peer }];
45
47
  if (row.kind === "review" && row.level === "act") return [{ kind: "review" as const, id: row.id }];
46
48
  if (row.kind === "offline" && row.level === "watch" && typeof row.peer === "string" && /^[A-Za-z0-9_.:-]{1,128}$/.test(row.peer)) return [{ kind: "offline" as const, id: row.id, peer: row.peer }];
47
49
  return [];
@@ -49,6 +51,8 @@ export function attentionFacts(raw: unknown): NonNullable<AttentionState["attent
49
51
  }
50
52
 
51
53
  export function attentionCommand(item: NeedItem): string {
54
+ if (item.kind === "offer") return `open ${item.peer}: task #${item.id} is waiting for it; ahub task show ${item.id}`;
55
+ if (item.kind === "stall") return `ahub task show ${item.id}; configure the peer or ahub task assign ${item.id} <peer>, or ahub task cancel ${item.id} --reason <text>`;
52
56
  if (item.kind === "delivery") return `ahub queue list${item.id ? `; ahub queue show ${item.id}` : ""}`;
53
57
  if (item.kind === "review") return `ahub task show ${item.id}; ahub review ${item.id} approved|changes_requested`;
54
58
  if (item.kind === "offline") return `ahub status; ahub task show ${item.id}; ahub task assign ${item.id} <peer>`;
@@ -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;
@@ -29,7 +31,9 @@ export interface ConsoleState {
29
31
  approvals: Approval[]; events: ConsoleEvent[]; peers: Record<string, any>; budget: Record<string, any>;
30
32
  tasks: any[]; tasksKnown?: boolean; taskCounts?: Record<string, number>; queue: any[]; detail?: Detail; detailOffset: number; help: boolean; notice: string; noticeAt?: number; noticeTone?: Tone;
31
33
  peerFilter?: string; kindFilter?: string; project?: string;
32
- confirm?: { type: "permission"; id: string; option: string; peer: string } | { type: "command"; args: string[] } | { type: "permission_default"; peer: string; source: string };
34
+ taskSelection?: number; queueSelection?: string;
35
+ actionLock?: { prefix: string[]; id: string; revision: number; state: string; owner?: string; reserved?: string; reviewer?: string; waiting?: boolean };
36
+ confirm?: { type: "permission"; id: string; option: string; peer: string } | { type: "command"; args: string[]; prompt?: string } | { type: "permission_default"; peer: string; source: string };
33
37
  optionChoice?: string;
34
38
  modeChoice?: string;
35
39
  permissionDefaults: PermissionDefault[];
@@ -313,14 +317,17 @@ function approvalFields(a: Approval): [string, unknown][] {
313
317
  export function permissionText(a: Pick<Approval, "id" | "peer" | "title" | "options">): string {
314
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`;
315
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~"];
316
323
  const KEYS: [string, string[]][] = [
317
324
  ["Everywhere", ["Tab stream/panels", ": command", "? keys", "Esc back", "q quit"]],
318
325
  ["Command", ["Enter run", "Esc cancel", "Up/Down history", "Ctrl-U clear"]],
319
- ["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"]],
320
327
  ["Approvals", ["a allow (then y)", "d deny", "v view", "[ ] select"]],
321
- ["Peers", ["p pause", "r resume", "m permission mode"]],
322
- ["Tasks", ["a assign (then y)", "r review"]],
323
- ["Queue", ["r resolve (reason, then y)"]],
328
+ ["Peers", ["p pause", "r resume", "m permission mode", "l make planner (then y)"]],
329
+ ["Tasks", ["a assign/reviewer (then y)", "u unassign (then y)", "r review (in review)"]],
330
+ ["Queue", ["r resolve (reason, then y)", "queued=discard, needs_review=retry"]],
324
331
  ["Events", ["f peer filter", "g kind filter"]],
325
332
  ];
326
333
  /** The `?` key table: one group per panel, its keys kept whole and packed into the width after the group column. */
@@ -352,7 +359,7 @@ function bound(s: ConsoleState): ConsoleState {
352
359
  if (s.requestDetail && s.requestDetail !== s.approvalId) { s.requestDetail = s.approvalId; s.detailOffset = 0; }
353
360
  return s;
354
361
  }
355
- const COMMANDS = new Set(["status", "board", "task", "review", "say", "pause", "resume", "budget", "queue", "permit", "permission", "ask", "remember", "route", "sdlc", "turns", "undo", "check-path", "report"]);
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"]);
356
363
  /** A tiny argv parser, never a shell. Quotes group arguments; backslash escapes one character. */
357
364
  export function parseConsoleCommand(input: string): string[] {
358
365
  const words: string[] = []; let word = ""; let quote = ""; let escaped = false; let started = false;
@@ -372,7 +379,7 @@ export function parseConsoleCommand(input: string): string[] {
372
379
  }
373
380
  export function commandNeedsConfirmation(args: string[]): boolean {
374
381
  const positional = args.filter(arg => arg !== "--yes");
375
- return (positional[0] === "permission" && positional[2] === "never-ask") || (args[0] === "task" && args[1] === "assign") || (args[0] === "queue" && args[1] === "resolve") || (args[0] === "permit" && args[2] !== "deny");
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");
376
383
  }
377
384
  export function panelRows(s: ConsoleState): any[] {
378
385
  if (s.panel === 1) return Object.entries(s.peers).map(([id, peer]) => ({ id, ...peer, budget: s.budget[id] }));
@@ -384,9 +391,49 @@ export function panelRows(s: ConsoleState): any[] {
384
391
  if (s.panel === 4) return s.queue.filter(q => q.state === "needs_review" || q.state === "queued");
385
392
  return s.events.filter(e => (!s.peerFilter || e.peer === s.peerFilter) && (!s.kindFilter || e.kind === s.kindFilter));
386
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);
387
403
  export const selectedApproval = (s: ConsoleState) => s.approvals.find(a => a.id === s.approvalId);
388
- /** The selected row: the Approvals panel's is the selected request (-1 when none is). */
389
- const selection = (s: ConsoleState) => s.panel === 2 ? s.approvals.findIndex(a => a.id === s.approvalId) : s.selection;
404
+ /** The selected row: approvals and refreshed Tasks/Queue rows are bound by id, never by a replacement row. */
405
+ const selection = (s: ConsoleState) => {
406
+ if (s.panel === 2) return s.approvals.findIndex(a => a.id === s.approvalId);
407
+ if (s.panel === 3 && s.taskSelection !== undefined) return panelRows(s).findIndex(row => row.id === s.taskSelection);
408
+ if (s.panel === 4 && s.queueSelection !== undefined) return panelRows(s).findIndex(row => row.id === s.queueSelection);
409
+ return s.selection;
410
+ };
411
+ export const selectedPanelRow = (s: ConsoleState): any | undefined => panelRows(s)[selection(s)];
412
+ export function captureConsoleSelection(s: ConsoleState, byIndex = false): void {
413
+ const row = byIndex ? panelRows(s)[s.selection] : selectedPanelRow(s);
414
+ if (s.panel === 3 && row) s.taskSelection = Number(row.id);
415
+ if (s.panel === 4 && row) s.queueSelection = String(row.id);
416
+ }
417
+ const commandFlag = (args: string[], name: string) => { const at = args.indexOf(name); return at < 0 ? undefined : args[at + 1]; };
418
+ function validateLockedCommand(lock: NonNullable<ConsoleState["actionLock"]>, args: string[]): void {
419
+ const revisions = args.flatMap((arg, index) => arg === "--revision" ? [args[index + 1]] : []);
420
+ if (args[0] !== lock.prefix[0] || args[1] !== lock.prefix[1] || args[2] !== lock.id || revisions.length !== 1 || revisions[0] !== String(lock.revision)) {
421
+ throw new Error("the selected id and revision cannot be edited; start again from the row");
422
+ }
423
+ }
424
+ function lockedCommandPrompt(lock: NonNullable<ConsoleState["actionLock"]>, args: string[]): string {
425
+ if (lock.prefix[0] === "queue") {
426
+ const action = commandFlag(args, "--action") ?? "resolve";
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" : ""}`;
428
+ }
429
+ if (lock.prefix[1] === "cancel") return `cancel task #${lock.id} at revision ${lock.revision}; native turns and worktrees are retained`;
430
+ const role = commandFlag(args, "--role") ?? "owner";
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" : ""}`;
432
+ const affected = role === "owner" ? [lock.owner, lock.reserved].filter(Boolean).join(" and ") : lock.reviewer ?? "none";
433
+ const resulting = role === "owner" ? "proposed" : lock.state;
434
+ const guidance = role === "reviewer" && lock.state !== "in_review" ? "; later completion without a reviewer may approve directly" : lock.state === "in_review" ? `; use ahub review ${lock.id}` : "";
435
+ return `unassign ${role} ${affected} on task #${lock.id} at revision ${lock.revision} -> ${resulting}${guidance}; native work and uncertain deliveries are not stopped or resolved`;
436
+ }
390
437
  export function reduceConsole(state: ConsoleState, key: string, now = Date.now()): { state: ConsoleState; effects: ConsoleEffect[] } {
391
438
  const attentionKeyBlocked = !!(state.confirm || state.optionChoice || state.modeChoice || state.help || state.editing || state.input);
392
439
  // A notice answers the key before this one; one that pruning raises now stays.
@@ -427,24 +474,29 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
427
474
  }
428
475
  if (s.help && s.mode === "panels" && !["?", "\x1b", "q"].includes(key)) return done(); // the key table is modal
429
476
  if (s.editing || s.input) {
430
- if (key === "\x1b") { s.input = ""; s.editing = false; }
477
+ if (key === "\x1b") { s.input = ""; s.editing = false; s.actionLock = undefined; }
431
478
  else if (key === "\x7f" || key === "\b") s.input = Array.from(s.input).slice(0, -1).join("");
432
479
  else if (key === "\x15") s.input = "";
433
480
  else if (key === "\x1b[A" || key === "\x1b[B") {
434
481
  s.historyIndex = Math.max(0, Math.min(s.history.length, s.historyIndex + (key === "\x1b[A" ? -1 : 1)));
435
482
  s.input = s.history[s.historyIndex] ?? "";
436
483
  } else if (key === "\r" || key === "\n") {
437
- const input = s.input; s.input = ""; s.editing = false;
484
+ const input = s.input, lock = s.actionLock; s.input = ""; s.editing = false; s.actionLock = undefined;
438
485
  if (!input.trim()) return done();
439
486
  try {
440
487
  const parsed = parseConsoleCommand(input);
441
488
  const args = parsed[0] === "permission" ? [...parsed.filter(arg => arg !== "--yes"), ...(parsed.includes("--yes") ? ["--yes"] : [])] : parsed;
442
489
  if (args[0] === "queue" && args[1] === "resolve") {
443
- const reason = args.indexOf("--reason");
490
+ const reason = args.indexOf("--reason"), action = commandFlag(args, "--action");
444
491
  if (reason < 0 || !args[reason + 1]?.trim()) throw new Error("queue resolve requires --reason");
492
+ if (!["completed", "retry", "discard"].includes(action ?? "")) throw new Error("queue resolve requires --action completed|retry|discard");
493
+ }
494
+ if (lock) {
495
+ validateLockedCommand(lock, args);
496
+ if (lock.prefix[0] === "task" && !["owner", "reviewer"].includes(commandFlag(args, "--role") ?? "owner")) throw new Error("role must be owner or reviewer");
445
497
  }
446
498
  s.history = [...s.history, input].slice(-100); s.historyIndex = s.history.length;
447
- if (commandNeedsConfirmation(args)) s.confirm = { type: "command", args };
499
+ if (commandNeedsConfirmation(args)) s.confirm = { type: "command", args, ...(lock ? { prompt: lockedCommandPrompt(lock, args) } : {}) };
448
500
  else effects.push({ type: "command", args });
449
501
  } catch (error) { notify(s, String((error as Error).message), now); }
450
502
  } else if (!key.startsWith("\x1b")) s.input += terminalText(key).replace(/\n/g, "");
@@ -482,7 +534,7 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
482
534
  const selected = rows.findIndex(row => row.id === item.id);
483
535
  if (selected >= 0) { s.panel = 3; s.selection = selected; navigated = true; }
484
536
  }
485
- if (navigated) { s.detail = undefined; s.requestDetail = undefined; }
537
+ if (navigated) { captureConsoleSelection(s, true); s.detail = undefined; s.requestDetail = undefined; }
486
538
  else {
487
539
  const command = attentionCommand(item);
488
540
  const text = `${item.kind}${item.peer ? ` ${item.peer}` : ""}${item.id ? ` ${item.id}` : ""}: ${command}`;
@@ -493,7 +545,7 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
493
545
  }
494
546
  if (key === "\t") { s.mode = s.mode === "panels" ? "stream" : "panels"; s.detail = undefined; s.requestDetail = undefined; return done(); }
495
547
  if (key === ":") { s.editing = true; return done(); }
496
- const switchPanel = (panel: number) => { s.panel = panel; s.selection = 0; s.detail = undefined; s.requestDetail = undefined; return done(); };
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(); };
497
549
  if (s.mode === "panels" && /^[1-5]$/.test(key) && !s.optionChoice) return switchPanel(Number(key));
498
550
  // Right and Left are the digit of the next or previous tab, wrapping at both ends (#309); the same guards as the
499
551
  // digits apply (the confirm, mode-choice, key-table and editing branches above returned already).
@@ -504,10 +556,24 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
504
556
  return done();
505
557
  }
506
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
+ }
507
572
  if (s.mode === "panels" && ["j", "k", "\x1b[A", "\x1b[B"].includes(key)) {
508
573
  if (s.detail !== undefined || s.requestDetail) { s.detailOffset = Math.max(0, s.detailOffset + (["j", "\x1b[B"].includes(key) ? 1 : -1)); return done(); }
509
574
  s.selection = Math.max(0, Math.min(panelRows(s).length - 1, selection(s) + (["j", "\x1b[B"].includes(key) ? 1 : -1)));
510
575
  if (s.panel === 2) s.approvalId = s.approvals[s.selection]?.id;
576
+ captureConsoleSelection(s, true);
511
577
  return done();
512
578
  }
513
579
  if (key === "[" || key === "]") {
@@ -537,10 +603,11 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
537
603
  if (key === "v" && s.mode === "stream") { effects.push({ type: "print", text: permissionText(approval), kind: "permission", tone: "attention" }); return done(); }
538
604
  if (key === "v") { s.requestDetail = approval.id; s.detailOffset = 0; return done(); }
539
605
  }
540
- const row = panelRows(s)[selection(s)];
606
+ const row = selectedPanelRow(s);
541
607
  // With a detail open the keys act on the item it shows (its id), never on what a refresh moved under the selection;
542
608
  // a detail that is only a message names no item.
543
- const item = s.detail === undefined ? row?.id : typeof s.detail === "object" ? s.detail.id : undefined;
609
+ const selected = s.detail === undefined ? row : typeof s.detail === "object" ? s.detail : undefined;
610
+ const item = selected?.id;
544
611
  if (s.mode === "panels" && row && s.detail === undefined && (key === "\r" || key === "\n")) {
545
612
  if (s.panel === 3 || s.panel === 4) effects.push({ type: "show", panel: s.panel, id: String(row.id) });
546
613
  else if (s.panel === 2) { s.requestDetail = row.id; s.detailOffset = 0; } // the row is the selected request
@@ -556,17 +623,52 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
556
623
  else s.modeChoice = peer;
557
624
  return done();
558
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
+ }
559
634
  const prefill = s.panel === 1 && key === "p" ? `pause ${item}` : s.panel === 1 && key === "r" ? `resume ${item}` :
560
- s.panel === 3 && key === "a" ? `task assign ${item} ` : s.panel === 3 && key === "r" ? `review ${item} ` :
561
- s.panel === 4 && key === "r" ? `queue resolve ${item} --action retry --reason ` : undefined;
635
+ s.panel === 3 && key === "r" && selected.state === "in_review" ? `review ${item} ` : undefined;
562
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
+ }
645
+ if (s.panel === 3 && (key === "a" || key === "u")) {
646
+ if (!["proposed", "in_progress", "in_review", "changes_requested"].includes(selected.state)) return done();
647
+ const revision = selected.flowRevision, role = selected.state === "in_review" || key === "u" && !selected.owner && !selected.reserved && selected.reviewer ? "reviewer" : "owner";
648
+ if (!Number.isSafeInteger(revision) || revision < 0) { notify(s, "task row actions need flowRevision; upgrade the hub", now); return done(); }
649
+ if (key === "u" && (role === "owner" && !selected.owner && !selected.reserved || role === "reviewer" && !selected.reviewer)) { notify(s, `task #${item}: no ${role} to unassign`, now); return done(); }
650
+ const listed = s.tasks.find(task => task.id === item), ready = listed?.ready ?? selected.ready;
651
+ const waiting = selected.state === "proposed" && ready !== true && Array.isArray(selected.deps) && selected.deps.length > 0;
652
+ s.actionLock = { prefix: ["task", key === "a" ? "assign" : "unassign"], id: String(item), revision, state: String(selected.state), ...(selected.owner ? { owner: String(selected.owner) } : {}), ...(selected.reserved ? { reserved: String(selected.reserved) } : {}), ...(selected.reviewer ? { reviewer: String(selected.reviewer) } : {}), waiting };
653
+ s.input = key === "a" ? `task assign ${item} ${role === "reviewer" ? "--role reviewer " : ""}--revision ${revision} ` : `task unassign ${item} --role ${role} --revision ${revision}`;
654
+ s.editing = true; return done();
655
+ }
656
+ if (s.panel === 4 && key === "r") {
657
+ if (!["queued", "needs_review"].includes(selected.state)) return done();
658
+ const revision = selected.revision;
659
+ if (!Number.isSafeInteger(revision) || revision < 0) { notify(s, "queue row actions need a delivery revision; upgrade the hub", now); return done(); }
660
+ const action = selected.state === "queued" ? "discard" : "retry";
661
+ s.actionLock = { prefix: ["queue", "resolve"], id: String(item), revision, state: String(selected.state) };
662
+ s.input = `queue resolve ${item} --action ${action} --revision ${revision} --reason `;
663
+ s.editing = true; return done();
664
+ }
563
665
  }
564
666
  if (s.mode === "panels" && s.panel === 5 && (key === "f" || key === "g")) {
565
667
  const field = key === "f" ? "peer" : "kind"; const current = key === "f" ? s.peerFilter : s.kindFilter;
566
668
  const values = [...new Set(s.events.map(e => e[field]).filter((x): x is string => !!x))];
567
669
  const next = values[values.indexOf(current ?? "") + 1];
568
670
  if (key === "f") s.peerFilter = next; else s.kindFilter = next;
569
- s.selection = 0; return done();
671
+ s.selection = newestEvent(s); return done();
570
672
  }
571
673
  if (s.mode === "stream" && !key.startsWith("\x1b")) { s.editing = true; s.input = terminalText(key).replace(/\n/g, ""); }
572
674
  return done();
@@ -594,7 +696,7 @@ export function cells(s: ConsoleState, row: any, selected: boolean, now: number,
594
696
  const paused = b?.paused ? `budget ${relative(b.paused.resetsAt, now)}` : row.paused && typeof row.paused === "object"
595
697
  ? `${row.paused.by ?? "paused"}${typeof row.paused.at === "number" ? ` ${duration(now - row.paused.at)}` : ""}`
596
698
  : row.paused === "manual" ? "user" : row.paused ? String(row.paused).split(":")[0] : "-";
597
- 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)),
598
700
  ...(showPermissionModes(s) ? [span(row.permissionMode && row.permissionMode !== "ask" ? row.permissionMode : "-", row.permissionMode === "never-ask" ? "attention" : undefined)] : []),
599
701
  span(row.pullOnly && row.id === "kimi" ? "pull-only" : row.toolsOnly ? "tools-only" : row.attached === false ? "detached" : "attached", row.toolsOnly ? "attention" : undefined),
600
702
  span(count(row.queued)), span(count(row.queuedImportant), row.queuedImportant ? "attention" : undefined),
@@ -608,9 +710,9 @@ export function cells(s: ConsoleState, row: any, selected: boolean, now: number,
608
710
  const at = last?.at ?? row.updated ?? row.created;
609
711
  const failed = last?.event === "check failed" || last?.event === "failed";
610
712
  const stage = stages?.get(row.id);
611
- const meter = stage ? "[" + "#".repeat(stage.stage - (stage.changes ? 1 : 0)) + (stage.changes ? "!" : "") + "-".repeat(4 - stage.stage) + "]" : "[----]";
612
- return [id(`#${row.id}`, "info"), span(`${row.state}${failed && row.state !== last.event ? ` ${last.event}` : ""}${row.ready ? " ready" : ""}${stage?.waiting ? " waiting" : ""}`, failed ? "failure" : row.ready || stage?.waiting ? "attention" : stateTone(row.state)),
613
- span(row.owner ?? "-"), span(row.reviewer ?? "-"), span(row.profileClass ? `${row.profileClass} (base ${row.class})` : row.class ?? "-"), span(typeof at === "number" ? duration(now - at) : "-", "muted"), span(row.title), span(row.lifecycle ? `${meter} ${row.lifecycle.workflow ? row.lifecycle.workflow + " " : ""}SDLC ${row.lifecycle.stage}${row.lifecycle.definition ? ` (base ${row.lifecycle.definition.base}) / next: ${row.lifecycle.actor} / entry: ${row.lifecycle.entry?.length ? "blocked" : "ready"} / exit: ${row.lifecycle.exit?.length ? "blocked" : "ready"}` : ""}: ${row.lifecycle.next}` : meter, stage?.changes ? "failure" : stateTone(row.state))];
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))];
614
716
  }
615
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")];
616
718
  const [header, ...body] = String(row.text).split("\n");
@@ -646,6 +748,21 @@ export function table(head: string[], rows: Span[][], columns: number, explicitW
646
748
  return { ...cell, text: text + " ".repeat(Math.max(0, widths[i]! - Bun.stringWidth(text)) + (i === head.length - 1 ? 0 : 2)) };
647
749
  }));
648
750
  }
751
+ function taskHints(row: any): string[] {
752
+ if (!["proposed", "in_progress", "in_review", "changes_requested"].includes(row.state)) return [];
753
+ const review = row.state === "in_review";
754
+ const hints = [review ? "a reviewer" : "a assign"];
755
+ if (review ? row.reviewer : row.owner || row.reserved) hints.push("u unassign");
756
+ if (review) hints.push("r review");
757
+ return hints;
758
+ }
759
+ function panelContext(s: ConsoleState, row: any): string[] {
760
+ if (!row) return [];
761
+ if (s.panel === 1) return ["p pause", "r resume", "m permission mode"];
762
+ if (s.panel === 3) return [...taskHints(row), ...(s.features?.includes("task_cancel") ? ["c cancel"] : [])];
763
+ if (s.panel === 4) return [row.state === "queued" ? "r discard" : "r resolve"];
764
+ return [];
765
+ }
649
766
  /** The footer's key hint: only keys that act in this mode, panel and state. */
650
767
  function hint(s: ConsoleState, columns: number): string {
651
768
  const pending = selectedApproval(s) ? ["a allow", "d deny"] : [];
@@ -653,9 +770,8 @@ function hint(s: ConsoleState, columns: number): string {
653
770
  if (s.help) return "? or Esc close q quit";
654
771
  const approvals = s.panel === 2 ? pending : [];
655
772
  if (s.detail !== undefined || s.requestDetail) return [...approvals, "j/k scroll", "Esc back", "? keys", "q quit"].join(" ");
656
- const rows = panelRows(s); const row = rows[selection(s)];
657
- const context = !row ? [] : s.panel === 1 ? ["p pause", "r resume", "m permission mode"] : s.panel === 3 ? ["a assign", "r review"] : s.panel === 4 ? ["r resolve"] : [];
658
- const text = [...approvals, ...context, ...(s.panel === 5 ? ["f peer", "g kind"] : []), ...(rows.length ? ["j/k move"] : []), ...(row ? ["Enter view"] : []), "? keys", ": command", "Tab stream", "q quit"].join(" ");
773
+ const rows = panelRows(s); const row = selectedPanelRow(s);
774
+ const text = [...approvals, ...panelContext(s, row), ...(s.panel === 5 ? ["f peer", "g kind"] : []), ...(rows.length ? ["j/k move"] : []), ...(row ? ["Enter view"] : []), "? keys", ": command", "Tab stream", "q quit"].join(" ");
659
775
  return s.panel === 1 && Bun.stringWidth(text) > columns ? text.replace("m permission mode", "m mode").replace("j/k move", "j/k").replace(": command", ": cmd") : text;
660
776
  }
661
777
  /** The project, then the tabs: the active one in brackets, pending approvals and held deliveries counted. */
@@ -705,7 +821,7 @@ export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24,
705
821
  const permission = selectedApproval(s);
706
822
  let prompt = s.editing ? `: ${s.input}` : hint(s, columns);
707
823
  if (s.confirm?.type === "permission") prompt = `allow ${quoted(s.confirm.option)} for ${s.confirm.peer} (request ${s.confirm.id})? y/N`;
708
- if (s.confirm?.type === "command") prompt = `${s.confirm.args.join(" ")}? y/N`;
824
+ if (s.confirm?.type === "command") prompt = `${s.confirm.prompt ?? s.confirm.args.join(" ")}? y/N`;
709
825
  if (s.modeChoice) prompt = `permission ${s.modeChoice}: 1 ask 2 ask-when-needed 3 never-ask Esc cancel`;
710
826
  const choice = s.optionChoice ? s.approvals.find(a => a.id === s.optionChoice) : undefined;
711
827
  if (choice) prompt = `allow request ${choice.id} (${choice.peer}) with: ${allowOptions(choice).map((o, i) => `${i + 1} ${quoted(o.name)}`).join(" ")} Esc cancel`;
@@ -4,7 +4,7 @@ import { StringDecoder } from "node:string_decoder";
4
4
  import type { ControlClient } from "../hub/control-client.ts";
5
5
  import { contextLine } from "./status-lines.ts";
6
6
  import { renderTailEvent } from "./tail-render.ts";
7
- import { syncPermissionDefaults, permissionDefaultText, approvalFrom, initialConsoleState, keyTable, notify, paint, panelRows, permissionText, plural, pruneApprovals, reduceConsole, renderConsoleLines, resolveColor, stateTone, streamTokens, wrapStreamTokens, terminalText, wrap } from "./console-state.ts";
7
+ import { syncPermissionDefaults, permissionDefaultText, approvalFrom, initialConsoleState, keyTable, notify, paint, permissionText, plural, pruneApprovals, reduceConsole, renderConsoleLines, resolveColor, stateTone, streamTokens, wrapStreamTokens, terminalText, wrap, captureConsoleSelection, selectedPanelRow, 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 {
@@ -82,7 +93,7 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
82
93
  let lastAct = 0; let lastBell = -Infinity;
83
94
  let columns = options.columns ?? terminal.columns; let rows = options.rows ?? terminal.rows;
84
95
  let active = true; let inAlternate = false; let plain = !terminal.isTTY || columns < 80 || rows < 24;
85
- let polling = false; let childRunning = false; let cancelChild: (() => void) | undefined;
96
+ let polling = false; let refreshPending = false; let childRunning = false; let cancelChild: (() => void) | undefined;
86
97
  const priorPush = client.onPush; const priorClose = client.onClose; const priorRaw = !!terminal.isRaw;
87
98
  const removers: (() => void)[] = []; const timers: ReturnType<typeof setInterval>[] = [];
88
99
  let resolveDone: () => void = () => {}; const done = new Promise<void>(resolve => { resolveDone = resolve; });
@@ -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");
@@ -159,8 +170,20 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
159
170
  safeWrite(`${reason ? reason + "\n" : ""}Left console; the hub keeps running (ahub kill stops it).\n`);
160
171
  resolveDone();
161
172
  };
173
+ const reconcileDetail = () => {
174
+ if (state.detail === undefined || typeof state.detail !== "object" || (state.panel !== 3 && state.panel !== 4)) return;
175
+ const id = state.detail.id;
176
+ if (id === undefined || id === null) return;
177
+ const key = state.panel === 3 ? "flowRevision" : "revision", rows = state.panel === 3 ? state.tasks : state.queue;
178
+ const fresh = rows.find(row => String(row.id) === String(id));
179
+ if (!fresh || state.detail[key] !== fresh[key]) {
180
+ state.detail = undefined; state.detailOffset = 0;
181
+ notice(`detail ${id} changed or is no longer available`);
182
+ }
183
+ };
162
184
  const refresh = async () => {
163
- if (polling || !active) return; polling = true;
185
+ if (polling || !active) { if (active) refreshPending = true; return; }
186
+ polling = true;
164
187
  try {
165
188
  const panels = state.mode === "panels" && !plain;
166
189
  const replies = await Promise.all([
@@ -169,7 +192,9 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
169
192
  ]);
170
193
  if (!active) return;
171
194
  const [status, budget, tasks, queue, ready] = replies;
195
+ captureConsoleSelection(state);
172
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 : [];
173
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] }]));
174
199
  if (status.status && status.ok !== false) {
175
200
  state.attentionFacts = attentionFacts(status.status.attentionItems);
@@ -184,10 +209,14 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
184
209
  state.tasksKnown = true;
185
210
  } }
186
211
  if (queue?.ok && state.mode === "panels" && Array.isArray(queue.deliveries)) state.queue = queue.deliveries;
212
+ reconcileDetail();
187
213
  const error = replies.find(reply => reply.ok === false)?.error; if (error) notice(String(error));
188
214
  draw();
189
215
  } catch (error) { if (active) { notice(String((error as Error).message)); draw(); } }
190
- finally { polling = false; }
216
+ finally {
217
+ polling = false;
218
+ if (refreshPending) { refreshPending = false; void refresh(); }
219
+ }
191
220
  };
192
221
  const run = options.runCommand ?? ((args, output, finished, commandColumns) => {
193
222
  const child = Bun.spawn([process.execPath, fileURLToPath(new URL("./main.ts", import.meta.url)), "--project", options.cwd, ...(options.liveTest ? ["--live-test"] : []), ...args], {
@@ -223,7 +252,7 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
223
252
  const result = await client.request(action.panel === 3 ? { t: "task", op: "task_show", args: { id: action.id } } : { t: "queue", op: "show", id: action.id }, 3000);
224
253
  // A late reply is for the panel and row it was asked for: once the person has moved on, it would show one item
225
254
  // while the keys act on another, so it is dropped.
226
- if (!active || state.mode !== "panels" || state.panel !== action.panel || state.help || state.detail !== undefined || String(panelRows(state)[state.selection]?.id) !== action.id) return;
255
+ if (!active || state.mode !== "panels" || state.panel !== action.panel || state.help || state.detail !== undefined || String(selectedPanelRow(state)?.id) !== action.id) return;
227
256
  state.detailOffset = 0;
228
257
  state.detail = result.ok === false ? String(result.error) : action.panel === 3 ? labeled(String(result.text)) : result.delivery ?? "delivery not found";
229
258
  draw(); return;
@@ -282,10 +311,11 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
282
311
  terminal.raw(true);
283
312
  removers.push(terminal.onData(text => {
284
313
  // A pasted chunk can only edit text, never approve, confirm or run commands. Bracketed-paste delimiters are stripped.
285
- // Under the modal key table it does nothing at all. A read holding nothing but arrow sequences is that many
286
- // key presses (key repeat, a slow link), not a paste (#309): each arrow reaches the reducer on its own.
287
- if (Array.from(text).length > 1 && !["\x1b[A", "\x1b[B", "\x1b[C", "\x1b[D"].includes(text)) {
288
- 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); }
289
319
  else {
290
320
  state.attentionMessages = []; state.attentionSelection = undefined;
291
321
  if (state.help && state.mode === "panels") { draw(); return; }
package/src/cli/help.ts CHANGED
@@ -65,7 +65,7 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
65
65
  ["ahub pause|resume <peer>", "hold a peer's deliveries in its queue / release them"],
66
66
  ["ahub queue list [--peer <id>] [--json] [--color=auto|always|never]", "inspect durable deliveries"],
67
67
  ["ahub queue show <delivery-id> [--json] [--color=auto|always|never]", "inspect one delivery and revision"],
68
- ["ahub queue resolve <delivery-id> --action completed|retry|discard --reason <text>", "release a delivery held as needs_review: mark it completed, retry it or discard it"],
68
+ ["ahub queue resolve <delivery-id> --action completed|retry|discard --reason <text> [--revision N]", "resolve a queued or needs_review delivery; --revision binds the displayed row instead of a fresh show"],
69
69
  ]],
70
70
  ["Tasks and review", [
71
71
  ["ahub board import github --label <label> [--milestone <milestone>] [--repo <owner/repo>]", "person only: read bounded open issues into deduplicated Backlog; never writes GitHub"],
@@ -78,7 +78,8 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
78
78
  ["ahub backlog pull|return <id>", "person only: pull backlog into To Do or return ownerless proposed work; --backlog on task propose stages unoffered work"],
79
79
  ["ahub task show|escalate <id>", "full task with history (PII text included) / hand it to the next peer in escalate_to"],
80
80
  ["ahub task role <id> <role|none>", "person only: change bounded catalog guidance on an open task; routing and authority are unchanged"],
81
- ["ahub task assign <id> <peer> [--role owner|reviewer]", "name the owner (default) or reviewer; a task that still waits reserves its owner"],
81
+ ["ahub task assign <id> <peer> [--role owner|reviewer] [--revision N]", "name the owner (default) or reviewer; a task that still waits reserves its owner; --revision refuses stale console actions"],
82
+ ["ahub task unassign <id> [--role owner|reviewer] [--revision N]", "person only: remove the owner/reservation or reviewer without routing a replacement or stopping native work"],
82
83
  ["ahub task label <id> ok|regressed|reverted|incomplete|wrong|abandoned", "your later verdict on an approved task, kept in the research records (research must be on)"],
83
84
  ["ahub review <id> approved|changes_requested [note...] [--unmet <item>]...", "give a review verdict on a task"],
84
85
  ["ahub route explain <id>", "why a task went where it went, its reserved owner included"],
@@ -77,11 +77,12 @@ export function cliCommandLabel(command: string, args: readonly string[]): strin
77
77
  if (!["upgrade", "restart", "recovery", "recovery-run", "help", "--help", "--version", "version", "projects", "ui", "manager", "setup", "init", "daemon", "up", "console", "claude", "codex", "kimi", "qwen", "jcode", "pi", "local", "stop", "models", "say", "queue", "tail", "watch", "budget", "board", "task", "review", "remember", "ask", "route", "pause", "resume", "permit", "permission", "settings", "roster", "status", "pet", "logs", "export", "report", "research", "memory", "goal", "bench", "sdlc", "role", "facts", "check-path", "turns", "undo", "kill", "reset", "doctor"].includes(command)) return "unknown";
78
78
  if (["task", "queue", "budget", "route", "recovery", "models", "projects", "research", "memory", "goal", "bench"].includes(command)) {
79
79
  const sub = command === "bench" || command === "memory" || command === "goal" ? args.find((arg) => arg !== "--json") : args[0];
80
- if (sub && ["propose", "show", "assign", "escalate", "label", "list", "resolve", "resume", "set", "execution", "explain", "status", "abort", "dispose", "setup", "start", "stop", "remove", "export", "backfill", "addons", "run", "report", "compare", "search", "graph", "promote", "demote", "forget", "supersede", "dedupe", "rebuild", "sync", "machine", "import", "suggestions", "confirm", "reject", "new", "link", "state", "done", "take"].includes(sub)) return `${command} ${sub}`;
80
+ if (sub && ["propose", "show", "assign", "unassign", "escalate", "label", "list", "resolve", "resume", "set", "execution", "explain", "status", "abort", "dispose", "setup", "start", "stop", "remove", "export", "backfill", "addons", "run", "report", "compare", "search", "graph", "promote", "demote", "forget", "supersede", "dedupe", "decay", "rebuild", "sync", "machine", "import", "suggestions", "confirm", "reject", "new", "link", "state", "done", "take"].includes(sub)) return `${command} ${sub}`;
81
81
  }
82
82
  return (/^[a-z][a-z0-9-]*$/.test(command) && !/\s/.test(command)) || ["--help", "--version"].includes(command) ? command : "unknown";
83
83
  }
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.js CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env bun
2
2
  // Keep this entrypoint plain JavaScript: Node must fail before loading TypeScript or bun:sqlite.
3
3
  const version = /^(\d+)\.(\d+)\.(\d+)$/.exec(process.versions.bun ?? "");
4
- if (!version || !(Number(version[1]) > 1 || (Number(version[1]) === 1 && Number(version[2]) >= 3))) {
5
- console.error("ahub: Bun >=1.3.0 is required. Install Bun: https://bun.sh");
4
+ if (!version || !(Number(version[1]) > 1 || (Number(version[1]) === 1 && (Number(version[2]) > 4 || (Number(version[2]) === 4 && Number(version[3]) >= 2))))) {
5
+ console.error("ahub: Bun >=1.4.2 is required. Install Bun: https://bun.sh");
6
6
  process.exit(1);
7
7
  }
8
8