@staix/agent-hub 0.30.0 → 0.32.0

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 (130) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/README.md +13 -7
  3. package/docs/agent-notes/README.md +1 -0
  4. package/docs/agent-notes/adapters.md +5 -2
  5. package/docs/agent-notes/budget.md +1 -0
  6. package/docs/agent-notes/bus.md +11 -4
  7. package/docs/agent-notes/daemon.md +16 -8
  8. package/docs/agent-notes/local-worker.md +1 -0
  9. package/docs/agent-notes/tasks.md +7 -3
  10. package/docs/agent-notes/tests.md +6 -4
  11. package/docs/design/adapters.md +52 -0
  12. package/docs/design/agent-hub-architecture.md +46 -0
  13. package/docs/design/bus.md +178 -1
  14. package/docs/design/task-board.md +82 -0
  15. package/docs/dogfood.md +23 -4
  16. package/docs/events.md +3 -0
  17. package/docs/memory.md +17 -1
  18. package/docs/operations.md +355 -78
  19. package/docs/profiles.md +35 -0
  20. package/docs/quickstart.md +24 -5
  21. package/docs/security.md +178 -60
  22. package/docs/specs/2026-09-19-agent-hub-design.md +127 -14
  23. package/docs/specs/2026-09-19-multi-project-design.md +7 -2
  24. package/docs/verification/2026-11-04-1171-channel-client-evidence.md +31 -0
  25. package/docs/verification/2026-11-05-0.31.0.md +182 -0
  26. package/docs/verification/2026-11-05-claude-implementer-2.1.318.md +70 -0
  27. package/docs/verification/2026-11-05-hardcoded-values-classification-rules.md +311 -0
  28. package/docs/verification/2026-11-05-hardcoded-values-ratchet.md +55 -0
  29. package/docs/verification/2026-11-06-0.32.0.md +149 -0
  30. package/docs/verification/2026-11-06-rooms-presence-cost.md +152 -0
  31. package/docs/verification/claude-shell-wrapper-native.py +1 -1
  32. package/docs/verified.json +14 -14
  33. package/package.json +3 -4
  34. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  35. package/plugins/agent-hub/server.js +4393 -3609
  36. package/src/adapters/acp.ts +5 -4
  37. package/src/adapters/channel-peer.ts +72 -3
  38. package/src/adapters/claude-channel-evidence.ts +52 -0
  39. package/src/adapters/claude-channel.ts +61 -18
  40. package/src/adapters/claude-implementer.ts +29 -7
  41. package/src/adapters/claude-print-child.ts +12 -2
  42. package/src/adapters/codex-appserver.ts +13 -12
  43. package/src/adapters/compat.ts +3 -3
  44. package/src/adapters/definitions/builtin-metadata.ts +13 -13
  45. package/src/adapters/definitions/qwen.ts +1 -0
  46. package/src/adapters/local-worker.ts +61 -18
  47. package/src/adapters/pi.ts +48 -6
  48. package/src/adapters/registry-types.ts +12 -0
  49. package/src/adapters/registry.ts +2 -0
  50. package/src/adapters/reviewer-print.ts +50 -11
  51. package/src/broker/server.ts +44 -13
  52. package/src/broker/store.ts +38 -8
  53. package/src/cli/answer-args.ts +50 -0
  54. package/src/cli/attention.ts +17 -2
  55. package/src/cli/backlog-return-args.ts +37 -0
  56. package/src/cli/console-question.ts +110 -0
  57. package/src/cli/console-state.ts +78 -17
  58. package/src/cli/console.ts +54 -1
  59. package/src/cli/handoff-args.ts +15 -5
  60. package/src/cli/handoff-output.ts +15 -2
  61. package/src/cli/help.ts +8 -5
  62. package/src/cli/identity.ts +13 -3
  63. package/src/cli/main.ts +128 -16
  64. package/src/cli/memory.ts +107 -3
  65. package/src/cli/output.ts +29 -6
  66. package/src/cli/pet.ts +4 -4
  67. package/src/cli/results-args.ts +30 -0
  68. package/src/cli/room.ts +20 -10
  69. package/src/cli/runtime-version-warning.ts +50 -0
  70. package/src/cli/status-lines.ts +41 -1
  71. package/src/cli/tail-render.ts +1 -0
  72. package/src/cli/upgrade-interactive.ts +14 -2
  73. package/src/hub/action-class.ts +9 -1
  74. package/src/hub/board.ts +369 -18
  75. package/src/hub/bus.ts +511 -53
  76. package/src/hub/completion-detail.ts +50 -0
  77. package/src/hub/completions-store.ts +168 -0
  78. package/src/hub/completions.ts +120 -0
  79. package/src/hub/config-apply.ts +7 -5
  80. package/src/hub/config-reload.ts +10 -1
  81. package/src/hub/control-client.ts +40 -4
  82. package/src/hub/daemon.ts +1576 -139
  83. package/src/hub/delivery-failure.ts +14 -0
  84. package/src/hub/delivery-hints.ts +14 -4
  85. package/src/hub/delivery-journal.ts +751 -42
  86. package/src/hub/delivery-superseded.ts +4 -1
  87. package/src/hub/envelope.ts +3 -3
  88. package/src/hub/events.ts +6 -0
  89. package/src/hub/handoff.ts +145 -6
  90. package/src/hub/hub-tools.ts +56 -7
  91. package/src/hub/manager.ts +59 -3
  92. package/src/hub/memory-policy.ts +44 -1
  93. package/src/hub/memory-sync-policy.ts +109 -0
  94. package/src/hub/memory-sync-state.ts +145 -0
  95. package/src/hub/no-writes.ts +1 -1
  96. package/src/hub/operational-settings.ts +11 -2
  97. package/src/hub/operational-view.ts +12 -1
  98. package/src/hub/peers.ts +25 -2
  99. package/src/hub/planning.ts +6 -1
  100. package/src/hub/question-desk.ts +201 -0
  101. package/src/hub/question-detach.ts +6 -0
  102. package/src/hub/questions.ts +260 -0
  103. package/src/hub/research-evidence.ts +106 -0
  104. package/src/hub/restart.ts +14 -0
  105. package/src/hub/review-anchor.ts +181 -0
  106. package/src/hub/review-brief.ts +13 -0
  107. package/src/hub/review-comments.ts +115 -0
  108. package/src/hub/review-findings.ts +7 -2
  109. package/src/hub/review-material.ts +76 -0
  110. package/src/hub/routing.ts +3 -1
  111. package/src/hub/task-sweep.ts +2 -0
  112. package/src/hub/tasks.ts +577 -63
  113. package/src/memory/capture.ts +5 -2
  114. package/src/pet/config.ts +25 -13
  115. package/src/rooms/commands.ts +61 -16
  116. package/src/rooms/link.ts +338 -36
  117. package/src/rooms/liveness.ts +11 -0
  118. package/src/rooms/remote-peer.ts +186 -19
  119. package/src/rooms/service.ts +475 -35
  120. package/src/rooms/status.ts +8 -1
  121. package/src/rooms/store.ts +447 -65
  122. package/src/rooms/wire.ts +24 -6
  123. package/src/ui/index.html +142 -9
  124. package/src/ui/needs.ts +10 -4
  125. package/src/ui/plugin-questions.ts +56 -0
  126. package/src/ui/plugin-results.ts +81 -0
  127. package/src/ui/plugin.ts +207 -27
  128. package/src/pet/window.html +0 -114
  129. package/src/pet/window.jxa.js +0 -122
  130. package/src/pet/window.ts +0 -154
@@ -9,6 +9,7 @@ import { sanitize } from "../hub/envelope.ts";
9
9
  import { DOING_STUB } from "../hub/peer-doing.ts";
10
10
  import type { SettingRow, SettingValue } from "../hub/settings.ts";
11
11
  import { symbolFields, overlapFields } from "./console-symbols.ts";
12
+ import { flowBody, flowPrompt, startFlow, stepFlow, type QuestionFlow, type QuestionView } from "./console-question.ts";
12
13
  import { choicePrompt, reviewChoiceFor, reviewHint, reviewRefusal, verdictGuide, verdictInput, type ReviewChoice, type Verdict } from "./console-review.ts";
13
14
  import { configFormPrompt, reduceConfigForm, type ConfigEditBody, type ConfigForm } from "./console-config.ts";
14
15
  import { windowKind } from "../hub/window-kind.ts";
@@ -38,9 +39,13 @@ export interface ConsoleState {
38
39
  pendingRestart?: { key: string; reason: string }[];
39
40
  retainedConfig?: { key: string; reason: string }[];
40
41
  features?: readonly string[];
42
+ /** #1053 T4: the open questions the hub reported (the person's text, shown here only) and the answer being built; `A` starts it. */
43
+ questions?: QuestionView[]; questionFlow?: QuestionFlow; questionsSeen?: string[];
41
44
  /** Older embedding callers omit choices and retain the original STAI/off toggle. */
42
45
  petChoices?: readonly CharacterId[]; petCharacter?: CharacterId; petSize?: PetSize; petGlyphs?: "ascii" | "blocks";
43
46
  attentionFacts?: { kind: "review" | "offline" | "stall" | "offer"; id: number; peer?: string }[];
47
+ /** #1054: unread results from the hub, and the ids the stream has already told the person about. */
48
+ results?: readonly { id: string; peer?: string; since?: number }[]; resultsSeen?: string[];
44
49
  attentionFingerprint?: string;
45
50
  petEnabled?: boolean; petBlink?: boolean; attentionMessages?: { id?: string; peer?: string; important: boolean; since?: number }[]; storageBlocked?: boolean; recoveryNeed?: { id?: string; running: boolean; since?: number }; attentionSelection?: number;
46
51
  mode: "stream" | "panels"; panel: number; selection: number;
@@ -76,7 +81,7 @@ export interface ConsoleState {
76
81
  const roomsVisible = (s: Pick<ConsoleState, "rooms">) => s.rooms === null || Array.isArray(s.rooms) && s.rooms.length > 0;
77
82
  /** A detail view: text as the stream shows it, or labeled fields. */
78
83
  export type Detail = string | Record<string, unknown>;
79
- export type ConsoleEffect = { type: "exit" } | { type: "permit"; id: string; option?: string } |
84
+ export type ConsoleEffect = { type: "exit" } | { type: "permit"; id: string; option?: string } | { type: "answer"; set: string; answers: { questionId: string; optionIds: string[]; other?: string }[] } | { type: "peer_takeover"; peer: string; standbyId: string } |
80
85
  { type: "settings_preview"; token: number; key?: string; value?: SettingValue; undo: boolean } |
81
86
  { type: "config_preview"; token: number; edit: ConfigEditBody } | { type: "config_write"; edit: ConfigEditBody; reason: string; confirm?: string } |
82
87
  { type: "settings_write"; key?: string; value?: SettingValue; undo: boolean; reason: string; confirm?: string; undoDigest?: string } |
@@ -107,7 +112,7 @@ export function syncPermissionDefaults(s: ConsoleState, rows: unknown): boolean
107
112
  s.permissionDefaultsHandled = s.permissionDefaultsHandled.filter(fingerprint => current.has(fingerprint));
108
113
  const pending = s.confirm;
109
114
  if (pending?.type === "permission_default" && !s.permissionDefaults.some(row => row.peer === pending.peer && row.source === pending.source)) s.confirm = undefined;
110
- if (s.settingsEdit || s.confirm || s.editing || s.input || s.modeChoice || s.reviewChoice || s.optionChoice || s.help) return false;
115
+ if (s.settingsEdit || s.confirm || s.editing || s.input || s.modeChoice || s.reviewChoice || s.optionChoice || s.questionFlow || s.help) return false;
111
116
  const next = s.permissionDefaults.find(row => !s.permissionDefaultsHandled.includes(permissionDefaultFingerprint(s, row)));
112
117
  if (!next) return false;
113
118
  s.confirm = { type: "permission_default", peer: next.peer, source: next.source };
@@ -359,11 +364,14 @@ export function budgetFields(budget: any, now: number): [string, unknown][] {
359
364
  return [["quota", lines.length ? lines : typeof budget.note === "string" ? budget.note : "no reading"], ...(lines.length && typeof budget.note === "string" ? [["quota note", budget.note] as [string, unknown]] : [])];
360
365
  }
361
366
  /** #784 AC3: the Peers detail carries the planner WIP split as one line (`1 of 2: active 1, review-wait 0, reserved 0`); a hub that sends none adds nothing. */
362
- function peerDetailRow(s: ConsoleState, row: any): Record<string, unknown> {
367
+ function peerDetailRow(s: ConsoleState, row: any, now: number): Record<string, unknown> {
363
368
  const wip = s.taskWip?.[row.id];
364
369
  const split = wip && [wip.active, wip.reviewWait, wip.reserved].every(n => Number.isSafeInteger(n)) ? `${wip.wip} of ${wip.wipLimit}: active ${wip.active}, review-wait ${wip.reviewWait}, reserved ${wip.reserved}` : undefined;
365
370
  const isolation = ownerIsolationText(row.ownerIsolation, row.ownerIsolationNote);
366
- return { ...row, ...(isolation ? { ownerIsolation: isolation } : {}), ...(split ? { "planner WIP": split } : {}) };
371
+ const { standbySessions, ...fields } = row;
372
+ const standby = Array.isArray(standbySessions) ? standbySessions.filter((session: any) => session && typeof session.id === "string").map((session: any) =>
373
+ `${typeof session.pid === "number" ? `pid ${session.pid}` : "pid unknown"}; session ${session.id}${typeof session.since === "number" ? `; waiting since ${relative(session.since, now)}` : ""}`) : [];
374
+ return { ...fields, ...(standby.length ? { "standing by": standby } : {}), ...(isolation ? { ownerIsolation: isolation } : {}), ...(split ? { "planner WIP": split } : {}) };
367
375
  }
368
376
  const detailLines = (detail: Detail, columns: number, now: number, task = false): Span[][] => {
369
377
  if (typeof detail === "string") return wrap(detail, columns).map(text => [span(text)]);
@@ -397,9 +405,9 @@ export const LAST_KEYS = [">", "\x1b[F", "\x1bOF", "\x1b[4~", "\x1b[8~"];
397
405
  const KEYS: [string, string[]][] = [
398
406
  ["Everywhere", ["Tab stream/panels", ": command", "? keys", "Esc back", "q quit"]],
399
407
  ["Command", ["Enter run", "Esc cancel", "Up/Down history", "Ctrl-U clear"]],
400
- ["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 (outside Peers)", "o pet window"]],
401
- ["Approvals", ["a allow (then y)", "d deny", "v view", "[ ] select"]],
402
- ["Peers", ["p pause", "r resume", "m permission mode", "w WIP limit", "l make planner (then y)", "t rename Pi/local session"]],
408
+ ["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 (outside Peers)"]],
409
+ ["Approvals", ["a allow (then y)", "d deny", "v view", "[ ] select", "A answer an agent's question"]],
410
+ ["Peers", ["p pause", "r resume", "o takeover standby session", "m permission mode", "w WIP limit", "l make planner (then y)", "t rename Pi/local session"]],
403
411
  ["Tasks", ["b backlog/open", "n add backlog", "a assign/reserve (then y)", "u unassign (then y)", "r approve or request changes (in review; a or c, then a note)", "p pull, e plan (backlog)", "R return to backlog", "c cancel (then y)"]],
404
412
  ["Queue", ["r resolve (reason, then y)", "queued=discard, needs_review=retry", "D discard the peer's whole queued backlog (reason, then y)"]],
405
413
  ["Events", ["f peer filter", "g kind filter"]],
@@ -429,13 +437,22 @@ export function pruneApprovals(s: ConsoleState, now: number): ConsoleState {
429
437
  * dropped, and an open detail follows the selection from its top (it closes when nothing is selected). Every reducer
430
438
  * exit and every prune passes through here, so no key can act on a request other than the one shown.
431
439
  */
440
+ /**
441
+ * #1053: in stream mode there is no body to draw the question in, and the footer holds only the keys, so the question and every numbered option are printed into the
442
+ * stream when a flow opens and at each new part. In panels mode the body shows them (renderConsoleLines), so nothing is printed.
443
+ */
444
+ function showQuestion(s: ConsoleState, row: QuestionView, effects: ConsoleEffect[]): void {
445
+ if (s.mode !== "stream" || !s.questionFlow) return;
446
+ const body = flowBody(s.questionFlow, row, flat);
447
+ if (body) effects.push({ type: "print", text: [`${body.head}: ${body.prompt}`, ...body.options.map(line => ` ${line}`)].join("\n"), kind: "attention", tone: "attention" });
448
+ }
432
449
  function bound(s: ConsoleState): ConsoleState {
433
450
  if (s.optionChoice && s.optionChoice !== s.approvalId) s.optionChoice = undefined;
434
451
  if (s.confirm?.type === "permission" && s.confirm.id !== s.approvalId) s.confirm = undefined;
435
452
  if (s.requestDetail && s.requestDetail !== s.approvalId) { s.requestDetail = s.approvalId; s.detailOffset = 0; }
436
453
  return s;
437
454
  }
438
- const COMMANDS = new Set(["status", "board", "task", "backlog", "review", "say", "pause", "resume", "budget", "queue", "permit", "permission", "ask", "remember", "route", "sdlc", "turns", "undo", "check-path", "report", "ui", "pet"]);
455
+ const COMMANDS = new Set(["status", "board", "task", "backlog", "review", "say", "pause", "resume", "budget", "queue", "permit", "permission", "ask", "remember", "route", "sdlc", "turns", "undo", "check-path", "report", "ui", "pet", "peer"]);
439
456
  /** A tiny argv parser, never a shell. Quotes group arguments; backslash escapes one character. */
440
457
  export function parseConsoleCommand(input: string): string[] {
441
458
  const words: string[] = []; let word = ""; let quote = ""; let escaped = false; let started = false;
@@ -532,6 +549,22 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
532
549
  const done = () => ({ state: bound(s), effects });
533
550
  const answered = (id: string) => { s.approvals = s.approvals.filter(a => a.id !== id); if (s.approvalId === id) s.approvalId = undefined; };
534
551
  if (key === "\x03") { effects.push({ type: "exit" }); return done(); }
552
+ // #1053 T4: a question being answered takes every key until it is sent or left; the hub validates the answers against the stored row.
553
+ if (s.questionFlow) {
554
+ const row = s.questions?.find(item => item.set === s.questionFlow!.set);
555
+ if (!row) { s.questionFlow = undefined; notify(s, "that question is no longer open", now, "attention"); return done(); }
556
+ const step = stepFlow(s.questionFlow, row, key);
557
+ if (step.answers) { effects.push({ type: "answer", set: row.set, answers: step.answers }); s.questionFlow = undefined; }
558
+ else if (step.flow) { const moved = step.flow.index !== s.questionFlow.index; s.questionFlow = step.flow; if (moved) showQuestion(s, row, effects); }
559
+ else { s.questionFlow = undefined; if (step.notice) notify(s, step.notice, now, "attention"); }
560
+ return done();
561
+ }
562
+ if (key === "A" && !s.settingsEdit && !s.confirm && !s.editing && !s.input && !s.modeChoice && !s.reviewChoice && !s.optionChoice && !s.help && !s.configForm) {
563
+ const row = s.questions?.[0];
564
+ if (!row) { notify(s, "no open question; agents ask with hub_ask", now, "attention"); return done(); }
565
+ s.questionFlow = startFlow(row); s.detail = undefined; s.requestDetail = undefined; showQuestion(s, row, effects);
566
+ return done();
567
+ }
535
568
  if (s.configForm) {
536
569
  const step = reduceConfigForm(s.configForm, key, s.configRevisions ?? {});
537
570
  s.configForm = step.form;
@@ -698,6 +731,9 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
698
731
  s.approvalId = String(item.id);
699
732
  if (s.mode === "panels") { s.panel = 2; s.selection = s.approvals.findIndex(row => row.id === item.id); }
700
733
  navigated = true;
734
+ } else if (item.kind === "question") {
735
+ const row = s.questions?.find(question => question.set === item.id);
736
+ if (row) { s.questionFlow = startFlow(row); s.detail = undefined; s.requestDetail = undefined; navigated = true; showQuestion(s, row, effects); }
701
737
  } else if (item.kind === "delivery" && s.mode === "panels") {
702
738
  const rows = s.queue.filter(row => row.state === "needs_review" || row.state === "queued");
703
739
  const selected = item.id === undefined ? -1 : rows.findIndex(row => row.id === item.id);
@@ -764,9 +800,9 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
764
800
  return done();
765
801
  }
766
802
  if (key === "\x1b") { s.detail = undefined; s.requestDetail = undefined; s.help = false; s.optionChoice = undefined; return done(); }
767
- // #452: open the project dashboard or the pet window as a person's console command; both return at once.
768
- if (s.mode === "panels" && (key === "w" || key === "o") && !s.optionChoice) {
769
- effects.push({ type: "command", args: key === "w" ? ["ui"] : ["pet", "--window"] });
803
+ // #1012: the project dashboard stays on w; the floating pet window belongs to acon Mini.
804
+ if (s.mode === "panels" && key === "w" && !s.optionChoice) {
805
+ effects.push({ type: "command", args: ["ui"] });
770
806
  return done();
771
807
  }
772
808
  if (s.mode === "panels" && (FIRST_KEYS.includes(key) || LAST_KEYS.includes(key))) {
@@ -821,7 +857,7 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
821
857
  else if (s.panel === 2) { s.requestDetail = row.id; s.detailOffset = 0; } // the row is the selected request
822
858
  else if (s.panel === 7) { s.detail = { usagePeer: row.id }; s.detailOffset = 0; } // rendered live from s.usage, so a refresh updates it
823
859
  else if (s.panel === 8) { s.roomSelection = row.name; s.detail = { roomName: row.name }; s.detailOffset = 0; }
824
- else { s.detail = s.panel === 5 ? String(row.text) : s.panel === 1 ? peerDetailRow(s, row) : { ...row }; s.detailOffset = 0; }
860
+ else { s.detail = s.panel === 5 ? String(row.text) : s.panel === 1 ? peerDetailRow(s, row, now) : { ...row }; s.detailOffset = 0; }
825
861
  return done();
826
862
  }
827
863
  if (s.mode === "panels" && item !== undefined && item !== null) {
@@ -861,6 +897,15 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
861
897
  if (definitionOf(String(item))?.title !== "hub") { notify(s, "Rename this session in its native UI", now); return done(); }
862
898
  s.input = `title ${item} `; s.editing = true; return done();
863
899
  }
900
+ if (s.panel === 1 && key === "o") {
901
+ const standby = Array.isArray(selected.standbySessions) ? selected.standbySessions[0] : undefined;
902
+ if (!s.features?.includes("peer_takeover")) { notify(s, "peer takeover requires a newer hub", now); return done(); }
903
+ if (!standby || typeof standby.id !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(standby.id)) {
904
+ notify(s, "no current standby session is available; refresh ahub status", now); return done();
905
+ }
906
+ effects.push({ type: "peer_takeover", peer: String(item), standbyId: standby.id });
907
+ return done();
908
+ }
864
909
  const prefill = s.panel === 1 && key === "p" ? `pause ${item}` : s.panel === 1 && key === "r" ? `resume ${item}` : undefined;
865
910
  if (prefill) { s.input = prefill; s.editing = true; return done(); }
866
911
  if (s.panel === 3 && key === "c") {
@@ -941,7 +986,7 @@ export function cells(s: ConsoleState, row: any, selected: boolean, now: number,
941
986
  const paused = b?.paused ? `budget ${relative(b.paused.resetsAt, now)}` : row.paused && typeof row.paused === "object"
942
987
  ? `${row.paused.by ?? "paused"}${typeof row.paused.at === "number" ? ` ${duration(now - row.paused.at)}` : ""}`
943
988
  : row.paused === "manual" ? "user" : row.paused ? String(row.paused).split(":")[0] : "-";
944
- 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)),
989
+ return [id(row.planner ? `${row.id} (planner)` : row.id, row.state === "offline" ? undefined : "info"), span(`${row.rostered === false ? `${row.state} off roster` : row.state}${Array.isArray(row.standbySessions) && row.standbySessions.length ? ` +standby:${row.standbySessions.length}` : ""}`, stateTone(row.state)),
945
990
  ...(showPermissionModes(s) ? [span(row.permissionMode && row.permissionMode !== "ask" ? row.permissionMode : "-", row.permissionMode === "never-ask" ? "attention" : undefined)] : []),
946
991
  span(row.pullOnly && definitionOf(row.id)?.attach?.pullOnlyForms?.includes("tui") ? "pull-only" : row.toolsOnly ? "tools-only" : row.attached === false ? "detached" : "attached", row.toolsOnly ? "attention" : undefined),
947
992
  span(count(row.queued)), span(count(row.queuedImportant), row.queuedImportant ? "attention" : undefined),
@@ -986,7 +1031,7 @@ function roomDetail(row: RoomStatusView): Record<string, unknown> {
986
1031
  return {
987
1032
  room: row.name, "room id": row.room, "link state": row.link ?? "unknown", owner: row.owner, "local member": row.member,
988
1033
  "member count": count(row.memberCount),
989
- "remote peers": row.remotePeers === null ? "unknown" : row.remotePeers.length ? row.remotePeers.map(peer => `${peer.id} (${peer.state})`) : "0",
1034
+ "remote peers": row.remotePeers === null ? "unknown" : row.remotePeers.length ? row.remotePeers.map(peer => `${peer.id} (${peer.presence ?? peer.state})${peer.liveness === "untracked" ? "; liveness not tracked (broker without ephemeral); upgrade the broker to track liveness" : ""}`) : "0",
990
1035
  "counter scope": `since daemon start: ${since}`,
991
1036
  "out handoff": count(relays?.outbound), "in admitted": count(relays?.inbound), withheld: count(relays?.withheld),
992
1037
  };
@@ -1054,13 +1099,17 @@ function panelContext(s: ConsoleState, row: any): string[] {
1054
1099
  // The structural config form and the restart prefill need no selected row (#689).
1055
1100
  if (s.panel === 6) return [...(row ? ["e edit"] : []), "c config", ...(s.settingsUndo ? ["u undo"] : []), ...(s.pendingRestart?.length ? ["R restart"] : [])];
1056
1101
  if (!row) return [];
1057
- if (s.panel === 1) return ["p pause", "r resume", "m permission mode", "w WIP limit"];
1102
+ if (s.panel === 1) return ["p pause", "r resume", ...(s.features?.includes("peer_takeover") && Array.isArray(row.standbySessions) && row.standbySessions.length ? ["o takeover"] : []), "m permission mode", "w WIP limit"];
1058
1103
  if (s.panel === 4) return [row.state === "queued" ? "r discard" : "r resolve"]; // D (the peer's whole backlog) is in the ? key table: it must not push j/k off an 80-column footer
1059
1104
  return [];
1060
1105
  }
1061
1106
  /** The footer's key hint: only keys that act in this mode, panel and state. */
1062
1107
  function hint(s: ConsoleState, columns: number): string {
1063
- const pending = selectedApproval(s) ? ["a allow", "d deny"] : [];
1108
+ if (s.questionFlow) {
1109
+ const asking = s.questions?.find(row => row.set === s.questionFlow!.set)?.questions[s.questionFlow.index];
1110
+ return `${s.questionFlow.typing !== undefined ? "Enter keeps Esc back" : `number picks${asking?.allowOther === true ? " o other" : ""}${asking?.multi === true ? " Enter done" : ""} Esc cancel`}`;
1111
+ }
1112
+ const pending = [...(selectedApproval(s) ? ["a allow", "d deny"] : []), ...(s.questions?.length ? ["A answer"] : [])];
1064
1113
  if (s.mode === "stream") return [...pending, ...(pending.length ? ["v view"] : []), ...(s.approvals.length > (pending.length ? 1 : 0) ? ["[ ] select"] : []), "Tab panels", ": command", "? keys", "q quit"].join(" ");
1065
1114
  if (s.help) return "? or Esc close q quit";
1066
1115
  const approvals = s.panel === 2 ? pending : [];
@@ -1143,6 +1192,8 @@ export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24,
1143
1192
  if (s.modeChoice) prompt = `permission ${s.modeChoice}: 1 ask 2 ask-when-needed 3 never-ask Esc cancel`;
1144
1193
  if (s.reviewChoice) prompt = choicePrompt(s.reviewChoice);
1145
1194
  const choice = s.optionChoice ? s.approvals.find(a => a.id === s.optionChoice) : undefined;
1195
+ const asking = s.questionFlow ? s.questions?.find(row => row.set === s.questionFlow!.set) : undefined;
1196
+ if (s.questionFlow && asking) prompt = flowPrompt(s.questionFlow, asking, flat, columns);
1146
1197
  if (choice) prompt = `allow request ${choice.id} (${choice.peer}) with: ${allowOptions(choice).map((o, i) => `${i + 1} ${quoted(o.name)}`).join(" ")} Esc cancel`;
1147
1198
  const summary: Span[] = [];
1148
1199
  for (const [id, p] of Object.entries(s.peers)) {
@@ -1169,7 +1220,7 @@ export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24,
1169
1220
  if (s.confirm?.type === "permission_default") prompt = `enable never-ask default for ${s.confirm.peer}? y/n Esc defer`;
1170
1221
  const footer = [fitLine(neverPeer ? [span(permissionBoundary(neverPeer, undefined, createProjectAdapterCatalog()), "attention")] : summary, columns),
1171
1222
  fitLine(defaultSource !== undefined ? [span(`source ${quoted(defaultSource.split(/[\\/]/).at(-1))}; stays ask until y`, "attention")] : s.editing && s.reviewGuide ? [span(s.reviewGuide, "info")] : approvals, columns),
1172
- fitLine([span(prompt, s.confirm || s.optionChoice ? "attention" : undefined)], columns)];
1223
+ fitLine([span(prompt, s.confirm || s.optionChoice || asking ? "attention" : undefined)], columns)];
1173
1224
  const rule = [span("-".repeat(Math.max(0, columns)), "muted")];
1174
1225
  if (s.mode === "stream") {
1175
1226
  const counts = s.taskCounts;
@@ -1198,6 +1249,16 @@ export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24,
1198
1249
  const definition: [string, unknown][] = row ? [["type", row.type], ...(row.min !== undefined || row.max !== undefined ? [["range", `${row.min ?? "-"}..${row.max ?? "-"}`] as [string, unknown]] : []), ["risk", row.risk], ["applies", row.applies]] : [];
1199
1250
  lines.push(...fieldLines([["setting", edit.key ?? s.settingsUndo ?? "last write"], ...definition, ["preview", (edit.lines ?? []).join("\n")], ["decision", "Esc cancels; saving needs a reason and explicit y"]], columns, now).flat());
1200
1251
  }
1252
+ else if (s.questionFlow && asking && flowBody(s.questionFlow, asking, flat)) {
1253
+ // #1053: the question and every option in the body, wrapped, never the one-line footer that clips. The options come first and are never cut; the prompt gives way (marked) when the terminal is short.
1254
+ const body = flowBody(s.questionFlow, asking, flat)!;
1255
+ const optionLines = body.options.flatMap(option => wrap(option, columns, 3).map(text => [span(text, "info")]));
1256
+ const room = Math.max(1, height - 1 - optionLines.length);
1257
+ const promptLines = wrap(body.prompt, columns);
1258
+ const shown = promptLines.length > room ? [...promptLines.slice(0, Math.max(1, room) - 1), "..."] : promptLines;
1259
+ // Options that do not fit even wrapped are shown one per line (clipped to the width, but a label is at most 80 characters) rather than dropped: no option is ever off screen.
1260
+ lines.push([span(body.head, "attention")], ...shown.map(text => [span(text)]), ...(optionLines.length <= height - 1 ? optionLines : body.options.map(option => fitLine([span(option, "info")], columns))));
1261
+ }
1201
1262
  else if (s.help) lines.push(...keyTable(columns, !!s.petEnabled || !!s.petChoices?.length, !!s.petChoices?.length, roomsVisible(s)));
1202
1263
  else if (s.detail !== undefined || (s.requestDetail && permission)) {
1203
1264
  const usagePeer = s.panel === 7 && s.detail && typeof s.detail === "object" ? (s.detail as { usagePeer?: unknown }).usagePeer : undefined;
@@ -9,12 +9,14 @@ import { reviewResult } from "./console-review.ts";
9
9
  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";
10
10
  import type { ConsoleEffect, ConsoleEvent, Detail, Tone } from "./console-state.ts";
11
11
  import type { BusEvent } from "../hub/bus.ts";
12
- import { consoleNeeds, attentionFacts } from "./attention.ts";
12
+ import { consoleNeeds, attentionFacts, attentionResults } from "./attention.ts";
13
13
  import { loadPetConfig } from "./pet-config.ts";
14
14
  import { CHARACTER_IDS, petGlyphs, petSize } from "../pet/frame.ts";
15
15
  import { activeNotice, consoleFooterRows } from "./console-state.ts";
16
+ import { pasteFlow, questionRows } from "./console-question.ts";
16
17
  import { characterAttentionLine } from "./attention.ts";
17
18
  import { projectRoomStatus } from "../rooms/status.ts";
19
+ import { LIVE_TEST_PERSON_REFUSAL } from "./identity.ts";
18
20
 
19
21
  export interface ConsoleTerminal {
20
22
  columns: number; rows: number; isTTY: boolean; isRaw?: boolean;
@@ -208,6 +210,10 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
208
210
  ...(usageTask !== undefined ? [client.request({ t: "usage", by: "peer", task: usageTask }, 5000)] : [])]);
209
211
  })() : undefined;
210
212
  if (!active) return;
213
+ // #1053 T4: the person's own read of the open questions, asked only of a hub whose status lists the `questions` feature: an older hub is never sent a request it does not know.
214
+ const hasQuestions = Array.isArray(replies[0]?.status?.features) && replies[0].status.features.includes("questions");
215
+ const questionReply = hasQuestions ? await client.request({ t: "questions" }, 3000) : undefined;
216
+ if (!active) return;
211
217
  const [status, budget, tasks, queue, ready, settings] = replies;
212
218
  captureConsoleSelection(state);
213
219
  try {
@@ -219,6 +225,15 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
219
225
  state.petCharacter = pet.character ?? "stai"; state.petSize = pet.size; state.petGlyphs = petGlyphs(pet.glyphs);
220
226
  }
221
227
  } catch { /* retain running console presentation while a config file is unreadable */ }
228
+ if (questionReply?.ok === true) {
229
+ const rows = questionRows(questionReply.questions);
230
+ // A set the person has not been told about gets one stream line with no question text: A answers it, or ahub answer.
231
+ const seen = new Set(state.questionsSeen ?? []);
232
+ for (const row of rows) if (!seen.has(row.set) && state.questionsSeen !== undefined) stream({ text: ` ? ${row.peer} asks ${plural(row.questions.length, "question")}: A answers it here, or ahub answer ${row.set}`, peer: row.peer, kind: "question", tone: "attention" });
233
+ state.questionsSeen = rows.map(row => row.set);
234
+ state.questions = rows;
235
+ if (state.questionFlow && !rows.some(row => row.set === state.questionFlow!.set)) { state.questionFlow = undefined; notice("that question is no longer open"); }
236
+ } else if (questionReply?.ok === false) state.questions = [];
222
237
  if (status.status?.tasks && status.ok !== false) state.taskCounts = status.status.tasks;
223
238
  if (status.status && status.ok !== false) state.pendingRestart = Array.isArray(status.status.pendingRestart) ? status.status.pendingRestart : [];
224
239
  if (status.status && status.ok !== false) state.retainedConfig = Array.isArray(status.status.retainedConfig) ? status.status.retainedConfig : [];
@@ -241,6 +256,12 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
241
256
  if (status.status && status.ok !== false) {
242
257
  state.taskWip = status.status.taskWip ?? {};
243
258
  state.attentionFacts = attentionFacts(status.status.attentionItems);
259
+ // #1054: unread results (a run a person started that settled), ids and peers only. A result the person has not been told about gets one stream line with no text.
260
+ const unread = attentionResults(status.status.attentionItems);
261
+ const seenResults = new Set(state.resultsSeen ?? []);
262
+ for (const result of unread) if (!seenResults.has(result.id) && state.resultsSeen !== undefined) stream({ text: ` ✓ ${result.peer ?? "a peer"} finished a run you started: ahub results --read ${result.id}`, ...(result.peer ? { peer: result.peer } : {}), kind: "result", tone: "info" });
263
+ state.resultsSeen = unread.map(result => result.id);
264
+ state.results = unread;
244
265
  state.storageBlocked = !!status.status.deliveryError;
245
266
  state.recoveryNeed = status.status.recovery ? { id: status.status.recovery.operationId, running: true } : undefined;
246
267
  }
@@ -344,6 +365,36 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
344
365
  } else stream({ text: ` ${action.peer} never-ask default ${action.confirmed ? "enabled" : "cancelled for this hub"}`, kind: "permission_default", peer: action.peer, tone: "attention" });
345
366
  await refresh(); draw(); return;
346
367
  }
368
+ if (action.type === "answer") {
369
+ const result = await client.request({ t: "answer", surface: "console", questionSet: action.set, answers: action.answers }, 5000);
370
+ if (!active) return;
371
+ if (result.ok === false) notice(String(result.error ?? "the answer was refused"));
372
+ else notify(state, `answered ${action.set}`, Date.now(), "success");
373
+ await refresh(); draw(); return;
374
+ }
375
+ if (action.type === "peer_takeover") {
376
+ if (options.liveTest) { notice(LIVE_TEST_PERSON_REFUSAL); draw(); return; }
377
+ let result: any;
378
+ try { result = await client.request({ t: "peer_takeover", peer: action.peer, standbyId: action.standbyId }, 5000); }
379
+ catch {
380
+ notice("peer takeover result is unconfirmed; inspect ahub status before retrying; takeover may already have started");
381
+ await refresh(); draw(); return;
382
+ }
383
+ if (!active) return;
384
+ const transportError = String(result.error ?? "");
385
+ if (result.ok === false && (transportError === "hub connection closed" || transportError === "hub request could not be sent" || /^no answer from the hub within \d+ s$/.test(transportError))) {
386
+ notice("peer takeover result is unconfirmed; inspect ahub status before retrying; takeover may already have started");
387
+ await refresh(); draw(); return;
388
+ }
389
+ if (result.ok !== true) notice(String(result.error ?? "peer takeover was refused"));
390
+ else if (result.outcome !== "requested" || result.standbyId !== action.standbyId) notice("peer takeover result was not confirmed; refresh ahub status");
391
+ else {
392
+ const message = `${action.peer}: old session detached; standby attachment requested`;
393
+ stream({ text: message, peer: action.peer, kind: "peer_takeover", tone: "attention" });
394
+ notify(state, message, Date.now(), "attention");
395
+ }
396
+ await refresh(); draw(); return;
397
+ }
347
398
  if (action.type === "permit") {
348
399
  client.send({ t: "permit", surface: "console", id: action.id, ...(action.option ? { option: action.option } : {}) });
349
400
  if (!action.option) stream({ text: ` ! denial requested for permission ${action.id}`, kind: "permission", tone: "failure" });
@@ -434,6 +485,8 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
434
485
  state.attentionMessages = []; state.attentionSelection = undefined;
435
486
  if (state.help && state.mode === "panels") { draw(); return; }
436
487
  if (state.confirm?.type === "permission_default") { draw(); return; }
488
+ // #1053: while the person types an `o` answer a paste is that answer's text, never the command line's; pasteFlow keeps only printable characters, so a pasted Enter, Esc or digit cannot send, leave or pick.
489
+ if (state.questionFlow) { state.questionFlow = pasteFlow(state.questionFlow, text.replace(/\x1b\[20[01]~/g, "")); draw(); return; }
437
490
  if (state.settingsEdit) {
438
491
  const edit = state.settingsEdit;
439
492
  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) };
@@ -1,16 +1,25 @@
1
1
  // Argument parsing for `ahub task handoff` (#604, agent-hub#1030). Pure: argv in, one request or a usage line out, so main.ts stays a thin dispatcher and every form is testable without a daemon.
2
2
  import { CLASSES } from "../hub/board.ts";
3
+ import { HANDOFF_KEY } from "../hub/handoff.ts";
3
4
 
4
- export const HANDOFF_USAGE = "usage: ahub task handoff --project <root|id> [<class>] <title...> [--path <p>]... [--issue <n>] [--task <n>] [--detail <text>] [--json] [--color=auto|always|never]";
5
+ export const HANDOFF_USAGE = "usage: ahub task handoff --list | --sync | --project <root|id> [<class>] <title...> [--path <p>]... [--issue <n>] [--task <n>] [--detail <text>] [--duplicate-ok <reason>] [--key <uuid>] [--json] [--color=auto|always|never]";
5
6
 
6
- export interface HandoffCliRequest { project: string; title: string; class?: string; detail?: string; paths?: string[]; issue?: number; task?: number }
7
+ /** `--list` and `--sync` are the person's views of the handoffs this project made (#734): the list shows each one's age and whether its target is reachable, the sync asks the targets now. */
8
+ export type HandoffCliView = { view: "list" | "sync" };
9
+ export interface HandoffCliRequest { project: string; title: string; class?: string; detail?: string; paths?: string[]; issue?: number; task?: number; duplicate_ok?: string; handoffKey?: string }
7
10
 
8
11
  /** `operands` is argv after `task handoff`, presentation flags already removed. A first word that is a class name is the class, said out loud by the caller (the documented short form). */
9
- export function parseHandoffArgs(operands: readonly string[]): HandoffCliRequest | { usage: string } {
12
+ export function parseHandoffArgs(operands: readonly string[]): HandoffCliRequest | HandoffCliView | { usage: string } {
13
+ if (operands.includes("--list") || operands.includes("--sync")) {
14
+ // A view takes no other operand: `--list --project x` would read as filing a handoff and listing at once, which is neither.
15
+ const views = operands.filter(arg => arg === "--list" || arg === "--sync");
16
+ if (operands.length !== views.length || new Set(views).size !== 1) return { usage: HANDOFF_USAGE };
17
+ return { view: views[0] === "--list" ? "list" : "sync" };
18
+ }
10
19
  const one: Record<string, string> = {}, paths: string[] = [], rest: string[] = [];
11
20
  for (let i = 0; i < operands.length; i++) {
12
21
  const arg = operands[i]!;
13
- if (arg === "--project" || arg === "--detail" || arg === "--issue" || arg === "--task" || arg === "--path") {
22
+ if (arg === "--project" || arg === "--detail" || arg === "--issue" || arg === "--task" || arg === "--path" || arg === "--duplicate-ok" || arg === "--key") {
14
23
  const value = operands[++i];
15
24
  if (value === undefined || value.startsWith("--")) return { usage: HANDOFF_USAGE };
16
25
  if (arg === "--path") paths.push(value); else { if (Object.hasOwn(one, arg)) return { usage: HANDOFF_USAGE }; one[arg] = value; }
@@ -23,5 +32,6 @@ export function parseHandoffArgs(operands: readonly string[]): HandoffCliRequest
23
32
  if (!project || !title) return { usage: HANDOFF_USAGE };
24
33
  if (one["--issue"] !== undefined && !/^[1-9]\d{0,9}$/.test(one["--issue"])) return { usage: `${HANDOFF_USAGE}\n--issue is the target repository's issue number` };
25
34
  if (one["--task"] !== undefined && !/^[1-9]\d{0,9}$/.test(one["--task"])) return { usage: `${HANDOFF_USAGE}\n--task is this project's own task id` };
26
- return { project, title, ...(classed ? { class: rest[0]! } : {}), ...(one["--detail"] !== undefined ? { detail: one["--detail"] } : {}), ...(paths.length ? { paths } : {}), ...(one["--issue"] !== undefined ? { issue: Number(one["--issue"]) } : {}), ...(one["--task"] !== undefined ? { task: Number(one["--task"]) } : {}) };
35
+ if (one["--key"] !== undefined && !HANDOFF_KEY.test(one["--key"])) return { usage: `${HANDOFF_USAGE}\n--key is the key a handoff that was not confirmed named (a lowercase UUID v4): the same request with it is filed once` };
36
+ return { project, title, ...(classed ? { class: rest[0]! } : {}), ...(one["--detail"] !== undefined ? { detail: one["--detail"] } : {}), ...(paths.length ? { paths } : {}), ...(one["--issue"] !== undefined ? { issue: Number(one["--issue"]) } : {}), ...(one["--task"] !== undefined ? { task: Number(one["--task"]) } : {}), ...(one["--duplicate-ok"] !== undefined ? { duplicate_ok: one["--duplicate-ok"] } : {}), ...(one["--key"] !== undefined ? { handoffKey: one["--key"] } : {}) };
27
37
  }
@@ -19,7 +19,20 @@ export function renderHandoffReceipt(receipt: unknown, columns?: number): Span[]
19
19
  export function renderHandoffs(data: unknown, columns?: number): Span[][] {
20
20
  const rows: any[] = Array.isArray((data as any)?.handoffs) ? (data as any).handoffs : [];
21
21
  if (!rows.length) return [[s("no handoffs: hand work that belongs to another project with `ahub task handoff --project <root|id> <title...>`", "muted")]];
22
- return [...heading(`Handoffs (${rows.length})`, columns), ...grid(["PROJECT", "TASK", "STATE", "PR", "BY", "TITLE"], rows.map(row => [
22
+ // #734: a row from a hub that has no age or reach (an older daemon) shows "-" for both, never a guess.
23
+ const reach = (row: any): Span => {
24
+ const word = row.reach === "reachable" || row.reach === "unreachable" || row.reach === "moved" ? row.reach : undefined;
25
+ if (word === undefined) return s("-", "muted");
26
+ return s(word === "reachable" ? "reachable" : word === "moved" ? "moved" : "unreachable", word === "reachable" ? "success" : "attention");
27
+ };
28
+ const down = rows.filter(row => row.reach === "unreachable" || row.reach === "moved" ? typeof row.cause === "string" : false);
29
+ return [...heading(`Handoffs (${rows.length})`, columns), ...grid(["PROJECT", "TASK", "STATE", "AGE", "REACH", "PR", "BY", "TITLE"], rows.map(row => [
23
30
  s(String(row.remoteProject ?? "?").slice(0, 24), "issueRef"), s(`#${num(row.remoteTask)}`, "taskRef"), s(String(row.state ?? "?"), STATE_TONE[row.state]),
24
- s(typeof row.pr === "number" ? `#${row.pr}` : "-"), s(String(row.originPeer ?? "?")), s(String(row.title ?? ""))]), columns)];
31
+ s(typeof row.age === "string" ? row.age : "-", "muted"), reach(row),
32
+ s(typeof row.pr === "number" ? `#${row.pr}` : "-"), s(String(row.originPeer ?? "?")), s(String(row.title ?? ""))]), columns),
33
+ // The cause of each unreachable or moved target, once per target and with its next step, so the column stays narrow and the person still learns what to run.
34
+ ...[...new Map(down.map(row => [String(row.remoteProject), String(row.cause)])).entries()].map(([project, cause]) => [s(`${String(project).slice(0, 24)}: ${cause.slice(0, 200)}`, "attention")]),
35
+ // A sync that did not finish in time: the pass keeps running, so the list above is the state so far and the person is told what to run to read the rest.
36
+ ...(typeof (data as any)?.cause === "string" && (data as any)?.synced === false ? [[s(String((data as any).cause), "attention")]] : []),
37
+ ...((data as any)?.syncing === true ? [[s(`catch-up still running for ${num((data as any).pending)} target${(data as any).pending === 1 ? "" : "s"}: run \`ahub task handoff --list\` in a minute`, "attention")]] : [])];
25
38
  }
package/src/cli/help.ts CHANGED
@@ -23,9 +23,8 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
23
23
  ["ahub up [--unattended] [--no-console] [--no-agents]", "start the daemon, then ask it to start this project's default agents (start.agents, claude and codex unless chosen at setup; one line each, a refusal names the manual command; --no-agents skips them), then enter console in an interactive terminal"],
24
24
  ["ahub console [--panels] [--color=auto|always|never]", "enter the human console; P cycles built-in pet characters/off; exiting leaves the daemon running"],
25
25
  ["ahub console --no-pet", "turn off attention for this session; combines with --panels and --color"],
26
- ["ahub pet --window [--close]", "start or close the macOS floating window, from a person's terminal"],
27
26
  ["ahub pet [--short]", "read attention without connecting; --short prints ahub!N only when action is needed"],
28
- ["ahub pet --palette <blue|teal|violet|ochre>", "save the floating window's base color to pet.palette in .agenthub/config.local.json"],
27
+ ["ahub pet --palette <blue|teal|violet|ochre>", "retired floating-window option: run acon ui and open Mini"],
29
28
  ["ahub sdlc status [task] | explain <task> [--json] [--color=auto|always|never] | export <task> | record <task> <receipt-json>", "read head-bound lifecycle gates and the next actor/action; status and explain are open to any agent as a public view (private tasks stay stubs), export and record are person only; export stays JSON"],
30
29
  ["ahub sdlc queue <owner/repo> | release <owner/repo> [live-issue...] [--json] [--color=auto|always|never]", "read-only merge and release evidence; open to any agent as public readback of the checkout's repositories, never an authorization"],
31
30
  ["ahub sdlc merge <task> [--dry-run] [--json] [--color=auto|always|never]", "guarded squash merge of an approved task at its approved head after every gate is re-read; follows sdlc.toml auto_merge (off, dry-run, on); --dry-run only reports (person or conductor)"],
@@ -39,7 +38,7 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
39
38
  ["ahub sdlc release-assign <task> <revision> <release-id|none> <reason...>", "assign approved/in-flight work without changing execution"],
40
39
  ["ahub sdlc costs [owner/repo] [--json] [--color=auto|always|never]", "read bounded planner cost metrics for linked merged PRs"],
41
40
  ["ahub sdlc metrics [--since <YYYY-MM-DD|ISO time>] [--json] [--color=auto|always|never]", "read SDLC metrics and control band tiers from board history (planner or person; read-only)"],
42
- ["ahub backlog groom | return <task> <revision> <reason...> [--json] [--color=auto|always|never]", "read grooming findings or return accepted untouched work to backlog (planner/person)"],
41
+ ["ahub backlog groom | return <task> <revision> <reason...> [--reserved <peer> | --unreserve] [--json] [--color=auto|always|never]", "read grooming findings or return accepted untouched work to backlog (planner/person); --reserved names the peer the card is reserved for as it goes back, --unreserve clears the reservation (a person's reservation needs the person)"],
43
42
  ["ahub status [--json] [--full] [--color=auto|always|never]", "the hub, its peers, model backends and task counts, plus a specs line (errors and stale documents) when the project has design specs"],
44
43
  ["ahub status --all [--json] [--color=auto|always|never]", "show every registered project"],
45
44
  ["ahub logs [-f]", "person: last 100 hub.log lines (-f starts at 10); conductor: bounded public lines and follow while authority remains"],
@@ -65,6 +64,7 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
65
64
  ["ahub role list [--group <group>] | show <id>", "read bounded catalog guidance with layer and content hash"],
66
65
  ["ahub role assign <peer> <id> | clear <peer> [<id>]", "person only: add or clear catalog guidance, applied at the next peer start"],
67
66
  ["ahub stop <peer>", "person or configured conductor: stop a hub-owned headless peer; end TUI agents in their terminals"],
67
+ ["ahub peer takeover <peer> [standby-id] [--json] [--color=auto|always|never]", "person only: detach the attached session without stopping its process and request that the selected standby session attach; ahub status shows standby identities"],
68
68
  ["ahub models status [--json] [--color=auto|always|never]", "inspect local model state as labelled fields"],
69
69
  ["ahub models setup|start [--json] [--color=auto|always|never] | stop", "prepare or inspect local Ollama MLX; setup and start print the model state like status (legacy stop is explicit)"],
70
70
  ]],
@@ -74,6 +74,8 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
74
74
  ["ahub tail", "live stream of messages, states and permission requests"],
75
75
  ["ahub permission [<peer> [ask|ask-when-needed|never-ask]] [--yes]", "human only: inspect or set a running peer mode; never-ask requires --yes; config never-ask waits for console y"],
76
76
  ["ahub permit <id> <option>", "answer a permission request shown by tail (\"deny\" cancels)"],
77
+ ["ahub answer [<set> <question>=<option>[,<option>]... [--other [<question>=]<text>]] [--json] [--color=auto|always|never]", "person only: without operands list the questions agents asked with hub_ask; with them answer one set (every question once, known options only, other text only where offered)"],
78
+ ["ahub results [--unread] [--read <id> | --read-all | --unread-last] [--json] [--color=auto|always|never]", "person only: list what the runs you started left behind (unread ones marked *); --read marks one read, --read-all every one, --unread-last lights the most recent read one again; listing never marks anything"],
77
79
  ["ahub roster [--json] [--color=auto|always|never]", "every known peer, participation, start mode and retained work"],
78
80
  ["ahub roster add <peer> | remove <peer> [--yes] [--now]", "person or configured conductor: change live participation through settings; removal previews retained messages and task handoff; remove also deletes the roster key of an instance no longer declared"],
79
81
  ["ahub autonomy on [--all-projects] [--yes] | off [--all-projects] | status [--json]", "person only: apply the autonomy preset and machine default with one confirmation, optionally to every registered project; off removes fixed preset keys (manual edits included); status reports modes, floor enforcement and Claude homes; settings undo restores a live-hub project write"],
@@ -91,8 +93,8 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
91
93
  ["ahub board import github --label <label> [--milestone <milestone>] [--repo <owner/repo>]", "person or configured conductor: read bounded open issues into deduplicated Backlog; never writes GitHub"],
92
94
  ["ahub board [state | --ready] [--columns] [--release <name> | --release-id <id>] [--handoffs] [--json] [--full] [--color=auto|always|never]", "the task board, one line per task at a terminal width; --full: whole titles and SDLC advice; --ready: proposed tasks with nothing left to wait for; --handoffs: what this project handed to other projects and the last state each reported"],
93
95
  ["ahub task propose [--class <c> | <class>] <title...> [--owner <peer>] [--path <p>]... [--after <id>]... [--urgent] [--needs-network] [--needs-worktree] [--backlog] [--detail <text>]", "put a task on the board; --needs-network keeps it away from a peer whose shell has none; --needs-worktree keeps it away from a sandboxed peer unless the hub can make the task worktree for it (it makes it, commits and pushes at done); a first word that names a class is taken as the class; while a task named by --after is not approved yet, --owner reserves the task for that peer, offered to it first once it is ready; otherwise --owner assigns it now"],
94
- ["ahub task handoff --project <root|id> [<class>] <title...> [--path <p>]... [--issue <n>] [--task <n>] [--detail <text>] [--json] [--color=auto|always|never]", "person only: register work that belongs to ANOTHER project that runs agent-hub on that project's board (an ownerless proposal its own roles take from there); the target must be registered and running, nothing is queued; a feature or behavior change first needs a spec issue there (--issue); its state changes show in ahub board --handoffs (an agent that hands off gets them as task messages)"],
95
- ["ahub backlog list [--json] [--full] [--color=auto|always|never] | add <class> <title...> | plan <id> <revision> <fields-json> <reason...> | pin <id> <revision> true|false <reason...> | undo <id> <revision> [--json] [--color=auto|always|never]", "person only: plan work with a reason; {} records a note-only revision, fields.sdlc sets backlog repo/issue identity and clears old spec acceptance; preserve pins/frozen scope and refuse stale edits; list is by rank (--json: raw listing)"],
96
+ ["ahub task handoff --list | --sync | --project <root|id> [<class>] <title...> [--path <p>]... [--issue <n>] [--task <n>] [--detail <text>] [--duplicate-ok <reason>] [--key <uuid>] [--json] [--color=auto|always|never]", "person only: register work that belongs to ANOTHER project that runs agent-hub on that project's board (an ownerless proposal its own roles take from there); the target must be registered and running, nothing is queued; a feature or behavior change first needs a spec issue there (--issue), and an issue that already has an open task there is refused with those ids unless --duplicate-ok <reason>; a handoff the target did not confirm is reported as outcome_unknown with a key, and --key <uuid> files the same request once; --list shows each handoff with its age and whether its target is reachable (reachable, unreachable with the cause, or moved), and --sync asks every target now instead of at the next 5-minute pass; its state changes show in ahub board --handoffs (an agent that hands off gets them as task messages)"],
97
+ ["ahub backlog list [--json] [--full] [--color=auto|always|never] | add <class> <title...> | plan <id> <revision> <fields-json> <reason...> | pin <id> <revision> true|false <reason...> | person <id> <revision> true|false <reason...> | undo <id> <revision> [--json] [--color=auto|always|never]", "person only: plan work with a reason; {} records a note-only revision, fields.sdlc sets backlog repo/issue identity and clears old spec acceptance; preserve pins/frozen scope and refuse stale edits; person marks a leg only the person can run (live receipts, their accounts or approvals): no agent is offered it, auto pull and the idle sweep skip it, and it counts toward no planner WIP; list is by rank and shows it as waiting on the person (--json: raw listing)"],
96
98
  ["ahub release new <name> <goal> <order> | list | show <name> | plan <name> <revision> <dates-json> | activate|freeze|ship|drop <name> <revision> [--json] [--color=auto|always|never]", "person only, except show (any agent, public metadata): planned UTC millisecond dates and phased scope; ship records a time after approval, never tags or publishes"],
97
99
  ["ahub backlog split <id> <revision> <parts-json> <reason...> [--json] [--color=auto|always|never]", "person only: stage2..8 child prerequisites; original task retains integration, undo refuses used or changed children"],
98
100
  ["ahub backlog pull-ready <id> <revision> <reason...> [--json] [--color=auto|always|never]", "person only: reserve an eligible WIP slot before routing; pending unowned work can be undone"],
@@ -159,6 +161,7 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
159
161
  ["ahub memory sync [--remote <private git url>] | machine [--label <name>]", "sync hub memory with your other machines through a private git remote (a person's), or show or name this machine"],
160
162
  ["ahub memory import claude-mem [--db <path>] [--limit N] [--restart] | dedupe [--apply] | suggestions | confirm|reject <id>", "copy the hub's notes from claude-mem into hub memory (a person's); review the model's suggested edges"],
161
163
  ["ahub memory decay [--days N] [--json]", "person only: list long-term entries older than N days (default 90) that no open goal links; review only, nothing is changed"],
164
+ ["ahub memory declined [--json]", "person only: list declined physical outbound row ids and safe rule labels; never shows row text; reports incomplete scans explicitly"],
162
165
  ["ahub memory profile-check [--json]", "read-only check of the running hubs' memory bindings against their memory profiles; exits 1 when one is not ready"],
163
166
  ["ahub goal [list] [--all] | show <id> [--json]", "long-term tasks: state, which machine holds each and until when, linked board tasks"],
164
167
  ["ahub goal new <title> [--milestone <text>]... [--depends <id>]... [--shared] | link <id> <task> | state <id> <state> | done <id> | take <id> [--force]", "a person's changes to long-term tasks (link also the configured conductor's); take --force voids other machines' claims"],
@@ -17,6 +17,7 @@ export const LIVE_TEST_PERSON_REFUSAL = "live-test cannot write person-attribute
17
17
  export function liveTestRefusal(command: string, args: readonly string[], context: { repairedFresh?: boolean } = {}): boolean {
18
18
  if (command === "sdlc" && (args[0] === "authorize-release" || args[0] === "plan-review" || args[0] === "release-assign" || args[0] === "merge" || args[0] === "train" && args[1] !== "report")) return true;
19
19
  if (args.some(arg => arg === "--as-user" || arg.startsWith("--as-user="))) return true;
20
+ if (command === "pet" && (args.includes("--window") || args.includes("--palette"))) return false;
20
21
  const sub = command === "recovery" ? recoveryAction(args) : args.find(arg => arg !== "--json");
21
22
  return ["task", "role", "goal", "deliverables", "source", "broker", "schedule", "intake"].includes(command)
22
23
  || (command === "room" && !["list", "status"].includes(sub ?? ""))
@@ -33,7 +34,13 @@ export function liveTestRefusal(command: string, args: readonly string[], contex
33
34
  || (command === "specs" && args[0] === "stamp")
34
35
  // #1057: removing a worktree and its branch is the person's own act, in their own terminal; no delegation, live-test or otherwise, may confirm a tidy.
35
36
  || (command === "worktrees" && args.some(arg => arg === "--confirm" || arg.startsWith("--confirm=")))
36
- || (command === "agents" && !args.every(arg => arg === "--json"));
37
+ || (command === "agents" && !args.every(arg => arg === "--json"))
38
+ // #1053: an answer is the person's choice, recorded as the person's; an agent that could run it through `--live-test` would answer its own hub_ask as the person. Any operand answers (the set, a pick, --other); a bare listing stays allowed.
39
+ || (command === "answer" && args.some(arg => arg !== "--json" && arg !== "--full" && !arg.startsWith("--color=")))
40
+ // #1054: a receipt is the person's record that they read a result and clears their unread need; no delegation, live-test or otherwise, may write one. A plain listing stays allowed.
41
+ || (command === "results" && args.some(arg => arg === "--read" || arg.startsWith("--read=") || arg === "--read-all" || arg === "--unread-last"))
42
+ // A takeover is a person-authored lifecycle decision; never let an agent delegate it through --live-test.
43
+ || command === "peer";
37
44
  }
38
45
 
39
46
  export type CliIdentity =
@@ -139,9 +146,9 @@ export function classifyPeerCommand(command: string, args: readonly string[], ca
139
146
  if (command === "recovery" && recoveryAction(args) === "reattach") return "console";
140
147
  const native = catalog?.definitionOf(command) ?? definitionOf(command);
141
148
  if (native?.cli.personOnlyFlags?.some(flag => args.includes(flag))) return "console";
142
- if (command === "pet" && args.includes("--window")) return "console";
143
149
  if (command === "permission" || command === "watch") return "console";
144
150
  if (args.some((arg) => arg === "--as-user" || arg.startsWith("--as-user="))) return "console";
151
+ if (command === "pet" && (args.includes("--window") || args.includes("--palette"))) return "allowed";
145
152
  if (conductorOperation(command,args)) return "conductor";
146
153
  if (command === "stop") return "console";
147
154
  if (command === "sdlc" && ["release-guard", "release-guard-hook"].includes(args[0] ?? "")) return "allowed";
@@ -187,6 +194,7 @@ export function classifyPeerCommand(command: string, args: readonly string[], ca
187
194
 
188
195
  /** No argument text is included: notices and refusal logs must not copy task text or credentials. */
189
196
  export function cliCommandLabel(command: string, args: readonly string[], catalog?: ProjectAdapterCatalog): string {
197
+ if (command === "peer") return args[0] === "takeover" ? "peer takeover" : "peer";
190
198
  if (command === "backlog") return ["list", "add", "pull", "pull-ready", "split", "return", "plan", "pin", "undo"].includes(args[0] ?? "") ? `backlog ${args[0]}` : "backlog";
191
199
  if (command === "board" && args[0] === "import") return "board import";
192
200
  // #508: the registry's two forms are named in the audit and in a refusal, never their operands (a path or a URL can carry a secret).
@@ -210,10 +218,12 @@ export function cliCommandLabel(command: string, args: readonly string[], catalo
210
218
  if (command === "usage") return args[0] === "limits" || args[0] === "total" ? `usage ${args[0]}` : "usage";
211
219
  if (command === "deliverables") return "deliverables";
212
220
  if (command === "title") return "title";
221
+ // #1054: a result id is the hub's, but the audit names the action only, never the id.
222
+ if (command === "results") return ["--read-all", "--unread-last"].find(flag => args.includes(flag))?.slice(2) ? `results ${["--read-all", "--unread-last"].find(flag => args.includes(flag))!.slice(2)}` : args.includes("--read") || args.some(arg => arg.startsWith("--read=")) ? "results read" : "results";
213
223
  if (command === "specs") return ["list", "check", "covers", "stamp"].includes(args[0] ?? "") ? `specs ${args[0]}` : "specs";
214
224
  // #1057: the audit names the subcommand and whether it confirms, never a path, a branch or a fingerprint.
215
225
  if (command === "worktrees") return args[0] === "tidy" ? (args.some(arg => arg === "--confirm" || arg.startsWith("--confirm=")) ? "worktrees tidy confirm" : "worktrees tidy") : "worktrees";
216
- if (!(catalog?.definitionOf(command) ?? definitionOf(command)) && !["upgrade", "restart", "recovery", "recovery-run", "help", "--help", "--version", "version", "completion", "projects", "ui", "manager", "setup", "init", "daemon", "up", "console", "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", "down", "reset", "doctor", "agents", "source", "broker", "schedule", "intake", "learn", "study"].includes(command)) return "unknown";
226
+ if (!(catalog?.definitionOf(command) ?? definitionOf(command)) && !["upgrade", "restart", "recovery", "recovery-run", "help", "--help", "--version", "version", "completion", "projects", "ui", "manager", "setup", "init", "daemon", "up", "console", "stop", "models", "say", "queue", "tail", "watch", "budget", "board", "task", "review", "remember", "ask", "route", "pause", "resume", "permit", "answer", "permission", "results", "settings", "roster", "status", "pet", "logs", "export", "report", "research", "memory", "goal", "bench", "sdlc", "role", "facts", "check-path", "turns", "undo", "kill", "down", "reset", "doctor", "agents", "source", "broker", "schedule", "intake", "learn", "study"].includes(command)) return "unknown";
217
227
  if (["task", "queue", "budget", "route", "recovery", "models", "projects", "research", "memory", "goal", "bench", "agents", "roster", "settings"].includes(command)) {
218
228
  const sub = command === "bench" || command === "memory" || command === "goal" ? args.find((arg) => arg !== "--json") : args[0];
219
229
  if (sub && ["propose", "show", "assign", "unassign", "escalate", "cancel", "claim", "label", "list", "add", "resolve", "resume", "set", "execution", "explain", "status", "abort", "dispose", "reattach", "setup", "start", "stop", "remove", "discard", "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", "trust"].includes(sub)) return `${command} ${sub}`;