@staix/agent-hub 0.12.31 → 0.12.33

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 (79) hide show
  1. package/CHANGELOG.md +20 -1
  2. package/README.md +14 -7
  3. package/docs/agent-notes/adapters.md +7 -4
  4. package/docs/agent-notes/bus.md +3 -0
  5. package/docs/agent-notes/daemon.md +13 -8
  6. package/docs/agent-notes/local-worker.md +1 -1
  7. package/docs/agent-notes/tasks.md +4 -3
  8. package/docs/agent-notes/tests.md +1 -0
  9. package/docs/cooperbench.md +1 -1
  10. package/docs/gsd-collaboration-analysis.md +1 -1
  11. package/docs/memory.md +1 -1
  12. package/docs/operations.md +141 -25
  13. package/docs/quickstart.md +10 -6
  14. package/docs/security.md +18 -9
  15. package/docs/smoke.md +34 -0
  16. package/docs/specs/2026-09-19-agent-hub-design.md +204 -0
  17. package/docs/specs/2026-09-20-upgrade-recovery-design.md +211 -0
  18. package/docs/verification/2026-10-18-0.12.32.md +54 -0
  19. package/docs/verification/2026-10-18-0.12.33.md +42 -0
  20. package/docs/verified.json +24 -16
  21. package/package.json +2 -2
  22. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  23. package/plugins/agent-hub/server.js +1296 -26
  24. package/src/adapters/acp.ts +5 -6
  25. package/src/adapters/definitions/builtin-metadata.ts +121 -0
  26. package/src/adapters/definitions/kimi-native.ts +4 -0
  27. package/src/adapters/definitions/qwen-native.ts +24 -0
  28. package/src/adapters/definitions/qwen.ts +71 -0
  29. package/src/adapters/pi.ts +6 -2
  30. package/src/adapters/registry-types.ts +174 -0
  31. package/src/adapters/registry.ts +36 -0
  32. package/src/cli/console-state.ts +74 -5
  33. package/src/cli/console.ts +35 -2
  34. package/src/cli/help.ts +3 -1
  35. package/src/cli/identity.ts +13 -3
  36. package/src/cli/launch.ts +1 -6
  37. package/src/cli/main.ts +200 -39
  38. package/src/cli/output.ts +2 -0
  39. package/src/cli/preview.ts +8 -5
  40. package/src/cli/recovery-guidance.ts +137 -0
  41. package/src/cli/recovery-presentation-options.ts +8 -0
  42. package/src/cli/recovery-session-file.ts +18 -0
  43. package/src/cli/reset.ts +13 -1
  44. package/src/cli/terminal-recovery.ts +48 -0
  45. package/src/cli/upgrade-interactive.ts +129 -75
  46. package/src/cli/upgrade-labels.ts +30 -0
  47. package/src/cli/upgrade-presentation-data.ts +41 -0
  48. package/src/cli/upgrade-presentation-flags.ts +31 -0
  49. package/src/cli/upgrade-presentation.ts +227 -0
  50. package/src/cli/upgrade-restore-state.ts +6 -2
  51. package/src/cli/upgrade-runtime.ts +360 -199
  52. package/src/cli/upgrade.ts +278 -25
  53. package/src/hub/bus.ts +144 -14
  54. package/src/hub/cohorts.ts +2 -2
  55. package/src/hub/config-apply.ts +242 -0
  56. package/src/hub/config-reload.ts +120 -0
  57. package/src/hub/config-trust.ts +11 -2
  58. package/src/hub/control-client.ts +1 -1
  59. package/src/hub/crash.ts +8 -6
  60. package/src/hub/daemon.ts +577 -179
  61. package/src/hub/deferred-hub.ts +60 -0
  62. package/src/hub/delivery-journal.ts +6 -0
  63. package/src/hub/events.ts +2 -2
  64. package/src/hub/manager.ts +1 -1
  65. package/src/hub/peer-catalog.ts +10 -13
  66. package/src/hub/peers.ts +12 -2
  67. package/src/hub/permission-mode.ts +5 -3
  68. package/src/hub/recovery-continuity.ts +152 -0
  69. package/src/hub/recovery-store.ts +10 -4
  70. package/src/hub/restart.ts +12 -1
  71. package/src/hub/sdlc.ts +5 -1
  72. package/src/hub/settings.ts +50 -36
  73. package/src/hub/settled-roles.ts +35 -0
  74. package/src/hub/supervision.ts +3 -2
  75. package/src/hub/tasks.ts +29 -9
  76. package/src/hub/ui.ts +1 -1
  77. package/src/memory/brief.ts +6 -5
  78. package/src/pi/session.ts +23 -4
  79. package/src/ui/index.html +23 -7
@@ -6,6 +6,7 @@ import { PLANNER_PEERS } from "../hub/peer-catalog.ts";
6
6
  import { taskProgress } from "../ui/task-progress.ts";
7
7
  import type { ProgressStage } from "../ui/task-progress.ts";
8
8
  import { sanitize } from "../hub/envelope.ts";
9
+ import type { SettingRow, SettingValue } from "../hub/settings.ts";
9
10
 
10
11
  export interface Approval {
11
12
  id: string; peer: string; title: string; expiresAt: number;
@@ -13,7 +14,14 @@ export interface Approval {
13
14
  }
14
15
  export interface PermissionDefault { peer: string; mode: "never-ask"; source: string }
15
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
+ }
16
21
  export interface ConsoleState {
22
+ settingsRows?: SettingRow[]; settingsKnown?: boolean; settingsUndo?: string; settingsProblem?: string;
23
+ settingsEdit?: SettingsEdit; settingsSequence?: number;
24
+ pendingRestart?: { key: string; reason: string }[];
17
25
  features?: readonly string[];
18
26
  /** Older embedding callers omit choices and retain the original STAI/off toggle. */
19
27
  petChoices?: readonly CharacterId[]; petCharacter?: CharacterId; petSize?: PetSize; petGlyphs?: "ascii" | "blocks";
@@ -42,6 +50,8 @@ export interface ConsoleState {
42
50
  /** A detail view: text as the stream shows it, or labeled fields. */
43
51
  export type Detail = string | Record<string, unknown>;
44
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 } |
45
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" };
46
56
  export function initialConsoleState(panels = false): ConsoleState {
47
57
  return { mode: panels ? "panels" : "stream", panel: 1, selection: 0,
@@ -69,7 +79,7 @@ export function syncPermissionDefaults(s: ConsoleState, rows: unknown): boolean
69
79
  s.permissionDefaultsHandled = s.permissionDefaultsHandled.filter(fingerprint => current.has(fingerprint));
70
80
  const pending = s.confirm;
71
81
  if (pending?.type === "permission_default" && !s.permissionDefaults.some(row => row.peer === pending.peer && row.source === pending.source)) s.confirm = undefined;
72
- 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;
73
83
  const next = s.permissionDefaults.find(row => !s.permissionDefaultsHandled.includes(permissionDefaultFingerprint(s, row)));
74
84
  if (!next) return false;
75
85
  s.confirm = { type: "permission_default", peer: next.peer, source: next.source };
@@ -329,6 +339,7 @@ const KEYS: [string, string[]][] = [
329
339
  ["Tasks", ["a assign/reviewer (then y)", "u unassign (then y)", "r review (in review)"]],
330
340
  ["Queue", ["r resolve (reason, then y)", "queued=discard, needs_review=retry"]],
331
341
  ["Events", ["f peer filter", "g kind filter"]],
342
+ ["Settings", ["6 Settings panel", "e edit", "u undo", "preview, reason, then y"]],
332
343
  ];
333
344
  /** The `?` key table: one group per panel, its keys kept whole and packed into the width after the group column. */
334
345
  export function keyTable(columns: number, attention = false, characters = false): Span[][] {
@@ -388,6 +399,7 @@ export function panelRows(s: ConsoleState): any[] {
388
399
  const states = ["proposed", "in_progress", "in_review", "changes_requested"];
389
400
  return s.tasks.filter(t => states.includes(t.state)).sort((a, b) => states.indexOf(a.state) - states.indexOf(b.state));
390
401
  }
402
+ if (s.panel === 6) return [...(s.settingsRows ?? [])].sort((a, b) => a.group.localeCompare(b.group) || a.key.localeCompare(b.key));
391
403
  if (s.panel === 4) return s.queue.filter(q => q.state === "needs_review" || q.state === "queued");
392
404
  return s.events.filter(e => (!s.peerFilter || e.peer === s.peerFilter) && (!s.kindFilter || e.kind === s.kindFilter));
393
405
  }
@@ -435,12 +447,41 @@ function lockedCommandPrompt(lock: NonNullable<ConsoleState["actionLock"]>, args
435
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`;
436
448
  }
437
449
  export function reduceConsole(state: ConsoleState, key: string, now = Date.now()): { state: ConsoleState; effects: ConsoleEffect[] } {
438
- 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);
439
451
  // A notice answers the key before this one; one that pruning raises now stays.
440
452
  let s = pruneApprovals({ ...state, notice: "" }, now); const effects: ConsoleEffect[] = [];
441
453
  const done = () => ({ state: bound(s), effects });
442
454
  const answered = (id: string) => { s.approvals = s.approvals.filter(a => a.id !== id); if (s.approvalId === id) s.approvalId = undefined; };
443
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
+ }
444
485
  if (s.confirm) {
445
486
  const confirm = s.confirm; s.confirm = undefined;
446
487
  if (confirm.type === "permission_default") {
@@ -546,10 +587,19 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
546
587
  if (key === "\t") { s.mode = s.mode === "panels" ? "stream" : "panels"; s.detail = undefined; s.requestDetail = undefined; return done(); }
547
588
  if (key === ":") { s.editing = true; return done(); }
548
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(); };
549
- if (s.mode === "panels" && /^[1-5]$/.test(key) && !s.optionChoice) return switchPanel(Number(key));
590
+ if (s.mode === "panels" && /^[1-6]$/.test(key) && !s.optionChoice) return switchPanel(Number(key));
550
591
  // Right and Left are the digit of the next or previous tab, wrapping at both ends (#309); the same guards as the
551
592
  // digits apply (the confirm, mode-choice, key-table and editing branches above returned already).
552
- 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
+ }
553
603
  if (key === "?") {
554
604
  if (s.mode === "stream") effects.push({ type: "keys" });
555
605
  else s.help = !s.help;
@@ -685,6 +735,7 @@ export const TABLES: Record<number, string[]> = {
685
735
  2: ["ID", "PEER", "LEFT", "TITLE"],
686
736
  3: ["ID", "STATE", "OWNER", "REVIEWER", "CLASS", "AGE", "TITLE", "STAGE"],
687
737
  4: ["ID", "PEER", "STATE", "REV", "AGE"],
738
+ 6: ["GROUP", "KEY", "VALUE", "SOURCE", "APPLIES"],
688
739
  };
689
740
  const showPermissionModes = (s: ConsoleState) => Object.values(s.peers).some(peer => peer.permissionMode && peer.permissionMode !== "ask");
690
741
  const count = (n: unknown) => typeof n === "number" && n ? String(n) : "-";
@@ -714,6 +765,7 @@ export function cells(s: ConsoleState, row: any, selected: boolean, now: number,
714
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)),
715
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))];
716
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)];
717
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")];
718
770
  const [header, ...body] = String(row.text).split("\n");
719
771
  // The header goes through the stream's own tokenizer with the event's tone and kind, on the same tab-flattened
@@ -756,8 +808,10 @@ function taskHints(row: any): string[] {
756
808
  if (review) hints.push("r review");
757
809
  return hints;
758
810
  }
811
+ const settingValueText = (value: unknown): string => value === null || value === undefined ? "inherit" : Array.isArray(value) ? value.join(",") : String(value);
759
812
  function panelContext(s: ConsoleState, row: any): string[] {
760
813
  if (!row) return [];
814
+ if (s.panel === 6) return ["e edit", ...(s.settingsUndo ? ["u undo"] : [])];
761
815
  if (s.panel === 1) return ["p pause", "r resume", "m permission mode"];
762
816
  if (s.panel === 3) return [...taskHints(row), ...(s.features?.includes("task_cancel") ? ["c cancel"] : [])];
763
817
  if (s.panel === 4) return [row.state === "queued" ? "r discard" : "r resolve"];
@@ -778,7 +832,7 @@ function hint(s: ConsoleState, columns: number): string {
778
832
  function header(s: ConsoleState, columns: number): Span[] {
779
833
  const counts = [0, s.approvals.length, 0, s.queue.filter(q => q.state === "needs_review").length, 0];
780
834
  const tabs: Span[] = [];
781
- for (const [i, name] of ["Peers", "Approvals", "Tasks", "Queue", "Events"].entries()) {
835
+ for (const [i, name] of ["Peers", "Approvals", "Tasks", "Queue", "Events", "Settings"].entries()) {
782
836
  const active = i + 1 === s.panel; const tone = active ? "strong" : "info";
783
837
  tabs.push(span(active ? " [" : " ", tone), span(`${i + 1} ${name}`, tone));
784
838
  if (counts[i]) tabs.push(span(` ${counts[i]}`, i === 1 ? "attention" : "failure"));
@@ -822,6 +876,10 @@ export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24,
822
876
  let prompt = s.editing ? `: ${s.input}` : hint(s, columns);
823
877
  if (s.confirm?.type === "permission") prompt = `allow ${quoted(s.confirm.option)} for ${s.confirm.peer} (request ${s.confirm.id})? y/N`;
824
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
+ }
825
883
  if (s.modeChoice) prompt = `permission ${s.modeChoice}: 1 ask 2 ask-when-needed 3 never-ask Esc cancel`;
826
884
  const choice = s.optionChoice ? s.approvals.find(a => a.id === s.optionChoice) : undefined;
827
885
  if (choice) prompt = `allow request ${choice.id} (${choice.peer}) with: ${allowOptions(choice).map((o, i) => `${i + 1} ${quoted(o.name)}`).join(" ")} Esc cancel`;
@@ -834,6 +892,7 @@ export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24,
834
892
  if (p.paused) summary.push(span(" paused", "attention"));
835
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"));
836
894
  }
895
+ if (s.pendingRestart?.length) summary.push(span(`${summary.length ? " | " : ""}restart pending: ${s.pendingRestart.map(row => row.key).join(", ")}`, "attention"));
837
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(" ");
838
897
  if (quotas) summary.push(span(`${summary.length ? " | " : ""}${quotas}`));
839
898
  const approvals = [span(plural(s.approvals.length, "approval"), s.approvals.length ? "attention" : undefined)];
@@ -863,6 +922,10 @@ export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24,
863
922
  const height = bodyHeight - noticeLines.length;
864
923
  const lines: Span[][] = [];
865
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
+ }
866
929
  else if (s.help) lines.push(...keyTable(columns, !!s.petEnabled || !!s.petChoices?.length, !!s.petChoices?.length));
867
930
  else if (s.detail !== undefined || (s.requestDetail && permission)) {
868
931
  const detail = s.detail !== undefined ? detailLines(s.detail, columns, now) : fieldLines(approvalFields(permission!), columns, now).flat();
@@ -879,6 +942,12 @@ export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24,
879
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);
880
943
  const stages = new Map(progress.stages.map(stage => [stage.id, stage]));
881
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
+ }
882
951
  if (s.panel === 3) {
883
952
  const done = progress.total ? Math.floor(progress.counts.approved * 20 / progress.total) : 0;
884
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}%`;
@@ -189,11 +189,13 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
189
189
  const replies = await Promise.all([
190
190
  client.request({ t: "status" }, 3000), client.request({ t: "budget" }, 3000),
191
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)] : []),
192
193
  ]);
193
194
  if (!active) return;
194
- const [status, budget, tasks, queue, ready] = replies;
195
+ const [status, budget, tasks, queue, ready, settings] = replies;
195
196
  captureConsoleSelection(state);
196
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 : [];
197
199
  if (status.status) state.features = Array.isArray(status.status.features) ? status.status.features : [];
198
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] }]));
199
201
  if (status.status && status.ok !== false) {
@@ -209,6 +211,13 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
209
211
  state.tasksKnown = true;
210
212
  } }
211
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
+ }
212
221
  reconcileDetail();
213
222
  const error = replies.find(reply => reply.ok === false)?.error; if (error) notice(String(error));
214
223
  draw();
@@ -233,6 +242,23 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
233
242
  });
234
243
  const effect = async (action: ConsoleEffect) => {
235
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
+ }
236
262
  if (action.type === "permission_default") {
237
263
  const result = await client.request({ t: "permission_default", peer: action.peer, confirmed: action.confirmed }, 35_000);
238
264
  if (!active) return;
@@ -268,12 +294,13 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
268
294
  };
269
295
  const handle = (key: string) => {
270
296
  if (!active) return;
297
+ const priorPanel = state.panel;
271
298
  const result = reduceConsole(state, key); state = result.state;
272
299
  state.attentionMessages = []; state.attentionSelection = key === "n" ? state.attentionSelection : undefined;
273
300
  if (state.mode === "panels" && (plain || columns < 80 || rows < 24)) { state.mode = "stream"; notice("panels need at least 80x24"); }
274
301
  for (const action of result.effects) void effect(action).catch(error => stop(`Console error: ${String(error.message)}`));
275
302
  draw();
276
- if (key === "\t") void refresh();
303
+ if (key === "\t" || state.panel !== priorPanel) void refresh();
277
304
  };
278
305
  client.onPush = msg => {
279
306
  if (!active) return;
@@ -320,6 +347,12 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
320
347
  state.attentionMessages = []; state.attentionSelection = undefined;
321
348
  if (state.help && state.mode === "panels") { draw(); return; }
322
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
+ }
323
356
  state.confirm = undefined; state.optionChoice = undefined; state.editing = true;
324
357
  state.input += terminalText(text.replace(/\x1b\[20[01]~/g, "")).replace(/\n/g, " "); draw();
325
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,7 +87,7 @@ 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
  }
package/src/cli/launch.ts CHANGED
@@ -393,12 +393,7 @@ export function buildKimiLaunch(command: string[], model?: string): Launch {
393
393
  return { cmd: cmd!, args: model ? ["--model", model, ...args] : args };
394
394
  }
395
395
 
396
- /** Qwen flags follow the configured executable argv, including wrapper/script arguments. */
397
- export function buildQwenLaunch(command: string[], model?: string): Launch {
398
- const [cmd, ...args] = command;
399
- if (!cmd) throw new Error("qwen_cmd must name an executable");
400
- return { cmd, args: [...args, ...(model ? ["--model", model] : [])] };
401
- }
396
+ export { buildQwenLaunch } from "../adapters/definitions/qwen-native.ts";
402
397
 
403
398
  /** The configured ACP argv has a TUI form only when its exact final word is the ACP subcommand. */
404
399
  export function buildKimiTuiLaunch(command: string[], model?: string): Launch {