@staix/agent-hub 0.12.30 → 0.12.32

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 (102) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +29 -9
  3. package/docs/agent-notes/adapters.md +9 -5
  4. package/docs/agent-notes/budget.md +1 -1
  5. package/docs/agent-notes/bus.md +5 -0
  6. package/docs/agent-notes/daemon.md +14 -8
  7. package/docs/agent-notes/local-worker.md +3 -1
  8. package/docs/agent-notes/tasks.md +7 -1
  9. package/docs/agent-notes/tests.md +6 -2
  10. package/docs/cooperbench.md +1 -1
  11. package/docs/gsd-collaboration-analysis.md +1 -1
  12. package/docs/memory.md +2 -2
  13. package/docs/operations.md +236 -37
  14. package/docs/quickstart.md +24 -5
  15. package/docs/security.md +42 -10
  16. package/docs/smoke.md +34 -0
  17. package/docs/specs/2026-09-19-agent-hub-design.md +241 -3
  18. package/docs/specs/2026-09-20-upgrade-recovery-design.md +275 -10
  19. package/docs/verification/2026-10-17-0.12.31.md +71 -0
  20. package/docs/verification/2026-10-17-418-probes.md +21 -0
  21. package/docs/verification/2026-10-18-0.12.32.md +54 -0
  22. package/docs/verified.json +20 -15
  23. package/package.json +2 -2
  24. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  25. package/plugins/agent-hub/server.js +1361 -44
  26. package/src/adapters/acp.ts +5 -6
  27. package/src/adapters/claude-channel.ts +29 -11
  28. package/src/adapters/definitions/builtin-metadata.ts +121 -0
  29. package/src/adapters/definitions/kimi-native.ts +4 -0
  30. package/src/adapters/definitions/qwen-native.ts +24 -0
  31. package/src/adapters/definitions/qwen.ts +71 -0
  32. package/src/adapters/local-worker.ts +2 -2
  33. package/src/adapters/pi.ts +11 -2
  34. package/src/adapters/registry-types.ts +174 -0
  35. package/src/adapters/registry.ts +36 -0
  36. package/src/cli/attention.ts +5 -1
  37. package/src/cli/console-state.ts +130 -17
  38. package/src/cli/console.ts +54 -8
  39. package/src/cli/help.ts +3 -1
  40. package/src/cli/identity.ts +16 -5
  41. package/src/cli/jcode-mcp.ts +1 -1
  42. package/src/cli/launch.ts +1 -6
  43. package/src/cli/main.ts +208 -40
  44. package/src/cli/output.ts +15 -2
  45. package/src/cli/preview.ts +8 -5
  46. package/src/cli/recovery-guidance.ts +137 -0
  47. package/src/cli/recovery-presentation-options.ts +8 -0
  48. package/src/cli/recovery-session-file.ts +18 -0
  49. package/src/cli/terminal-recovery.ts +48 -0
  50. package/src/cli/upgrade-interactive.ts +129 -74
  51. package/src/cli/upgrade-labels.ts +30 -0
  52. package/src/cli/upgrade-presentation-data.ts +41 -0
  53. package/src/cli/upgrade-presentation-flags.ts +31 -0
  54. package/src/cli/upgrade-presentation.ts +217 -0
  55. package/src/cli/upgrade-restore-state.ts +137 -0
  56. package/src/cli/upgrade-runtime.ts +430 -175
  57. package/src/cli/upgrade.ts +325 -49
  58. package/src/hub/board.ts +82 -24
  59. package/src/hub/bus.ts +89 -29
  60. package/src/hub/check-runner.ts +514 -0
  61. package/src/hub/child-process.ts +1 -0
  62. package/src/hub/cohorts.ts +2 -2
  63. package/src/hub/config-apply.ts +242 -0
  64. package/src/hub/config-reload.ts +120 -0
  65. package/src/hub/config-trust.ts +11 -2
  66. package/src/hub/control-client.ts +5 -1
  67. package/src/hub/crash.ts +8 -6
  68. package/src/hub/daemon.ts +931 -209
  69. package/src/hub/delivery-hints.ts +30 -0
  70. package/src/hub/delivery-journal.ts +185 -12
  71. package/src/hub/delivery-superseded.ts +52 -0
  72. package/src/hub/envelope.ts +1 -1
  73. package/src/hub/events.ts +2 -2
  74. package/src/hub/host-executable.ts +11 -0
  75. package/src/hub/hub-tools.ts +9 -1
  76. package/src/hub/manager.ts +1 -1
  77. package/src/hub/peer-catalog.ts +13 -13
  78. package/src/hub/peers.ts +12 -2
  79. package/src/hub/permission-mode.ts +5 -3
  80. package/src/hub/recovery-continuity.ts +152 -0
  81. package/src/hub/recovery-store.ts +10 -4
  82. package/src/hub/routing.ts +35 -16
  83. package/src/hub/sdlc.ts +5 -1
  84. package/src/hub/settings.ts +52 -37
  85. package/src/hub/settled-roles.ts +35 -0
  86. package/src/hub/spec-read.ts +120 -0
  87. package/src/hub/supervision.ts +24 -8
  88. package/src/hub/task-sweep.ts +7 -6
  89. package/src/hub/tasks.ts +199 -40
  90. package/src/hub/ui.ts +1 -1
  91. package/src/memory/brief.ts +6 -5
  92. package/src/memory/capture.ts +2 -1
  93. package/src/pi/session.ts +23 -4
  94. package/src/ui/index.html +35 -16
  95. package/src/ui/needs.ts +7 -5
  96. package/src/ui/office-model.ts +4 -1
  97. package/src/ui/task-board.ts +1 -1
  98. package/src/ui/task-progress.ts +6 -2
  99. package/templates/AGENTS.block.md +1 -1
  100. package/templates/profiles/general.json +1 -1
  101. package/templates/profiles/research.json +1 -1
  102. package/templates/profiles/writing.json +1 -1
@@ -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,9 +2,11 @@ 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";
9
+ import type { SettingRow, SettingValue } from "../hub/settings.ts";
8
10
 
9
11
  export interface Approval {
10
12
  id: string; peer: string; title: string; expiresAt: number;
@@ -12,10 +14,18 @@ export interface Approval {
12
14
  }
13
15
  export interface PermissionDefault { peer: string; mode: "never-ask"; source: string }
14
16
  export interface ConsoleEvent { text: string; peer?: string; kind?: string; tone?: Tone }
17
+ export interface SettingsEdit {
18
+ token: number; key?: string; undo: boolean; phase: "value" | "preview" | "reason" | "confirm";
19
+ text: string; value?: SettingValue; reason?: string; lines?: string[]; confirm?: string; undoDigest?: string;
20
+ }
15
21
  export interface ConsoleState {
22
+ settingsRows?: SettingRow[]; settingsKnown?: boolean; settingsUndo?: string; settingsProblem?: string;
23
+ settingsEdit?: SettingsEdit; settingsSequence?: number;
24
+ pendingRestart?: { key: string; reason: string }[];
25
+ features?: readonly string[];
16
26
  /** Older embedding callers omit choices and retain the original STAI/off toggle. */
17
27
  petChoices?: readonly CharacterId[]; petCharacter?: CharacterId; petSize?: PetSize; petGlyphs?: "ascii" | "blocks";
18
- attentionFacts?: { kind: "review" | "offline"; id: number; peer?: string }[];
28
+ attentionFacts?: { kind: "review" | "offline" | "stall" | "offer"; id: number; peer?: string }[];
19
29
  attentionFingerprint?: string;
20
30
  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
31
  mode: "stream" | "panels"; panel: number; selection: number;
@@ -40,6 +50,8 @@ export interface ConsoleState {
40
50
  /** A detail view: text as the stream shows it, or labeled fields. */
41
51
  export type Detail = string | Record<string, unknown>;
42
52
  export type ConsoleEffect = { type: "exit" } | { type: "permit"; id: string; option?: string } |
53
+ { type: "settings_preview"; token: number; key?: string; value?: SettingValue; undo: boolean } |
54
+ { type: "settings_write"; key?: string; value?: SettingValue; undo: boolean; reason: string; confirm?: string; undoDigest?: string } |
43
55
  { type: "permission_default"; peer: string; confirmed: boolean } | { type: "command"; args: string[] } | { type: "show"; panel: number; id: string } | { type: "print"; text: string; kind: string; tone?: Tone } | { type: "keys" };
44
56
  export function initialConsoleState(panels = false): ConsoleState {
45
57
  return { mode: panels ? "panels" : "stream", panel: 1, selection: 0,
@@ -67,7 +79,7 @@ export function syncPermissionDefaults(s: ConsoleState, rows: unknown): boolean
67
79
  s.permissionDefaultsHandled = s.permissionDefaultsHandled.filter(fingerprint => current.has(fingerprint));
68
80
  const pending = s.confirm;
69
81
  if (pending?.type === "permission_default" && !s.permissionDefaults.some(row => row.peer === pending.peer && row.source === pending.source)) s.confirm = undefined;
70
- if (s.confirm || s.editing || s.input || s.modeChoice || s.optionChoice || s.help) return false;
82
+ if (s.settingsEdit || s.confirm || s.editing || s.input || s.modeChoice || s.optionChoice || s.help) return false;
71
83
  const next = s.permissionDefaults.find(row => !s.permissionDefaultsHandled.includes(permissionDefaultFingerprint(s, row)));
72
84
  if (!next) return false;
73
85
  s.confirm = { type: "permission_default", peer: next.peer, source: next.source };
@@ -315,15 +327,19 @@ function approvalFields(a: Approval): [string, unknown][] {
315
327
  export function permissionText(a: Pick<Approval, "id" | "peer" | "title" | "options">): string {
316
328
  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
329
  }
330
+ /** #441: Home/End as terminals send them, plus `<` and `>` for keyboards without those keys. */
331
+ export const FIRST_KEYS = ["<", "\x1b[H", "\x1bOH", "\x1b[1~", "\x1b[7~"];
332
+ export const LAST_KEYS = [">", "\x1b[F", "\x1bOF", "\x1b[4~", "\x1b[8~"];
318
333
  const KEYS: [string, string[]][] = [
319
334
  ["Everywhere", ["Tab stream/panels", ": command", "? keys", "Esc back", "q quit"]],
320
335
  ["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"]],
336
+ ["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
337
  ["Approvals", ["a allow (then y)", "d deny", "v view", "[ ] select"]],
323
- ["Peers", ["p pause", "r resume", "m permission mode"]],
338
+ ["Peers", ["p pause", "r resume", "m permission mode", "l make planner (then y)"]],
324
339
  ["Tasks", ["a assign/reviewer (then y)", "u unassign (then y)", "r review (in review)"]],
325
340
  ["Queue", ["r resolve (reason, then y)", "queued=discard, needs_review=retry"]],
326
341
  ["Events", ["f peer filter", "g kind filter"]],
342
+ ["Settings", ["6 Settings panel", "e edit", "u undo", "preview, reason, then y"]],
327
343
  ];
328
344
  /** The `?` key table: one group per panel, its keys kept whole and packed into the width after the group column. */
329
345
  export function keyTable(columns: number, attention = false, characters = false): Span[][] {
@@ -354,7 +370,7 @@ function bound(s: ConsoleState): ConsoleState {
354
370
  if (s.requestDetail && s.requestDetail !== s.approvalId) { s.requestDetail = s.approvalId; s.detailOffset = 0; }
355
371
  return s;
356
372
  }
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"]);
373
+ 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
374
  /** A tiny argv parser, never a shell. Quotes group arguments; backslash escapes one character. */
359
375
  export function parseConsoleCommand(input: string): string[] {
360
376
  const words: string[] = []; let word = ""; let quote = ""; let escaped = false; let started = false;
@@ -374,7 +390,7 @@ export function parseConsoleCommand(input: string): string[] {
374
390
  }
375
391
  export function commandNeedsConfirmation(args: string[]): boolean {
376
392
  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");
393
+ 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
394
  }
379
395
  export function panelRows(s: ConsoleState): any[] {
380
396
  if (s.panel === 1) return Object.entries(s.peers).map(([id, peer]) => ({ id, ...peer, budget: s.budget[id] }));
@@ -383,9 +399,19 @@ export function panelRows(s: ConsoleState): any[] {
383
399
  const states = ["proposed", "in_progress", "in_review", "changes_requested"];
384
400
  return s.tasks.filter(t => states.includes(t.state)).sort((a, b) => states.indexOf(a.state) - states.indexOf(b.state));
385
401
  }
402
+ if (s.panel === 6) return [...(s.settingsRows ?? [])].sort((a, b) => a.group.localeCompare(b.group) || a.key.localeCompare(b.key));
386
403
  if (s.panel === 4) return s.queue.filter(q => q.state === "needs_review" || q.state === "queued");
387
404
  return s.events.filter(e => (!s.peerFilter || e.peer === s.peerFilter) && (!s.kindFilter || e.kind === s.kindFilter));
388
405
  }
406
+ /** #441: Events follow the newest row while the selection is on the last one; a row the person moved to stays put. */
407
+ export function appendEvent(s: ConsoleState, event: ConsoleEvent, cap = 1000): void {
408
+ if (s.panel !== 5) { s.events = [...s.events, event].slice(-cap); return; }
409
+ const before = panelRows(s), held = before[s.selection], following = !held || s.selection >= before.length - 1;
410
+ s.events = [...s.events, event].slice(-cap);
411
+ const rows = panelRows(s);
412
+ s.selection = following ? Math.max(0, rows.length - 1) : Math.max(0, rows.indexOf(held));
413
+ }
414
+ const newestEvent = (s: ConsoleState) => Math.max(0, panelRows(s).length - 1);
389
415
  export const selectedApproval = (s: ConsoleState) => s.approvals.find(a => a.id === s.approvalId);
390
416
  /** The selected row: approvals and refreshed Tasks/Queue rows are bound by id, never by a replacement row. */
391
417
  const selection = (s: ConsoleState) => {
@@ -412,6 +438,7 @@ function lockedCommandPrompt(lock: NonNullable<ConsoleState["actionLock"]>, args
412
438
  const action = commandFlag(args, "--action") ?? "resolve";
413
439
  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
440
  }
441
+ if (lock.prefix[1] === "cancel") return `cancel task #${lock.id} at revision ${lock.revision}; native turns and worktrees are retained`;
415
442
  const role = commandFlag(args, "--role") ?? "owner";
416
443
  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
444
  const affected = role === "owner" ? [lock.owner, lock.reserved].filter(Boolean).join(" and ") : lock.reviewer ?? "none";
@@ -420,12 +447,41 @@ function lockedCommandPrompt(lock: NonNullable<ConsoleState["actionLock"]>, args
420
447
  return `unassign ${role} ${affected} on task #${lock.id} at revision ${lock.revision} -> ${resulting}${guidance}; native work and uncertain deliveries are not stopped or resolved`;
421
448
  }
422
449
  export function reduceConsole(state: ConsoleState, key: string, now = Date.now()): { state: ConsoleState; effects: ConsoleEffect[] } {
423
- const attentionKeyBlocked = !!(state.confirm || state.optionChoice || state.modeChoice || state.help || state.editing || state.input);
450
+ const attentionKeyBlocked = !!(state.settingsEdit || state.confirm || state.optionChoice || state.modeChoice || state.help || state.editing || state.input);
424
451
  // A notice answers the key before this one; one that pruning raises now stays.
425
452
  let s = pruneApprovals({ ...state, notice: "" }, now); const effects: ConsoleEffect[] = [];
426
453
  const done = () => ({ state: bound(s), effects });
427
454
  const answered = (id: string) => { s.approvals = s.approvals.filter(a => a.id !== id); if (s.approvalId === id) s.approvalId = undefined; };
428
455
  if (key === "\x03") { effects.push({ type: "exit" }); return done(); }
456
+ if (s.settingsEdit) {
457
+ const edit = { ...s.settingsEdit }; s.settingsEdit = edit;
458
+ if (key === "\x1b") { s.settingsEdit = undefined; return done(); }
459
+ if (edit.phase === "preview") return done();
460
+ if (edit.phase === "confirm") {
461
+ s.settingsEdit = undefined;
462
+ if (key === "y") {
463
+ if (edit.undo && (!edit.key || !edit.undoDigest)) { notify(s, "undo preview changed; reload settings and review it again", now); return done(); }
464
+ effects.push({ type: "settings_write", undo: edit.undo, ...(edit.key ? { key: edit.key } : {}), ...(!edit.undo ? { value: edit.value } : { undoDigest: edit.undoDigest }), reason: edit.reason!, ...(edit.confirm ? { confirm: edit.confirm } : {}) });
465
+ }
466
+ return done();
467
+ }
468
+ if (key === "\x7f" || key === "\b") edit.text = Array.from(edit.text).slice(0, -1).join("");
469
+ else if (key === "\x15") edit.text = "";
470
+ else if (key === "\r" || key === "\n") {
471
+ if (edit.phase === "reason") {
472
+ if (!edit.text.trim() || Buffer.byteLength(edit.text.trim()) > 400) { notify(s, "give a reason from 1 to 400 UTF-8 bytes", now); return done(); }
473
+ edit.reason = edit.text.trim(); edit.text = ""; edit.phase = "confirm";
474
+ } else {
475
+ const row = s.settingsRows?.find(row => row.key === edit.key);
476
+ if (!row) { s.settingsEdit = undefined; notify(s, "setting is no longer available", now); return done(); }
477
+ const text = edit.text;
478
+ edit.value = text === "inherit" ? null : row.type === "boolean" ? text === "true" ? true : text === "false" ? false : text : row.type === "number" ? text.trim() && Number.isFinite(Number(text)) ? Number(text) : text : row.type === "integer" ? /^(0|[1-9]\d*)$/.test(text) ? Number(text) : text : row.type === "peers" || row.type === "roles" ? text.split(",").map(value => value.trim()).filter(Boolean) : text;
479
+ edit.phase = "preview"; edit.text = "";
480
+ effects.push({ type: "settings_preview", token: edit.token, key: edit.key, value: edit.value, undo: false });
481
+ }
482
+ } else if (!key.startsWith("\x1b")) edit.text += terminalText(key).replace(/\n/g, "").slice(0, Math.max(0, 1000 - edit.text.length));
483
+ return done();
484
+ }
429
485
  if (s.confirm) {
430
486
  const confirm = s.confirm; s.confirm = undefined;
431
487
  if (confirm.type === "permission_default") {
@@ -530,17 +586,39 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
530
586
  }
531
587
  if (key === "\t") { s.mode = s.mode === "panels" ? "stream" : "panels"; s.detail = undefined; s.requestDetail = undefined; return done(); }
532
588
  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(); };
534
- if (s.mode === "panels" && /^[1-5]$/.test(key) && !s.optionChoice) return switchPanel(Number(key));
589
+ 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(); };
590
+ if (s.mode === "panels" && /^[1-6]$/.test(key) && !s.optionChoice) return switchPanel(Number(key));
535
591
  // Right and Left are the digit of the next or previous tab, wrapping at both ends (#309); the same guards as the
536
592
  // digits apply (the confirm, mode-choice, key-table and editing branches above returned already).
537
- if (s.mode === "panels" && (key === "\x1b[C" || key === "\x1b[D") && !s.optionChoice) return switchPanel((s.panel + (key === "\x1b[C" ? 0 : 3)) % 5 + 1);
593
+ if (s.mode === "panels" && (key === "\x1b[C" || key === "\x1b[D") && !s.optionChoice) return switchPanel(s.panel === 6 ? key === "\x1b[C" ? 1 : 5 : (s.panel + (key === "\x1b[C" ? 0 : 3)) % 5 + 1);
594
+ if (s.mode === "panels" && s.panel === 6 && s.detail === undefined && (key === "e" || key === "u")) {
595
+ const row = selectedPanelRow(s);
596
+ if (key === "u" && !s.settingsUndo) { notify(s, "nothing to undo", now); return done(); }
597
+ if (key === "e" && !row) return done();
598
+ const token = (s.settingsSequence ?? 0) + 1; s.settingsSequence = token;
599
+ s.settingsEdit = { token, undo: key === "u", ...(key === "e" ? { key: row.key } : {}), phase: key === "u" ? "preview" : "value", text: key === "e" ? settingValueText(row.fileValue) : "" };
600
+ if (key === "u") effects.push({ type: "settings_preview", token, undo: true });
601
+ return done();
602
+ }
538
603
  if (key === "?") {
539
604
  if (s.mode === "stream") effects.push({ type: "keys" });
540
605
  else s.help = !s.help;
541
606
  return done();
542
607
  }
543
608
  if (key === "\x1b") { s.detail = undefined; s.requestDetail = undefined; s.help = false; s.optionChoice = undefined; return done(); }
609
+ // #452: open the project dashboard or the pet window as a person's console command; both return at once.
610
+ if (s.mode === "panels" && (key === "w" || key === "o") && !s.optionChoice) {
611
+ effects.push({ type: "command", args: key === "w" ? ["ui"] : ["pet", "--window"] });
612
+ return done();
613
+ }
614
+ if (s.mode === "panels" && (FIRST_KEYS.includes(key) || LAST_KEYS.includes(key))) {
615
+ const first = FIRST_KEYS.includes(key);
616
+ if (s.detail !== undefined || s.requestDetail) { if (first) s.detailOffset = 0; return done(); }
617
+ s.selection = first ? 0 : Math.max(0, panelRows(s).length - 1);
618
+ if (s.panel === 2) s.approvalId = s.approvals[s.selection]?.id;
619
+ captureConsoleSelection(s, true);
620
+ return done();
621
+ }
544
622
  if (s.mode === "panels" && ["j", "k", "\x1b[A", "\x1b[B"].includes(key)) {
545
623
  if (s.detail !== undefined || s.requestDetail) { s.detailOffset = Math.max(0, s.detailOffset + (["j", "\x1b[B"].includes(key) ? 1 : -1)); return done(); }
546
624
  s.selection = Math.max(0, Math.min(panelRows(s).length - 1, selection(s) + (["j", "\x1b[B"].includes(key) ? 1 : -1)));
@@ -595,9 +673,25 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
595
673
  else s.modeChoice = peer;
596
674
  return done();
597
675
  }
676
+ // #455: the planner is a setting; the command runs only after y, as a person's terminal command.
677
+ if (s.panel === 1 && key === "l") {
678
+ const peer = String(item);
679
+ if (!(PLANNER_PEERS as readonly string[]).includes(peer)) notify(s, `${peer} cannot plan: the planner is one of ${PLANNER_PEERS.join(", ")}`, now);
680
+ else if (s.peers[peer]?.planner === "setting") notify(s, `${peer} is already the pinned planner`, now, "attention");
681
+ else s.confirm = { type: "command", args: ["settings", "set", "planner", peer], prompt: `make ${peer} the planner` };
682
+ return done();
683
+ }
598
684
  const prefill = s.panel === 1 && key === "p" ? `pause ${item}` : s.panel === 1 && key === "r" ? `resume ${item}` :
599
685
  s.panel === 3 && key === "r" && selected.state === "in_review" ? `review ${item} ` : undefined;
600
686
  if (prefill) { s.input = prefill; s.editing = true; return done(); }
687
+ if (s.panel === 3 && key === "c") {
688
+ if (!s.features?.includes("task_cancel")) { notify(s, "task cancellation requires a newer hub", now); return done(); }
689
+ const revision = selected.flowRevision;
690
+ if (!["proposed", "in_progress", "in_review", "changes_requested", "backlog"].includes(selected.state)) return done();
691
+ if (!Number.isSafeInteger(revision) || revision < 0) { notify(s, "task row actions need flowRevision; upgrade the hub", now); return done(); }
692
+ s.actionLock = { prefix: ["task", "cancel"], id: String(item), revision, state: String(selected.state) };
693
+ s.input = `task cancel ${item} --revision ${revision} --reason `; s.editing = true; return done();
694
+ }
601
695
  if (s.panel === 3 && (key === "a" || key === "u")) {
602
696
  if (!["proposed", "in_progress", "in_review", "changes_requested"].includes(selected.state)) return done();
603
697
  const revision = selected.flowRevision, role = selected.state === "in_review" || key === "u" && !selected.owner && !selected.reserved && selected.reviewer ? "reviewer" : "owner";
@@ -624,7 +718,7 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
624
718
  const values = [...new Set(s.events.map(e => e[field]).filter((x): x is string => !!x))];
625
719
  const next = values[values.indexOf(current ?? "") + 1];
626
720
  if (key === "f") s.peerFilter = next; else s.kindFilter = next;
627
- s.selection = 0; return done();
721
+ s.selection = newestEvent(s); return done();
628
722
  }
629
723
  if (s.mode === "stream" && !key.startsWith("\x1b")) { s.editing = true; s.input = terminalText(key).replace(/\n/g, ""); }
630
724
  return done();
@@ -641,6 +735,7 @@ export const TABLES: Record<number, string[]> = {
641
735
  2: ["ID", "PEER", "LEFT", "TITLE"],
642
736
  3: ["ID", "STATE", "OWNER", "REVIEWER", "CLASS", "AGE", "TITLE", "STAGE"],
643
737
  4: ["ID", "PEER", "STATE", "REV", "AGE"],
738
+ 6: ["GROUP", "KEY", "VALUE", "SOURCE", "APPLIES"],
644
739
  };
645
740
  const showPermissionModes = (s: ConsoleState) => Object.values(s.peers).some(peer => peer.permissionMode && peer.permissionMode !== "ask");
646
741
  const count = (n: unknown) => typeof n === "number" && n ? String(n) : "-";
@@ -652,7 +747,7 @@ export function cells(s: ConsoleState, row: any, selected: boolean, now: number,
652
747
  const paused = b?.paused ? `budget ${relative(b.paused.resetsAt, now)}` : row.paused && typeof row.paused === "object"
653
748
  ? `${row.paused.by ?? "paused"}${typeof row.paused.at === "number" ? ` ${duration(now - row.paused.at)}` : ""}`
654
749
  : 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)),
750
+ 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
751
  ...(showPermissionModes(s) ? [span(row.permissionMode && row.permissionMode !== "ask" ? row.permissionMode : "-", row.permissionMode === "never-ask" ? "attention" : undefined)] : []),
657
752
  span(row.pullOnly && row.id === "kimi" ? "pull-only" : row.toolsOnly ? "tools-only" : row.attached === false ? "detached" : "attached", row.toolsOnly ? "attention" : undefined),
658
753
  span(count(row.queued)), span(count(row.queuedImportant), row.queuedImportant ? "attention" : undefined),
@@ -666,10 +761,11 @@ export function cells(s: ConsoleState, row: any, selected: boolean, now: number,
666
761
  const at = last?.at ?? row.updated ?? row.created;
667
762
  const failed = last?.event === "check failed" || last?.event === "failed";
668
763
  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))];
764
+ const meter = stage?.cancelled ? "[----] cancelled" : stage ? "[" + "#".repeat(stage.stage - (stage.changes ? 1 : 0)) + (stage.changes ? "!" : "") + "-".repeat(4 - stage.stage) + "]" : "[----]";
765
+ 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)),
766
+ 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
767
  }
768
+ if (s.panel === 6) return [span(row.group), span(row.key, selected ? "strong" : "info"), span(settingValueText(row.value)), span(row.source), span(`${row.applies}${row.pending !== undefined ? ` (file ${settingValueText(row.fileValue)})` : ""}`, row.pending !== undefined ? "attention" : undefined)];
673
769
  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
770
  const [header, ...body] = String(row.text).split("\n");
675
771
  // The header goes through the stream's own tokenizer with the event's tone and kind, on the same tab-flattened
@@ -712,10 +808,12 @@ function taskHints(row: any): string[] {
712
808
  if (review) hints.push("r review");
713
809
  return hints;
714
810
  }
811
+ const settingValueText = (value: unknown): string => value === null || value === undefined ? "inherit" : Array.isArray(value) ? value.join(",") : String(value);
715
812
  function panelContext(s: ConsoleState, row: any): string[] {
716
813
  if (!row) return [];
814
+ if (s.panel === 6) return ["e edit", ...(s.settingsUndo ? ["u undo"] : [])];
717
815
  if (s.panel === 1) return ["p pause", "r resume", "m permission mode"];
718
- if (s.panel === 3) return taskHints(row);
816
+ if (s.panel === 3) return [...taskHints(row), ...(s.features?.includes("task_cancel") ? ["c cancel"] : [])];
719
817
  if (s.panel === 4) return [row.state === "queued" ? "r discard" : "r resolve"];
720
818
  return [];
721
819
  }
@@ -734,7 +832,7 @@ function hint(s: ConsoleState, columns: number): string {
734
832
  function header(s: ConsoleState, columns: number): Span[] {
735
833
  const counts = [0, s.approvals.length, 0, s.queue.filter(q => q.state === "needs_review").length, 0];
736
834
  const tabs: Span[] = [];
737
- for (const [i, name] of ["Peers", "Approvals", "Tasks", "Queue", "Events"].entries()) {
835
+ for (const [i, name] of ["Peers", "Approvals", "Tasks", "Queue", "Events", "Settings"].entries()) {
738
836
  const active = i + 1 === s.panel; const tone = active ? "strong" : "info";
739
837
  tabs.push(span(active ? " [" : " ", tone), span(`${i + 1} ${name}`, tone));
740
838
  if (counts[i]) tabs.push(span(` ${counts[i]}`, i === 1 ? "attention" : "failure"));
@@ -778,6 +876,10 @@ export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24,
778
876
  let prompt = s.editing ? `: ${s.input}` : hint(s, columns);
779
877
  if (s.confirm?.type === "permission") prompt = `allow ${quoted(s.confirm.option)} for ${s.confirm.peer} (request ${s.confirm.id})? y/N`;
780
878
  if (s.confirm?.type === "command") prompt = `${s.confirm.prompt ?? s.confirm.args.join(" ")}? y/N`;
879
+ if (s.settingsEdit) {
880
+ const edit = s.settingsEdit;
881
+ prompt = edit.phase === "value" ? `value for ${edit.key} (inherit removes): ${edit.text}` : edit.phase === "preview" ? "checking setting preview; Esc cancels" : edit.phase === "reason" ? `reason: ${edit.text}` : `${edit.undo ? "undo last setting" : `set ${edit.key}`} (${edit.reason})? y/N`;
882
+ }
781
883
  if (s.modeChoice) prompt = `permission ${s.modeChoice}: 1 ask 2 ask-when-needed 3 never-ask Esc cancel`;
782
884
  const choice = s.optionChoice ? s.approvals.find(a => a.id === s.optionChoice) : undefined;
783
885
  if (choice) prompt = `allow request ${choice.id} (${choice.peer}) with: ${allowOptions(choice).map((o, i) => `${i + 1} ${quoted(o.name)}`).join(" ")} Esc cancel`;
@@ -790,6 +892,7 @@ export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24,
790
892
  if (p.paused) summary.push(span(" paused", "attention"));
791
893
  if (p.toolsOnly) summary.push(span(id === "kimi" ? ` pull-only, read ${typeof p.lastInboxAt === "number" ? relative(p.lastInboxAt, now) : "never"}` : " tools-only: ahub claude", "attention"));
792
894
  }
895
+ if (s.pendingRestart?.length) summary.push(span(`${summary.length ? " | " : ""}restart pending: ${s.pendingRestart.map(row => row.key).join(", ")}`, "attention"));
793
896
  const quotas = Object.entries(s.budget).map(([id, b]) => `${id}:${b.windows?.map((w: any) => `${w.id} ${Math.round(w.used * 100)}%`).join("/") ?? "?"}`).join(" ");
794
897
  if (quotas) summary.push(span(`${summary.length ? " | " : ""}${quotas}`));
795
898
  const approvals = [span(plural(s.approvals.length, "approval"), s.approvals.length ? "attention" : undefined)];
@@ -819,6 +922,10 @@ export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24,
819
922
  const height = bodyHeight - noticeLines.length;
820
923
  const lines: Span[][] = [];
821
924
  if (s.confirm?.type === "permission_default") lines.push(...fieldLines([["default", `${s.confirm.peer} never-ask`], ["source", s.confirm.source], ["bounds", permissionBoundary(s.confirm.peer)], ["decision", "y enables; n cancels for this hub; Esc defers; until then ask"]], columns, now).flat());
925
+ else if (s.settingsEdit) {
926
+ const edit = s.settingsEdit;
927
+ lines.push(...fieldLines([["setting", edit.key ?? s.settingsUndo ?? "last write"], ["preview", (edit.lines ?? []).join("\n")], ["decision", "Esc cancels; saving needs a reason and explicit y"]], columns, now).flat());
928
+ }
822
929
  else if (s.help) lines.push(...keyTable(columns, !!s.petEnabled || !!s.petChoices?.length, !!s.petChoices?.length));
823
930
  else if (s.detail !== undefined || (s.requestDetail && permission)) {
824
931
  const detail = s.detail !== undefined ? detailLines(s.detail, columns, now) : fieldLines(approvalFields(permission!), columns, now).flat();
@@ -835,6 +942,12 @@ export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24,
835
942
  const capacity = Math.max(1, s.panel === 2 ? Math.min(Math.floor(height / 2) - 1, height - 3 - tail.length) : height - (s.panel === 3 ? 2 : 1)); const start = Math.max(0, at - capacity + 1);
836
943
  const stages = new Map(progress.stages.map(stage => [stage.id, stage]));
837
944
  const rowsOf = head ? table(head, data.map((row, i) => cells(s, row, i === at, now, stages)), columns) : undefined;
945
+ if (s.panel === 6) {
946
+ const row = selectedPanelRow(s);
947
+ const info = s.settingsProblem ?? (!s.settingsKnown ? "settings loading..." : row?.restartReason ?? row?.note ?? row?.label ?? "Settings");
948
+ lines.push(...wrap(info, columns).slice(0, 2).map(text => [span(text, s.settingsProblem ? "failure" : "info")]));
949
+ if (s.pendingRestart?.length) lines.push(fitLine([span(`pending restart: ${s.pendingRestart.map(row => `${row.key}: ${row.reason}`).join("; ")}`, "attention")], columns));
950
+ }
838
951
  if (s.panel === 3) {
839
952
  const done = progress.total ? Math.floor(progress.counts.approved * 20 / progress.total) : 0;
840
953
  const main = s.tasksKnown === false && !progress.total ? "tasks loading..." : `${progress.counts.approved}/${progress.total} approved [${"#".repeat(done)}${".".repeat(20 - done)}] ${progress.total ? Math.floor(progress.counts.approved * 100 / progress.total) : 0}%`;
@@ -4,7 +4,7 @@ import { StringDecoder } from "node:string_decoder";
4
4
  import type { ControlClient } from "../hub/control-client.ts";
5
5
  import { contextLine } from "./status-lines.ts";
6
6
  import { renderTailEvent } from "./tail-render.ts";
7
- import { syncPermissionDefaults, permissionDefaultText, approvalFrom, initialConsoleState, keyTable, notify, paint, permissionText, plural, pruneApprovals, reduceConsole, renderConsoleLines, resolveColor, stateTone, streamTokens, wrapStreamTokens, terminalText, wrap, captureConsoleSelection, selectedPanelRow } from "./console-state.ts";
7
+ import { syncPermissionDefaults, permissionDefaultText, approvalFrom, initialConsoleState, keyTable, notify, paint, permissionText, plural, pruneApprovals, reduceConsole, renderConsoleLines, resolveColor, stateTone, streamTokens, wrapStreamTokens, terminalText, wrap, captureConsoleSelection, selectedPanelRow, appendEvent, FIRST_KEYS, LAST_KEYS } from "./console-state.ts";
8
8
  import type { ConsoleEffect, ConsoleEvent, Detail, Tone } from "./console-state.ts";
9
9
  import type { BusEvent } from "../hub/bus.ts";
10
10
  import { consoleNeeds, attentionFacts } from "./attention.ts";
@@ -34,6 +34,17 @@ export interface ConsoleOptions {
34
34
  runCommand?: (args: string[], output: (text: string) => void, done: () => void, columns?: number) => (() => void);
35
35
  pollMs?: number;
36
36
  }
37
+ /** Exact navigation sequences only (#309, #441); anything else in a multi-character read is a paste. */
38
+ const NAVIGATION = ["\x1b[A", "\x1b[B", "\x1b[C", "\x1b[D", ...FIRST_KEYS, ...LAST_KEYS].filter(key => key.startsWith("\x1b"));
39
+ export function navigationPresses(text: string): string[] | undefined {
40
+ const keys: string[] = [];
41
+ for (let at = 0; at < text.length;) {
42
+ const key = NAVIGATION.find(k => text.startsWith(k, at));
43
+ if (!key) return undefined;
44
+ keys.push(key); at += key.length;
45
+ }
46
+ return keys.length ? keys : undefined;
47
+ }
37
48
  export const RESTORE_CONSOLE = "\x1b[?1049l\x1b[r\x1b[0m\x1b[?25h";
38
49
  /** Console-only styling uses event structure; tail text and message bodies are never parsed for meaning. */
39
50
  export function eventTone(e: BusEvent): Tone | undefined {
@@ -103,7 +114,7 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
103
114
  /** `record: false` is for the console's own prints (the key table, a request viewed again): not events, so not in Events. */
104
115
  const stream = (event: ConsoleEvent, record = true) => {
105
116
  if (event.kind === "command") event = { ...event, text: terminalText(event.text).replace(/\t/g, " ") };
106
- if (record) state.events = [...state.events, { ...event, text: terminalText(event.text) }].slice(-1000);
117
+ if (record) appendEvent(state, { ...event, text: terminalText(event.text) });
107
118
  if (plain) {
108
119
  const [header, ...body] = terminalText(event.text).split("\n");
109
120
  terminal.write(paint([...(color ? streamTokens(header ?? "", event.tone, event.kind) : [{ text: header ?? "" }]), ...body.map(text => ({ text: "\n" + text }))], color) + "\n");
@@ -178,11 +189,14 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
178
189
  const replies = await Promise.all([
179
190
  client.request({ t: "status" }, 3000), client.request({ t: "budget" }, 3000),
180
191
  ...(panels ? [client.request({ t: "task", op: "hub_task_list", args: {} }, 3000), client.request({ t: "queue", op: "list" }, 3000), client.request({ t: "task", op: "hub_task_list", args: { ready: true } }, 3000)] : []),
192
+ ...(panels && state.panel === 6 ? [client.request({ t: "settings_get" }, 3000)] : []),
181
193
  ]);
182
194
  if (!active) return;
183
- const [status, budget, tasks, queue, ready] = replies;
195
+ const [status, budget, tasks, queue, ready, settings] = replies;
184
196
  captureConsoleSelection(state);
185
197
  if (status.status?.tasks && status.ok !== false) state.taskCounts = status.status.tasks;
198
+ if (status.status && status.ok !== false) state.pendingRestart = Array.isArray(status.status.pendingRestart) ? status.status.pendingRestart : [];
199
+ if (status.status) state.features = Array.isArray(status.status.features) ? status.status.features : [];
186
200
  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
201
  if (status.status && status.ok !== false) {
188
202
  state.attentionFacts = attentionFacts(status.status.attentionItems);
@@ -197,6 +211,13 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
197
211
  state.tasksKnown = true;
198
212
  } }
199
213
  if (queue?.ok && state.mode === "panels" && Array.isArray(queue.deliveries)) state.queue = queue.deliveries;
214
+ if (settings) {
215
+ if (settings.ok && Array.isArray(settings.rows)) {
216
+ state.settingsRows = settings.rows; state.settingsKnown = true; state.settingsProblem = undefined;
217
+ state.settingsUndo = typeof settings.undo === "string" ? settings.undo : undefined;
218
+ state.pendingRestart = Array.isArray(settings.pendingRestart) ? settings.pendingRestart : [];
219
+ } else state.settingsProblem = String(settings.error ?? "settings are unavailable");
220
+ }
200
221
  reconcileDetail();
201
222
  const error = replies.find(reply => reply.ok === false)?.error; if (error) notice(String(error));
202
223
  draw();
@@ -221,6 +242,23 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
221
242
  });
222
243
  const effect = async (action: ConsoleEffect) => {
223
244
  if (action.type === "exit") return stop();
245
+ if (action.type === "settings_preview") {
246
+ const response = await client.request(action.undo ? { t: "settings_preview", undo: true } : { t: "settings_preview", key: action.key, value: action.value }, 3000);
247
+ const edit = state.settingsEdit;
248
+ if (!active || state.mode !== "panels" || state.panel !== 6 || !edit || edit.phase !== "preview" || edit.token !== action.token || edit.undo !== action.undo || edit.key !== action.key || (!action.undo && JSON.stringify(edit.value) !== JSON.stringify(action.value))) return;
249
+ if (response.ok === false) { state.settingsEdit = undefined; notice(String(response.error ?? "setting preview refused")); }
250
+ else if (action.undo && (typeof response.key !== "string" || !response.key || response.key.trim() !== response.key || response.key.length > 160 || typeof response.undoDigest !== "string" || !/^[a-f0-9]{64}$/i.test(response.undoDigest))) {
251
+ state.settingsEdit = undefined; notice("undo preview is unavailable or changed; reload settings and review it again");
252
+ } else state.settingsEdit = { ...edit, ...(action.undo ? { key: response.key, undoDigest: response.undoDigest } : {}), phase: "reason", text: "", lines: [...(Array.isArray(response.lines) ? response.lines.map(String) : []), ...(Array.isArray(response.tasks) ? response.tasks.flatMap((task: any) => [String(task.before?.join("\n") ?? ""), String(task.after?.join("\n") ?? "")]) : []), ...(response.hidden ? [`${response.hidden} more open tasks are not shown`] : [])], ...(typeof response.confirm === "string" ? { confirm: response.confirm } : {}) };
253
+ draw(); return;
254
+ }
255
+ if (action.type === "settings_write") {
256
+ const result = await client.request({ t: action.undo ? "settings_undo" : "settings_set", key: action.key, ...(!action.undo ? { value: action.value } : { undoDigest: action.undoDigest }), reason: action.reason, ...(action.confirm ? { confirm: action.confirm } : {}) }, 35_000);
257
+ if (!active) return;
258
+ if (result.ok === false) notice(String(result.error ?? "setting write refused"));
259
+ else stream({ text: String(result.text ?? "setting saved"), kind: "settings" });
260
+ await refresh(); draw(); return;
261
+ }
224
262
  if (action.type === "permission_default") {
225
263
  const result = await client.request({ t: "permission_default", peer: action.peer, confirmed: action.confirmed }, 35_000);
226
264
  if (!active) return;
@@ -256,12 +294,13 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
256
294
  };
257
295
  const handle = (key: string) => {
258
296
  if (!active) return;
297
+ const priorPanel = state.panel;
259
298
  const result = reduceConsole(state, key); state = result.state;
260
299
  state.attentionMessages = []; state.attentionSelection = key === "n" ? state.attentionSelection : undefined;
261
300
  if (state.mode === "panels" && (plain || columns < 80 || rows < 24)) { state.mode = "stream"; notice("panels need at least 80x24"); }
262
301
  for (const action of result.effects) void effect(action).catch(error => stop(`Console error: ${String(error.message)}`));
263
302
  draw();
264
- if (key === "\t") void refresh();
303
+ if (key === "\t" || state.panel !== priorPanel) void refresh();
265
304
  };
266
305
  client.onPush = msg => {
267
306
  if (!active) return;
@@ -299,14 +338,21 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
299
338
  terminal.raw(true);
300
339
  removers.push(terminal.onData(text => {
301
340
  // A pasted chunk can only edit text, never approve, confirm or run commands. Bracketed-paste delimiters are stripped.
302
- // Under the modal key table it does nothing at all. A read holding nothing but arrow sequences is that many
303
- // key presses (key repeat, a slow link), not a paste (#309): each arrow reaches the reducer on its own.
304
- if (Array.from(text).length > 1 && !["\x1b[A", "\x1b[B", "\x1b[C", "\x1b[D"].includes(text)) {
305
- if (/^(?:\x1b\[[ABCD])+$/.test(text)) { for (const key of text.match(/\x1b\[[ABCD]/g)!) handle(key); }
341
+ // Under the modal key table it does nothing at all. A read holding nothing but arrow or Home/End sequences is
342
+ // that many key presses (key repeat, a slow link), not a paste (#309, #441): each reaches the reducer on its own.
343
+ const presses = navigationPresses(text);
344
+ if (Array.from(text).length > 1 && presses?.length !== 1) {
345
+ if (presses) { for (const key of presses) handle(key); }
306
346
  else {
307
347
  state.attentionMessages = []; state.attentionSelection = undefined;
308
348
  if (state.help && state.mode === "panels") { draw(); return; }
309
349
  if (state.confirm?.type === "permission_default") { draw(); return; }
350
+ if (state.settingsEdit) {
351
+ const edit = state.settingsEdit;
352
+ if (edit.phase === "value" || edit.phase === "reason") state.settingsEdit = { ...edit, text: (edit.text + terminalText(text.replace(/\x1b\[20[01]~/g, "")).replace(/[\r\n]/g, " ")).slice(0, 1000) };
353
+ else state.settingsEdit = undefined;
354
+ draw(); return;
355
+ }
310
356
  state.confirm = undefined; state.optionChoice = undefined; state.editing = true;
311
357
  state.input += terminalText(text.replace(/\x1b\[20[01]~/g, "")).replace(/\n/g, " "); draw();
312
358
  }
package/src/cli/help.ts CHANGED
@@ -33,6 +33,7 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
33
33
  ["ahub restart [--dry-run] [--yes]", "recover this project's runtime; on a terminal without a flag it reviews the plan with you, follows the progress and offers the next steps"],
34
34
  ["ahub upgrade [--to <version>] [--dry-run] [--yes]", "review and upgrade running projects; --to defaults to the latest release (--yes needs it), and a newer release's own coordinator takes over; on a terminal without --dry-run or --yes: review, end agents first if you choose, apply, follow, and resume, cancel, end or reset on the spot"],
35
35
  ["ahub recovery [status|resume|abort] [<operation-id>]", "inspect, resume or cancel a preflight; status names the next actions; without an id, the operation that holds the lock, and with nothing else on a terminal its screen"],
36
+ ["ahub recovery reattach <operation-id> codex|claude|pi", "person only: repair that blocked peer with its recorded session; leaves the rest for recovery resume"],
36
37
  ["ahub recovery dispose [<operation-id>] --fresh-session codex|claude --reason <text>", "end a partial operation by starting that peer as a new session, recording the lost conversation"],
37
38
  ["ahub recovery dispose [<operation-id>] --stop-and-archive --reason <text>", "end a partial operation: stop its own targets, archive its snapshot and release the lock; the upgrade is abandoned, not completed"],
38
39
  ["ahub daemon [--unattended]", "internal: the detached hub process that ahub up or the dashboard manager starts"],
@@ -61,7 +62,7 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
61
62
  ["ahub roster [--json] [--color=auto|always|never]", "human only: every known peer, participation, start mode and retained work"],
62
63
  ["ahub roster add <peer> | remove <peer> [--yes] [--now]", "human only: change live participation through settings; removal previews retained messages and task handoff"],
63
64
  ["ahub settings [list] [--json] | get <key>", "human only: the settings the dashboard can change, each with its value, the file it comes from and when it applies"],
64
- ["ahub settings set <key> <value|inherit> [--yes] [--preview] [--now] | undo", "human only: change one, written to .agenthub/config.local.json or routing.local.toml (never a tracked file); --preview shows the effect and saves nothing; undo puts back the last write"],
65
+ ["ahub settings set <key> <value|inherit> [--yes] [--preview] [--now] | undo | reload", "human only: change or reload validated settings, written to .agenthub/config.local.json or routing.local.toml (never a tracked file); --preview shows the effect and saves nothing; undo puts back the last write"],
65
66
  ["ahub pause|resume <peer>", "hold a peer's deliveries in its queue / release them"],
66
67
  ["ahub queue list [--peer <id>] [--json] [--color=auto|always|never]", "inspect durable deliveries"],
67
68
  ["ahub queue show <delivery-id> [--json] [--color=auto|always|never]", "inspect one delivery and revision"],
@@ -80,6 +81,7 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
80
81
  ["ahub task role <id> <role|none>", "person only: change bounded catalog guidance on an open task; routing and authority are unchanged"],
81
82
  ["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
83
  ["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"],
84
+ ["ahub task cancel <id> --reason <text> [--superseded-by <ref>] [--revision N]", "person only: close obsolete work with a reason; retain its history without stopping native work or releasing dependents"],
83
85
  ["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)"],
84
86
  ["ahub review <id> approved|changes_requested [note...] [--unmet <item>]...", "give a review verdict on a task"],
85
87
  ["ahub route explain <id>", "why a task went where it went, its reserved owner included"],
@@ -1,15 +1,24 @@
1
+ import { recoveryPresentationFlag } from "./recovery-presentation-options.ts";
1
2
  import { isSupportedPeer } from "../hub/peer-catalog.ts";
2
3
 
3
4
  /** Recovery reads this declaration before delegating to another package's CLI. */
4
5
  export const LIVE_TEST_SUPPORTED = true;
5
6
 
7
+ /** Presentation prefixes cannot hide a person's recovery action from authorization. */
8
+ function recoveryAction(args: readonly string[]): string | undefined {
9
+ return args.find(arg => !recoveryPresentationFlag(arg));
10
+ }
11
+
12
+ export const LIVE_TEST_PERSON_REFUSAL = "live-test cannot write person-attributed content; use peer tools or a person's terminal";
13
+
6
14
  /** Person verdicts and user-authored workflow content are never delegated. */
7
- export function liveTestRefusal(command: string, args: readonly string[]): boolean {
15
+ export function liveTestRefusal(command: string, args: readonly string[], context: { repairedFresh?: boolean } = {}): boolean {
8
16
  if (args.some(arg => arg === "--as-user" || arg.startsWith("--as-user="))) return true;
9
- const sub = args.find(arg => arg !== "--json");
17
+ const sub = command === "recovery" ? recoveryAction(args) : args.find(arg => arg !== "--json");
10
18
  return ["task", "role", "goal"].includes(command)
11
19
  || (command === "backlog" && sub !== "list") || (command === "release" && !["list", "show"].includes(sub ?? ""))
12
20
  || (command === "memory" && sub !== "graph")
21
+ || (command === "recovery" && (sub === "reattach" || sub === "dispose" && context.repairedFresh === true))
13
22
  || (command === "board" && args[0] === "import") || (command === "sdlc" && args[0] === "record")
14
23
  || (command === "bench" && args.find(arg => arg !== "--json") === "run") || command === "pet";
15
24
  }
@@ -42,6 +51,7 @@ export type PeerCommandAccess = "allowed" | "conductor" | "console";
42
51
 
43
52
  /** A closed list: unknown commands cannot acquire console authority through an agent shell. */
44
53
  export function classifyPeerCommand(command: string, args: readonly string[]): PeerCommandAccess {
54
+ if (command === "recovery" && recoveryAction(args) === "reattach") return "console";
45
55
  if (command === "pi" && args.includes("--replace-session")) return "console";
46
56
  if (command === "pet" && args.includes("--window")) return "console";
47
57
  if (command === "permission" || command === "stop" || command === "watch") return "console";
@@ -77,11 +87,12 @@ export function cliCommandLabel(command: string, args: readonly string[]): strin
77
87
  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
88
  if (["task", "queue", "budget", "route", "recovery", "models", "projects", "research", "memory", "goal", "bench"].includes(command)) {
79
89
  const sub = command === "bench" || command === "memory" || command === "goal" ? args.find((arg) => arg !== "--json") : args[0];
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}`;
90
+ if (sub && ["propose", "show", "assign", "unassign", "escalate", "label", "list", "resolve", "resume", "set", "execution", "explain", "status", "abort", "dispose", "reattach", "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
91
  }
82
92
  return (/^[a-z][a-z0-9-]*$/.test(command) && !/\s/.test(command)) || ["--help", "--version"].includes(command) ? command : "unknown";
83
93
  }
84
94
 
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`;
95
+ export function peerCommandRefusal(peer: string, command: string, env: Readonly<Record<string, string | undefined>> = process.env): string {
96
+ const markers = ["CLAUDECODE", "CLAUDE_CODE_SESSION_ID", "AGENTHUB_PEER_ID", "CODEX_THREAD_ID"].filter(name => env[name] !== undefined);
97
+ 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
98
  }
@@ -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 {