@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
@@ -0,0 +1,227 @@
1
+ import { paint, quoted, terminalText, wrap, type Tone } from "./console-state.ts";
2
+ import { operationLines, stepLabel } from "./upgrade-labels.ts";
3
+ import { recordedPeerGuidance, stopAndArchiveSummary, type RecordedPeerGuidance } from "./recovery-guidance.ts";
4
+ import { restoreReceiptStatus } from "./upgrade-restore-state.ts";
5
+ import { recoveryCommand } from "../hub/recovery-store.ts";
6
+ import type { Inspection, NextChoice, RecoveryOperation, UpgradePlan } from "./upgrade.ts";
7
+
8
+ export interface RecoveryPresentationOptions {
9
+ columns: number;
10
+ color: boolean;
11
+ verbose?: boolean;
12
+ now(): number;
13
+ }
14
+ export type StepPresentationStatus = "done" | "running" | "waiting" | "blocked";
15
+ export interface MenuPresentationItem {
16
+ key: string;
17
+ label: string;
18
+ disabledReason?: string;
19
+ recommended?: boolean;
20
+ destructive?: boolean;
21
+ }
22
+
23
+ /** Human output only. Raw receipt JSON must bypass this module.
24
+ * ponytail: long command previews wrap as prose; raw JSON retains the copyable command.
25
+ * A shell-token continuation formatter can replace this if copying wrapped commands is required.
26
+ */
27
+ function lines(value: string, tone: Tone, options: RecoveryPresentationOptions): string[] {
28
+ const columns = Number.isFinite(options.columns) ? Math.max(2, Math.floor(options.columns)) : 80;
29
+ // A logical renderer line must stay one logical line: receipt text cannot introduce another hub heading.
30
+ // Quote only control-containing values so ordinary text and command previews retain their exact spelling.
31
+ const logical = /\p{Cc}/u.test(value) ? quoted(value) : value;
32
+ return wrap(logical, columns).map(text => paint([{ text, tone }], options.color));
33
+ }
34
+ function elapsed(ms: number): string {
35
+ const seconds = Math.max(0, Math.floor(ms / 1000));
36
+ if (seconds < 60) return `${seconds}s`;
37
+ const minutes = Math.floor(seconds / 60), remainder = seconds % 60;
38
+ if (minutes < 60) return `${minutes}m${remainder ? `${String(remainder).padStart(2, "0")}s` : ""}`;
39
+ return `${Math.floor(minutes / 60)}h${minutes % 60 ? `${String(minutes % 60).padStart(2, "0")}m` : ""}`;
40
+ }
41
+ const tones: Record<StepPresentationStatus, Tone> = { done: "success", running: "info", waiting: "attention", blocked: "failure" };
42
+
43
+ export function renderStep(label: string, status: StepPresentationStatus, elapsedMs: number | undefined, options: RecoveryPresentationOptions): string[] {
44
+ const time = elapsedMs !== undefined && Number.isFinite(elapsedMs) && elapsedMs >= 0 ? elapsed(elapsedMs) : "timing unknown";
45
+ return lines(`[${status}] ${label} (${time})`, tones[status], options);
46
+ }
47
+
48
+ /** Policy rows come from the existing plan helper; this renderer adds no peer-kind policy. */
49
+ export function renderPlan(plan: UpgradePlan, current: string, options: RecoveryPresentationOptions, rows: readonly string[]): string[] {
50
+ return [
51
+ ...lines(`${plan.kind} to ${plan.version}: plan`, "strong", options),
52
+ ...lines(`Coordinator version ${current}`, "muted", options),
53
+ ...rows.slice(1).flatMap(line => lines(line, line.trimStart().startsWith("blocker:") ? "failure" : line.trimStart().startsWith("in progress:") ? "attention" : "info", options)),
54
+ ];
55
+ }
56
+
57
+ const projectPhases = {
58
+ pending: "waiting to begin",
59
+ prepared: "source held for recovery",
60
+ stopped: "source stopped; target start remains",
61
+ started: "target started; session restoration remains",
62
+ "peers-restored": "sessions restored; final verification remains",
63
+ verified: "verified and released",
64
+ } as const;
65
+
66
+ export function renderOperation(op: RecoveryOperation, runner: number | "unknown" | undefined, options: RecoveryPresentationOptions): string[] {
67
+ const status: StepPresentationStatus = op.phase === "completed" ? "done" : op.phase === "blocked" ? "blocked" : op.phase === "running" ? "running" : "waiting";
68
+ const label = op.step === "stage" && op.targetRoot && op.targetDigest ? "checking retained release" : stepLabel(op.step);
69
+ const output = [...lines(`${op.plan.kind} to ${op.plan.version}: operation ${op.id}`, "strong", options), ...renderStep(label, status, undefined, options)];
70
+ if (runner === "unknown") output.push(...lines("Whether the recovery runner is active is unknown.", "attention", options));
71
+ else if (runner !== undefined) output.push(...lines(`Recovery runner ${runner} is active.`, "muted", options));
72
+ else if (op.phase === "running") output.push(...lines("No active runner was observed; review the coordinator's current actions.", "attention", options));
73
+ for (const progress of op.projects) {
74
+ output.push(...lines(`${progress.id}: ${projectPhases[progress.phase]}`, progress.phase === "verified" ? "success" : "info", options));
75
+ for (const [key, value] of Object.entries(progress.terminals)) {
76
+ if (key.startsWith("restored:")) {
77
+ let outcome = "recorded outcome unavailable";
78
+ try {
79
+ const state = restoreReceiptStatus(value);
80
+ outcome = state === "done" ? "session restored" : state === "failed" ? "session restoration needs attention" : state.startsWith("waiting:") ? "waiting for the recorded session to attach" : "session restoration is pending";
81
+ } catch { /* Malformed historical metadata is unknown, never successful. */ }
82
+ output.push(...lines(` ${key.slice("restored:".length)}: ${outcome}`, "muted", options));
83
+ } else if (key.startsWith("closed:") && value === true) output.push(...lines(` ${key.slice("closed:".length)}: previous terminal closed`, "muted", options));
84
+ }
85
+ }
86
+ if (options.verbose) output.push(...lines("Receipt details:", "muted", options), ...operationLines(op, runner, options.now()).flatMap(line => lines(line, "muted", options)));
87
+ return output;
88
+ }
89
+
90
+ const preservation = { queues: "message queues", manualPaused: "manual pauses", boardDigest: "task board", budgetDigest: "budget records" } as const;
91
+ const nativeRecovery = {
92
+ terminal_unverified: { happened: "terminal could not be verified", action: "Review the recorded terminal before retrying; keep its recorded conversation." },
93
+ session_changed: { happened: "different conversation was observed", action: "Return to the recorded conversation; do not accept the different one as its replacement." },
94
+ launcher_unverified: { happened: "launcher could not be verified", action: "Review launcher ownership before retrying; unknown ownership does not authorize another launch." },
95
+ target_unavailable: { happened: "target hub is unavailable", action: "Use an offered coordinator action for this target; do not start a replacement daemon by hand." },
96
+ attachment_timeout: { happened: "attachment timed out", action: "Check the recorded launch and its attachment state, then use an offered coordinator action." },
97
+ } as const;
98
+ const launcherEvidence: Readonly<Record<string, string>> = {
99
+ unknown: "launcher ownership is unknown",
100
+ "older-instance": "an older target's launcher may still run",
101
+ replaced: "the launcher identity differs from the saved binding",
102
+ gone: "no current launcher was confirmed",
103
+ "no-record-yet": "no current launch record was available",
104
+ live: "a launcher was live, but its saved binding was not verified",
105
+ };
106
+
107
+ /** Preserve coordinator guidance verbatim except for its exact retained status command in ordinary human output. */
108
+ export function choiceInstruction(op: RecoveryOperation, choice: NextChoice, options: RecoveryPresentationOptions): string {
109
+ return choice.kind === "wait" && !options.verbose
110
+ ? choice.text.split(recoveryCommand(op, "status")).join(`ahub recovery status ${op.id}`) : choice.text;
111
+ }
112
+
113
+ export function renderBlocked(op: RecoveryOperation, choices: readonly NextChoice[], options: RecoveryPresentationOptions): string[] {
114
+ let happened = "The recovery coordinator blocked this operation.";
115
+ let why = "The detailed cause is unknown in this receipt; no missing evidence is treated as success.";
116
+ let action = "read [j] or use --verbose for the recorded error, then choose an action offered by the coordinator.";
117
+ let guidance: RecordedPeerGuidance | undefined;
118
+ const cause = op.presentationCause;
119
+ if (cause?.kind === "preservation" && Object.hasOwn(preservation, cause.check)) {
120
+ happened = `The ${preservation[cause.check]} preservation check did not pass.`;
121
+ why = `${cause.projectId ? `${cause.projectId}: ` : ""}the recorded expected state and the target's readback did not match, or the comparison was unavailable.`;
122
+ } else if (cause?.kind === "native_recovery") {
123
+ if (Object.hasOwn(nativeRecovery, cause.reason)) {
124
+ const description = nativeRecovery[cause.reason];
125
+ happened = `${cause.peer}: ${description.happened}.`;
126
+ action = `${description.action} Read [j] or use --verbose for the recorded error.`;
127
+ const terminal = cause.handle ? ` Recorded terminal: ${cause.handle}.` : "";
128
+ const session = cause.expectedSession ? ` Expected conversation: ${cause.expectedSession}.` : "";
129
+ switch (cause.reason) {
130
+ case "terminal_unverified":
131
+ why = `The coordinator could not confirm that the recorded terminal was valid and ready.${terminal}${session}`;
132
+ break;
133
+ case "session_changed":
134
+ if (!cause.expectedSession || !cause.observedSession || cause.expectedSession === cause.observedSession) {
135
+ happened = `${cause.peer}: the recorded session identity could not be verified.`;
136
+ action = "Review the recorded conversation and session-file identity before retrying; do not substitute a new conversation. Read [j] or use --verbose for the recorded error.";
137
+ }
138
+ why = `Expected conversation: ${cause.expectedSession ?? "unknown"}; observed conversation: ${cause.observedSession ?? "unknown"}.${terminal}`;
139
+ break;
140
+ case "launcher_unverified":
141
+ why = `${cause.launcherState && Object.hasOwn(launcherEvidence, cause.launcherState) ? launcherEvidence[cause.launcherState] : "launcher evidence is unknown"}.${terminal}${session}`;
142
+ break;
143
+ case "target_unavailable":
144
+ if (cause.targetState === "running") happened = `${cause.peer}: the recorded target identity could not be verified.`;
145
+ why = `The recorded target state was ${cause.targetState ?? "unknown"}; this is the coordinator's observation, not a new live check.${terminal}`;
146
+ break;
147
+ case "attachment_timeout":
148
+ why = `The recorded attachment wait ended${typeof cause.timeoutMs === "number" && Number.isFinite(cause.timeoutMs) && cause.timeoutMs > 0 ? ` after ${elapsed(cause.timeoutMs)}` : "; its exact duration is unknown"}.${terminal}${session}`;
149
+ break;
150
+ }
151
+ const planned = op.plan.projects.find(project => project.project.id === cause.projectId);
152
+ const progress = op.projects.find(project => project.id === cause.projectId);
153
+ if (planned && progress) guidance = recordedPeerGuidance(planned, progress, {
154
+ recorded: Object.fromEntries(Object.entries(progress.reattachments ?? {}).flatMap(([peer, marker]) => marker ? [[peer, marker.binding]] : [])),
155
+ }).find(row => row.peer === cause.peer);
156
+ }
157
+ } else if (op.blockedReattachment) {
158
+ const { projectId, peer } = op.blockedReattachment;
159
+ const marker = op.projects.find(project => project.id === projectId)?.reattachments?.[peer];
160
+ happened = `${peer}'s recorded session needs reattachment.`;
161
+ why = marker ? `${projectId}: the session detached after restoration; its recorded terminal is ${marker.binding.handle} and its accepted conversation is ${marker.binding.sessionId}.` : "The terminal and session evidence is unknown in this receipt.";
162
+ } else if (op.failure?.code === "database_busy") {
163
+ happened = "The recovery database could not complete this step yet.";
164
+ why = "The coordinator recorded database contention as retryable; its saved recovery state is retained.";
165
+ } else if (!cause && op.error) {
166
+ // A recorded error is evidence to show, not a typed classification. Reuse the console's
167
+ // terminal sanitizer before the normal renderer; discard later lines and bound by code point.
168
+ const first = terminalText(op.error.split(/[\r\n\p{Zl}\p{Zp}]/u, 1)[0] ?? "").replace(/\p{Cc}/gu, "").trim();
169
+ if (first) {
170
+ const points = Array.from(first);
171
+ const recorded = points.length > 512 ? `${points.slice(0, 509).join("")}...` : first;
172
+ why = `recorded error: ${recorded} (typed cause unknown).`;
173
+ action = "Resolve the recorded error, then resume or choose an offered action. If the target lacks a feature the source needs, choose a compatible target; keep the source records. Read [j] or use --verbose for details.";
174
+ }
175
+ }
176
+ const output = [...lines(`What happened: ${happened}`, "failure", options), ...lines(`Why: ${why}`, "muted", options), ...lines(`What to do: ${action}`, "attention", options)];
177
+ for (const choice of choices) {
178
+ const instruction = choiceInstruction(op, choice, options);
179
+ output.push(...lines(` ${instruction}`, choice.kind === "stop" || choice.kind === "fresh" ? "failure" : "info", options));
180
+ }
181
+ if (guidance) {
182
+ if (guidance.command) output.push(...lines(`Recorded conversation command, for use when permitted by the recovery hold: ${guidance.command}`, "info", options));
183
+ output.push(...lines(guidance.note, "muted", options));
184
+ }
185
+ if (options.verbose && op.error) output.push(...lines(`Recorded error: ${op.error}`, "muted", options));
186
+ return output;
187
+ }
188
+
189
+ export function renderMenu(items: readonly MenuPresentationItem[], options: RecoveryPresentationOptions): string[] {
190
+ return items.flatMap(item => lines(`[${item.key}] ${item.label}${item.disabledReason ? ` (disabled: ${item.disabledReason})` : ""}${item.recommended ? " (recommended; Enter)" : ""}${item.destructive ? " [warning]" : ""}`,
191
+ item.destructive ? "failure" : item.disabledReason ? "muted" : item.recommended ? "strong" : "info", options));
192
+ }
193
+
194
+ export function renderWait(op: RecoveryOperation, step: string, waiting: readonly string[], options: RecoveryPresentationOptions): string[] {
195
+ const timing = op.waits?.[step];
196
+ const start = timing ? Date.parse(timing.waitStartedAt) : NaN, deadline = timing ? Date.parse(timing.deadlineAt) : NaN;
197
+ const end = timing?.waitEndedAt === undefined ? options.now() : Date.parse(timing.waitEndedAt);
198
+ const known = Number.isFinite(start) && Number.isFinite(deadline) && Number.isFinite(end) && deadline >= start && end >= start;
199
+ const clock = known ? `waiting ${elapsed(end - start)} of ${elapsed(deadline - start)}` : "timing unknown";
200
+ // These are coordinator step categories, never classifications inferred from a reason or error string.
201
+ const category = step.split(":")[0];
202
+ const action = category === "restore" || category === "restore-claude"
203
+ ? "Check the recorded native launch and session attachment; keep the recorded conversation and use an offered coordinator action if attachment does not complete."
204
+ : category === "prepare" || category === "reprepare"
205
+ ? "If an approval is pending, answer it in ahub console. Active turns must finish or be cancelled in their own terminal."
206
+ : "Review the recorded waiting condition and use an offered coordinator action; the action needed for this step is unknown.";
207
+ return [
208
+ ...lines(`[waiting] ${stepLabel(step)} (${clock})`, "attention", options),
209
+ ...(waiting.length ? waiting.flatMap(reason => lines(` Waiting for: ${reason}`, "attention", options)) : lines("The hub has not provided a waiting reason.", "muted", options)),
210
+ ...lines(action, "info", options),
211
+ ];
212
+ }
213
+
214
+ export function renderSummary(op: RecoveryOperation, live: Readonly<Record<string, Inspection | undefined>> | undefined, options: RecoveryPresentationOptions): string[] {
215
+ const outcome = op.disposition ? "abandoned, not completed" : op.phase === "completed" ? "completed" : op.phase === "blocked" ? "blocked" : op.phase === "cancelled" ? "cancelled" : "not completed";
216
+ const output = lines(`${op.plan.kind} ${outcome}`, op.phase === "completed" ? "success" : op.phase === "blocked" ? "failure" : "attention", options);
217
+ for (const progress of op.projects) {
218
+ const observed = live?.[progress.id];
219
+ if (!observed || !["running", "stopped", "missing"].includes(observed.state)) output.push(...lines(`${progress.id}: current runtime unknown; no confirmed current hub reading is available.`, "attention", options));
220
+ else if (observed.state === "running") output.push(...lines(`${progress.id}: running on ${observed.version ?? "an unknown version"} (fresh readback).`, "info", options));
221
+ else output.push(...lines(`${progress.id}: ${observed.state === "stopped" ? "hub stopped" : "project directory missing; runtime state unknown"} (fresh readback).`, "info", options));
222
+ }
223
+ const recorded = stopAndArchiveSummary(op);
224
+ if (!op.disposition) recorded.shift();
225
+ output.push(...recorded.flatMap(line => lines(line, "muted", options)));
226
+ return output;
227
+ }
@@ -10,10 +10,12 @@ export interface RestoreEvidence {
10
10
  launcher: "live" | "gone" | "unknown" | "no-record-yet" | "replaced" | "older-instance";
11
11
  session: "planned" | "other" | "none";
12
12
  allowOther: boolean;
13
+ /** Positive classification of the saved terminal, independent of launcher/session evidence. */
14
+ terminal?: "present" | "gone" | "changed" | "unknown";
13
15
  /** The original or verified selected terminal when a bare receipt first acquires a binding. */
14
16
  binding?: TerminalBinding;
15
17
  }
16
- export type RestoreAction = "poll" | "settle" | "revalidate" | "block" | "fail" | "create";
18
+ export type RestoreAction = "poll" | "settle" | "revalidate" | "block" | "fail" | "create" | "reattach";
17
19
  export interface RestoreTransition { next: RestoreReceipt; action: RestoreAction }
18
20
  export type RestoreReceiptStatus = "unstarted" | "pending" | "waiting:pending" | "waiting:failed" | "done" | "failed";
19
21
 
@@ -75,7 +77,8 @@ export function restoreTransition(receipt: RestoreReceipt, evidence: RestoreEvid
75
77
  const binding = receipt.state === "waiting" ? receipt.binding : evidence.binding;
76
78
  return binding ? { next: { state: "done", binding }, action: "settle" } : keep("block");
77
79
  }
78
- if (receipt.state === "done") return keep("block");
80
+ if (receipt.state === "done") return evidence.terminal === "gone" && evidence.launcher === "gone"
81
+ ? { next: { state: "failed" }, action: "reattach" } : keep("block");
79
82
  if (evidence.launcher === "unknown" || evidence.launcher === "replaced" || evidence.launcher === "older-instance") return keep("block");
80
83
  if (receipt.state === "waiting" && (evidence.launcher === "live" || evidence.launcher === "no-record-yet")) return keep("poll");
81
84
  const origin = receipt.state === "waiting" ? receipt.origin : receipt.state;
@@ -124,6 +127,7 @@ export function renderRestoreTransitionTable(): string {
124
127
  `| Target stopped or changed | ${priority(["stopped", "changed"], allSessions)} |`,
125
128
  `| Running target, other session not allowed | ${priority(["running"], [{ session: "other", allowOther: false }])} |`,
126
129
  "| Absent receipt | unstarted; initial launch is handled by the runtime outside this receipt table |",
130
+ "| Done, running target, no attached session, gone launchers and positively gone saved terminal (#463 supplemental evidence) | reattach -> failed; one recorded-session repair, no fresh-session choice |",
127
131
  "",
128
132
  table("Running target, no attached session (either allowOther value):", [{ session: "none", allowOther: false }, { session: "none", allowOther: true }]),
129
133
  "",