@staix/agent-hub 0.32.0 → 0.34.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 (58) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +2 -2
  3. package/docs/design/bus.md +68 -2
  4. package/docs/dogfood.md +3 -1
  5. package/docs/memory.md +9 -3
  6. package/docs/operations.md +124 -20
  7. package/docs/quickstart.md +32 -2
  8. package/docs/security.md +28 -9
  9. package/docs/specs/2026-09-19-agent-hub-design.md +36 -6
  10. package/docs/specs/2026-09-20-upgrade-recovery-design.md +7 -3
  11. package/docs/verification/2026-11-06-0.33.0.md +222 -0
  12. package/docs/verification/2026-11-06-claude-implementer-2.1.319.md +57 -0
  13. package/docs/verification/2026-11-06-codex-0167-reviewer-isolation.md +56 -0
  14. package/docs/verification/2026-11-06-codex-0167-reviewer-tool-call.md +66 -0
  15. package/docs/verification/2026-11-06-kimi-251-reviewer-compat.md +88 -0
  16. package/docs/verification/2026-11-06-orca-1.4.250-cli-contract.md +50 -0
  17. package/docs/verification/2026-11-06-qwen-0268-acp.md +75 -0
  18. package/docs/verification/2026-11-06-qwen-0268-dual-output.md +44 -0
  19. package/docs/verification/2026-11-07-0.34.0.md +71 -0
  20. package/docs/verification/2026-11-07-compat-receipt-audit-1248.md +256 -0
  21. package/docs/verification/claude-shell-wrapper-native.py +1 -1
  22. package/docs/verified.json +11 -11
  23. package/package.json +2 -2
  24. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  25. package/plugins/agent-hub/server.js +528 -206
  26. package/src/adapters/compat.ts +11 -11
  27. package/src/adapters/definitions/native-cli-claude-setup.ts +14 -5
  28. package/src/broker/protocol.ts +5 -0
  29. package/src/broker/server.ts +54 -4
  30. package/src/broker/store.ts +6 -3
  31. package/src/cli/autonomy-homes.ts +12 -3
  32. package/src/cli/claude-homes.ts +5 -5
  33. package/src/cli/claude-profile-hook.ts +38 -9
  34. package/src/cli/install-paths.ts +71 -0
  35. package/src/cli/main.ts +20 -1
  36. package/src/cli/output.ts +5 -1
  37. package/src/cli/room.ts +17 -5
  38. package/src/cli/runtime-version-warning.ts +8 -6
  39. package/src/cli/setup-homes.ts +13 -10
  40. package/src/cli/status-lines.ts +8 -1
  41. package/src/cli/terminal-recovery.ts +96 -18
  42. package/src/cli/upgrade-interactive.ts +29 -6
  43. package/src/cli/upgrade-output.ts +3 -2
  44. package/src/cli/upgrade-runtime.ts +34 -4
  45. package/src/cli/upgrade.ts +3 -1
  46. package/src/hub/bus.ts +7 -0
  47. package/src/hub/daemon.ts +13 -4
  48. package/src/hub/handoff.ts +1 -1
  49. package/src/rooms/application.ts +15 -0
  50. package/src/rooms/commands.ts +76 -16
  51. package/src/rooms/health.ts +32 -0
  52. package/src/rooms/link.ts +267 -43
  53. package/src/rooms/remote-peer.ts +18 -3
  54. package/src/rooms/service.ts +27 -12
  55. package/src/rooms/status.ts +57 -1
  56. package/src/rooms/store.ts +20 -1
  57. package/src/rooms/wire.ts +10 -8
  58. package/src/ui/index.html +1 -1
@@ -34,21 +34,21 @@ export const COMPAT = {
34
34
  parse: out => plain(out.trim()),
35
35
  trustNote: "The model-free extension load, RPC session identity and managed tool receipt path were checked on 1.4.0 and 1.5.0. Permissions remain host-owned; account-backed inference and another version's extension contract are unverified.",
36
36
  receipt: "docs/verification/2026-11-03-pi-150-compat.md" },
37
- // Transport receipts: docs/verification/2026-10-23-headless-claude-permissions.md; 2.1.310: docs/verification/2026-10-26-claude-implementer-2.1.310.md; 2.1.314 (transport and wrapper): docs/verification/2026-10-31-claude-implementer-2.1.314.md; 2.1.316: docs/verification/2026-10-31-claude-implementer-2.1.316.md; 2.1.318: docs/verification/2026-11-05-claude-implementer-2.1.318.md; host-wrapper receipt below repeats with them.
38
- "claude-implementer": { label: "Claude headless implementer", verified: ["2.1.309", "2.1.310", "2.1.314", "2.1.316", "2.1.318"], gate: "contract", probe: ["claude", "--version"],
37
+ // Transport receipts: docs/verification/2026-10-23-headless-claude-permissions.md; 2.1.310: docs/verification/2026-10-26-claude-implementer-2.1.310.md; 2.1.314 (transport and wrapper): docs/verification/2026-10-31-claude-implementer-2.1.314.md; 2.1.316: docs/verification/2026-10-31-claude-implementer-2.1.316.md; 2.1.318: docs/verification/2026-11-05-claude-implementer-2.1.318.md; 2.1.319: docs/verification/2026-11-06-claude-implementer-2.1.319.md; host-wrapper receipt below repeats with them.
38
+ "claude-implementer": { label: "Claude headless implementer", verified: ["2.1.309", "2.1.310", "2.1.314", "2.1.316", "2.1.318", "2.1.319"], gate: "contract", probe: ["claude", "--version"],
39
39
  parse: out => plain(out.trim().replace(/ \(Claude Code\)$/, "")),
40
40
  trustNote: "The native prompt-tool transport and host-wrapper rewrite, environment isolation and replay contract have isolated receipts. No dontAsk/bypass fallback exists; another version's permission or rewrite behavior is unverified.",
41
- receipt: "docs/verification/2026-11-05-claude-implementer-2.1.318.md" },
42
- // 0.163.0 receipt: docs/verification/2026-10-22-codex-reviewer-isolation.md; 0.164.0 receipt: docs/verification/2026-10-24-codex-0164-reviewer-isolation.md; 0.166.0: docs/verification/2026-10-31-codex-0166-reviewer-isolation.md
41
+ receipt: "docs/verification/2026-11-06-claude-implementer-2.1.319.md" },
42
+ // 0.163.0 receipt: docs/verification/2026-10-22-codex-reviewer-isolation.md; 0.164.0 receipt: docs/verification/2026-10-24-codex-0164-reviewer-isolation.md; 0.166.0: docs/verification/2026-10-31-codex-0166-reviewer-isolation.md; 0.167.0 model-free only, not verified until its model-backed run: docs/verification/2026-11-06-codex-0167-reviewer-isolation.md
43
43
  "codex-review": { label: "Codex reviewer (codex-cli)", verified: ["0.146.0", "0.163.0", "0.164.0", "0.166.0"], gate: "isolation", probe: ["codex", "--version"], config: "codex_bin",
44
44
  parse: out => plain(out.trim().replace(/^codex-cli /, "")),
45
45
  trustNote: "Every launch still proves the MCP inventory, Apps/plugins/hooks disabled, sandbox read/write denial and the binary fence; a failed leg still refuses. Trust covers only the source-level claims those probes cannot see (docs/verification/2026-10-19-reviewer-isolation.md).",
46
- receipt: "docs/verification/2026-10-31-codex-0166-reviewer-isolation.md" },
47
- // Kimi 2.3.0 / Qwen 0.26.0 receipt: docs/verification/2026-10-22-kimi-qwen-compat.md; Kimi 2.3.1: docs/verification/2026-10-25-kimi-231-reviewer-compat.md; Kimi 2.3.2: docs/verification/2026-10-26-kimi-232-reviewer-compat.md; Kimi 2.4.1: docs/verification/2026-10-31-kimi-241-reviewer-compat.md
48
- "kimi-review": { label: "Kimi reviewer (kimi-code)", verified: ["2.2.0", "2.3.0", "2.3.1", "2.3.2", "2.4.1"], gate: "isolation", probe: ["kimi", "--version"],
46
+ receipt: "docs/verification/2026-11-06-codex-0167-reviewer-isolation.md" },
47
+ // Kimi 2.3.0 / Qwen 0.26.0 receipt: docs/verification/2026-10-22-kimi-qwen-compat.md; Kimi 2.3.1: docs/verification/2026-10-25-kimi-231-reviewer-compat.md; Kimi 2.3.2: docs/verification/2026-10-26-kimi-232-reviewer-compat.md; Kimi 2.4.1: docs/verification/2026-10-31-kimi-241-reviewer-compat.md; Kimi 2.5.1: docs/verification/2026-11-06-kimi-251-reviewer-compat.md
48
+ "kimi-review": { label: "Kimi reviewer (kimi-code)", verified: ["2.2.0", "2.3.0", "2.3.1", "2.3.2", "2.4.1", "2.5.1"], gate: "isolation", probe: ["kimi", "--version"],
49
49
  parse: out => plain(out.trim()),
50
50
  trustNote: "Every launch still builds the private config, exact two-tool agent file and task-bound MCP; the native tool allowlist behaviour of an untested version is not re-proven.",
51
- receipt: "docs/verification/2026-10-31-kimi-241-reviewer-compat.md" },
51
+ receipt: "docs/verification/2026-11-06-kimi-251-reviewer-compat.md" },
52
52
  jcode: { label: "Jcode", verified: ["0.98.1", "0.99.0", "0.99.1"], line: { min: "0.98.0", below: "1.0.0" }, gate: "contract", probe: ["jcode", "--quiet", "version", "--json"], config: "jcode_cmd",
53
53
  parse: out => { try { const v = JSON.parse(out).semver; return typeof v === "string" ? plain(v) : undefined; } catch { return undefined; } },
54
54
  trustNote: "The ACP, serve and MCP contract was verified on 0.98.1 and 0.99.1 and steering on 0.99.0 and 0.99.1; an untested version may change them.",
@@ -57,10 +57,10 @@ export const COMPAT = {
57
57
  parse: out => plain(out.trim()),
58
58
  trustNote: "Only 1.4.2, 1.4.7 and 1.4.12 are T0-verified; the plugin MCP and hook contract of another version is unverified.",
59
59
  receipt: "docs/verification/2026-11-03-agy-1.4.12-launcher-nonce.md" },
60
- qwen: { label: "Qwen Code", verified: ["0.25.0", "0.26.0", "0.26.1", "0.26.4", "0.26.5"], gate: "contract", probe: ["qwen", "--version"], config: "qwen_cmd",
60
+ qwen: { label: "Qwen Code", verified: ["0.25.0", "0.26.0", "0.26.1", "0.26.4", "0.26.5", "0.26.8"], gate: "contract", probe: ["qwen", "--version"], config: "qwen_cmd",
61
61
  parse: out => plain(out.trim()),
62
- trustNote: "Native ACP identity, mode ids and permission callbacks were re-checked without inference on 0.26.4 and 0.26.5; real model turns remain unverified. Another version may change those contracts.",
63
- receipt: "docs/verification/2026-11-03-qwen-0265-acp.md" },
62
+ trustNote: "Native ACP identity, mode ids and permission callbacks were re-checked without inference on 0.26.4, 0.26.5 and 0.26.8 (macOS arm64 and Linux x86_64); real model turns remain unverified. Another version may change those contracts.",
63
+ receipt: "docs/verification/2026-11-06-qwen-0268-acp.md" },
64
64
  // Receipt: docs/verification/2026-10-25-qwen-dual-output-t0.md. The dual-output contract (input file, event file, can_use_tool control requests) is its own entry:
65
65
  // the ACP receipts of the headless `qwen` entry say nothing about it, so that entry is not widened and this one carries no `line`.
66
66
  "qwen-dual": { label: "Qwen Code dual-output TUI", verified: ["0.26.1"], gate: "contract", probe: ["qwen", "--version"], config: "qwen_cmd",
@@ -1,5 +1,7 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
+ import { homedir } from "node:os";
4
+ import { stablePackageRoot } from "../../cli/install-paths.ts";
3
5
  import { realPath } from "../../hub/project.ts";
4
6
  import { VERSION } from "../../version.ts";
5
7
 
@@ -53,11 +55,11 @@ export type PluginState =
53
55
  | { state: "stale"; version: string; why: string; notices?: string[] };
54
56
 
55
57
  const safeDisplayValue = (value: unknown): string | undefined =>
56
- typeof value === "string" && value.length <= 4096 && !/[\x00-\x1f\x7f]/.test(value) ? value : undefined;
58
+ typeof value === "string" && value.length <= 4096 && value.isWellFormed() && !/[\p{Cc}\p{Cf}]/u.test(value) ? value : undefined;
57
59
 
58
60
  function nonUserPluginNotices(plugins: InstalledPlugin[]): string[] {
59
61
  return plugins.filter(plugin => plugin.id === PLUGIN && plugin.scope !== "user").map(plugin => {
60
- const scope = plugin.scope === "project" || plugin.scope === "local" ? plugin.scope : "unknown-scope";
62
+ const scope = safeDisplayValue(plugin.scope) ?? "unknown-scope";
61
63
  const path = safeDisplayValue(plugin.projectPath) ?? safeDisplayValue(plugin.installPath);
62
64
  const version = safeDisplayValue(plugin.version) ?? "unknown";
63
65
  return `${scope} plugin row preserved${path ? ` at ${path}` : ""} (version ${version}); setup never removes non-user scopes`;
@@ -90,14 +92,21 @@ export interface SetupStep {
90
92
  * The next thing `ahub setup` has to do, or nothing. One step at a time: the state is read again after every step,
91
93
  * because what a step leaves behind is Claude Code's business (removing a marketplace may uninstall its plugins).
92
94
  */
93
- export function nextStep(plugins: InstalledPlugin[], marketplaces: Marketplace[], packageRoot: string): SetupStep | undefined {
95
+ export function nextStep(plugins: InstalledPlugin[], marketplaces: Marketplace[], packageRoot: string, personHome = homedir()): SetupStep | undefined {
94
96
  const root = real(packageRoot);
95
97
  const market = marketplaces.find((m) => m.name === MARKETPLACE);
96
98
  if (market && real(market.path ?? "") !== root) {
97
- if (plugins.some(plugin => plugin.id === PLUGIN && plugin.scope !== "user")) return { why: `the ${MARKETPLACE} marketplace points at another source; removing it may remove non-user plugin rows`, argv: [], blocked: true };
99
+ const blockers = plugins.filter(plugin => plugin.id === PLUGIN && plugin.scope !== "user");
100
+ if (blockers.length) {
101
+ const display = (value: unknown) => JSON.stringify(safeDisplayValue(value) ?? "unknown");
102
+ const rows = blockers.map(plugin => `scope=${display(plugin.scope)}, projectPath=${display(plugin.projectPath)}, version=${display(plugin.version)}, installPath=${display(plugin.installPath)}`).join("; ");
103
+ const homeProject = blockers.some(plugin => safeDisplayValue(plugin.projectPath) !== undefined && real(plugin.projectPath!) === real(personHome));
104
+ const danger = homeProject ? ` Do not run 'claude plugin uninstall --scope project' from $HOME (${display(personHome)}): its project settings file ~/.claude/settings.json (${display(join(personHome, ".claude/settings.json"))}) is also the default home's user settings.` : "";
105
+ return { why: `the ${MARKETPLACE} marketplace points at another source; removing it may remove non-user plugin rows. Blocking rows: ${rows}. Hub-launched Claude sessions load their plugin from ~/.agenthub/releases (${display(join(personHome, ".agenthub/releases"))}), not from these plugin-cache rows. Standalone Claude sessions may still use them.${danger} Next: back up the selected Claude home's plugins/installed_plugins.json, review every listed row, and manually remove only rows confirmed stale and unused, preserving every other row; then rerun ahub setup. Setup never removes these rows automatically`, argv: [], blocked: true };
106
+ }
98
107
  return { why: `the ${MARKETPLACE} marketplace points at ${market.path ?? "another source"}, not at this package`, argv: ["claude", "plugin", "marketplace", "remove", MARKETPLACE] };
99
108
  }
100
- if (!market) return { why: "register this package as a Claude Code plugin marketplace", argv: ["claude", "plugin", "marketplace", "add", root] };
109
+ if (!market) return { why: "register this package as a Claude Code plugin marketplace", argv: ["claude", "plugin", "marketplace", "add", stablePackageRoot(root)] };
101
110
  const state = pluginState(plugins, root);
102
111
  if (state.state === "missing") return { why: "install the user-scope channel plugin", argv: ["claude", "plugin", "install", PLUGIN, "--scope", "user"] };
103
112
  // Uninstall rather than update: with an unchanged version number an update has nothing to do.
@@ -8,6 +8,8 @@ import {
8
8
  type OwnerPin, type VerifiedPublicRoster,
9
9
  } from "../rooms/wire.ts";
10
10
 
11
+ /** Optional codec header token; the existing ephemeral opt-in remains byte-for-byte unchanged. */
12
+ export const BROKER_CODEC_FEATURES_OPT_IN = "1";
11
13
  export const BROKER_CONTROL_LIMITS = Object.freeze({ control: 1_048_576, challenge: 8192, nonceBytes: 32 });
12
14
  export type BrokerProtocolCode = "invalid_wire" | "limit" | "untrusted" | "expired" | "authentication_failed";
13
15
  export class BrokerProtocolError extends Error {
@@ -35,6 +37,9 @@ export interface BrokerAuth {
35
37
  roster: string | null;
36
38
  signature: string;
37
39
  }
40
+ /** Broker-to-client transport evidence; never accepted as client control or authority. */
41
+ export interface BrokerCodecPeer { member: string; session: string | null; supported: boolean }
42
+ export interface BrokerCodecPeers { v: 1; type: "codec_peers"; digest: string; peers: BrokerCodecPeer[] }
38
43
  export interface BrokerRosterReady { v: 1; type: "roster_ready"; digest: string }
39
44
  export interface BrokerReceived { v: 1; type: "received"; digest: string }
40
45
  export type BrokerControl = BrokerAuth | BrokerRosterReady | BrokerReceived;
@@ -2,7 +2,7 @@
2
2
  import type { ServerWebSocket } from "bun";
3
3
  import { assertRoomCryptoReady, decodeBase64url, encodeBase64url, randomBytes } from "../rooms/crypto.ts";
4
4
  import { brokerUrl, canonicalBytes, decodeFrame, decodeRoster, publicRosterWire, verifyPublicFrame, verifyPublicRoster, type VerifiedPublicRoster } from "../rooms/wire.ts";
5
- import { BROKER_CONTROL_LIMITS, decodeBrokerControl, encodeBrokerChallenge, verifyBrokerAuth, type BrokerChallenge } from "./protocol.ts";
5
+ import { BROKER_CODEC_FEATURES_OPT_IN, BROKER_CONTROL_LIMITS, decodeBrokerControl, encodeBrokerChallenge, verifyBrokerAuth, type BrokerChallenge } from "./protocol.ts";
6
6
  import { BrokerStore, BrokerStoreError, type BrokerLimits } from "./store.ts";
7
7
 
8
8
  export const BROKER_SOCKET_LIMITS = Object.freeze({ connections: 128, verifications: 16, authMs: 10_000, rosterAckMs: 30_000, backpressureBytes: 1_048_576, queuedMessages: 8, queuedBytes: 2_097_152, globalQueuedBytes: 33_554_432 });
@@ -26,6 +26,10 @@ interface Client {
26
26
  ws?: ServerWebSocket<Client>;
27
27
  room: string;
28
28
  ephemeral: boolean;
29
+ codecFeatures: boolean;
30
+ codecSession: string;
31
+ pendingCodecMembers: Set<string>;
32
+ codecFlushQueued: boolean;
29
33
  challenge: BrokerChallenge;
30
34
  phase: "challenge" | "authenticating" | "roster" | "ready" | "closed";
31
35
  busy: boolean;
@@ -108,11 +112,13 @@ export async function startBroker(options: BrokerOptions, ports: BrokerPorts = {
108
112
  function close(client: Client, code: string): void {
109
113
  if (client.phase === "closed") return;
110
114
  client.phase = "closed";
115
+ client.pendingCodecMembers.clear(); client.codecFlushQueued = false;
111
116
  client.inFlight = undefined; client.inFlightEphemeral = undefined; client.inFlightExpiresAt = undefined; client.inFlightEpoch = undefined;
112
117
  queuedBytes -= client.inboxBytes; client.inboxBytes = 0; client.inbox.length = 0;
113
118
  if (client.member && members.get(memberKey(client)) === client) {
114
119
  members.delete(memberKey(client));
115
120
  try { store.disconnect(client.room, client.member); } catch { emit("refused", client, "state_unavailable"); }
121
+ notifyCodecPeers(client.room, client.member);
116
122
  }
117
123
  if (client.ws) { try { client.ws.close(4403, code); } catch { /* already closed */ } }
118
124
  else clients.delete(client);
@@ -137,15 +143,55 @@ export async function startBroker(options: BrokerOptions, ports: BrokerPorts = {
137
143
  if (!roster || !member || identityKey(member) !== client.key) throw new BrokerServerError("untrusted");
138
144
  return roster;
139
145
  }
146
+ /** Connection evidence only: the sender must still echo its session in signed presence. */
147
+ function sendCodecPeers(recipient: Client, memberIds: readonly string[]): void {
148
+ if (!recipient.codecFeatures || recipient.phase !== "ready") return;
149
+ try {
150
+ const roster = active(recipient);
151
+ const peers = memberIds.filter(member => member !== recipient.member && roster.members.some(row => row.fingerprint === member)).map(member => {
152
+ const source = members.get(`${recipient.room}|${member}`);
153
+ const current = source?.phase === "ready" && active(source).digest === roster.digest ? source : undefined;
154
+ return { member, session: current?.codecSession ?? null, supported: current?.codecFeatures === true };
155
+ });
156
+ if (peers.length) control(recipient, { v: 1, type: "codec_peers", digest: roster.digest, peers });
157
+ } catch { close(recipient, "state_changed"); }
158
+ }
159
+ function flushCodecPeers(client: Client): void {
160
+ const pending = [...client.pendingCodecMembers]; client.pendingCodecMembers.clear();
161
+ if (pending.length) sendCodecPeers(client, pending);
162
+ }
163
+ function notifyCodecPeers(room: string, member: string): void {
164
+ for (const recipient of clients) if (recipient.room === room && recipient.codecFeatures && recipient.phase === "ready") {
165
+ try {
166
+ const current = new Set(active(recipient).members.map(row => row.fingerprint));
167
+ // At most the signed roster's members can wait; churn replaces evidence
168
+ // rather than growing a control queue beyond the existing packet ceiling.
169
+ for (const pending of recipient.pendingCodecMembers) if (!current.has(pending)) recipient.pendingCodecMembers.delete(pending);
170
+ if (member === recipient.member || !current.has(member)) continue;
171
+ recipient.pendingCodecMembers.add(member);
172
+ if (!recipient.codecFlushQueued) {
173
+ recipient.codecFlushQueued = true;
174
+ queueMicrotask(() => { recipient.codecFlushQueued = false; flushCodecPeers(recipient); });
175
+ }
176
+ } catch { close(recipient, "state_changed"); }
177
+ }
178
+ }
179
+ function codecInventory(client: Client): void {
180
+ if (!client.codecFeatures || client.phase !== "ready") return;
181
+ client.pendingCodecMembers.clear();
182
+ sendCodecPeers(client, active(client).members.map(member => member.fingerprint));
183
+ }
140
184
  function sendRoster(client: Client): void {
141
185
  const roster = active(client);
142
186
  client.phase = "roster"; client.rosterDigest = roster.digest;
143
187
  client.rosterDeadline = now() + BROKER_SOCKET_LIMITS.rosterAckMs;
144
188
  // Legacy hubs have a closed ready grammar; advertise only after HTTP opt-in.
145
- if (send(client, publicRosterWire(roster))) control(client, { v: 1, type: "ready", room: client.room, member: client.member, epoch: roster.epoch, digest: roster.digest, ...(client.ephemeral ? { ephemeral: true } : {}) });
189
+ if (send(client, publicRosterWire(roster))) control(client, { v: 1, type: "ready", room: client.room, member: client.member, epoch: roster.epoch, digest: roster.digest, ...(client.ephemeral ? { ephemeral: true } : {}), ...(client.codecFeatures ? { codecFeatures: true, codecSession: client.codecSession } : {}) });
146
190
  }
147
191
  function pump(client: Client): void {
148
192
  if (stopping || client.phase === "closed" || !client.member) return;
193
+ flushCodecPeers(client); // Source-session invalidation always precedes ciphertext handoff.
194
+ if (members.get(memberKey(client)) !== client) return;
149
195
  const roster = active(client);
150
196
  const next = store.next(client.room, client.member);
151
197
  if (client.inFlightEphemeral && client.inFlightEpoch !== roster.epoch) {
@@ -192,6 +238,7 @@ export async function startBroker(options: BrokerOptions, ports: BrokerPorts = {
192
238
  if (previous && previous !== client) close(previous, "replaced");
193
239
  members.set(memberKey(client), client);
194
240
  client.phase = "roster";
241
+ notifyCodecPeers(client.room, client.member);
195
242
  sendRoster(client);
196
243
  emit("connected", client);
197
244
  }
@@ -218,7 +265,7 @@ export async function startBroker(options: BrokerOptions, ports: BrokerPorts = {
218
265
  const proof = await (ports.verifyFrame ?? verifyPublicFrame)({ wire: bytes, roster: selected });
219
266
  const onlineRecipients = proof.recipients.filter(recipient => {
220
267
  const target = members.get(`${client.room}|${recipient}`);
221
- return target?.phase === "ready" && (!proof.ephemeral || target.ephemeral);
268
+ return target?.phase === "ready" && (!proof.ephemeral || target.ephemeral) && (!proof.codecFeatures || target.codecFeatures);
222
269
  });
223
270
  try {
224
271
  active(client);
@@ -241,6 +288,8 @@ export async function startBroker(options: BrokerOptions, ports: BrokerPorts = {
241
288
  const latest = active(client);
242
289
  if (packet.digest !== client.rosterDigest || packet.digest !== latest.digest) { sendRoster(client); return; }
243
290
  client.phase = "ready"; client.rosterDeadline = undefined;
291
+ codecInventory(client);
292
+ if (client.member) notifyCodecPeers(client.room, client.member);
244
293
  safePump(client); return;
245
294
  }
246
295
  if (packet.digest !== client.inFlight) { refused(client, "unexpected_ack", false); return; }
@@ -290,7 +339,8 @@ export async function startBroker(options: BrokerOptions, ports: BrokerPorts = {
290
339
  const room = roomId(url.pathname.slice("/v1/rooms/".length));
291
340
  if (clients.size >= BROKER_SOCKET_LIMITS.connections) return new Response("busy", { status: 503 });
292
341
  const current = store.current(room)?.current;
293
- const client: Client = { room, ephemeral: req.headers.get("x-agent-hub-ephemeral") === "1", challenge: { v: 1, type: "challenge", room, broker: publicUrl, nonce: encodeBase64url(randomBytes(32)), expiresAt: now() + BROKER_SOCKET_LIMITS.authMs, rosterDigest: current?.digest ?? null }, phase: "challenge", busy: false, inbox: [], inboxBytes: 0 };
342
+ const transportOptIn = req.headers.get("x-agent-hub-ephemeral");
343
+ const client: Client = { room, ephemeral: transportOptIn === "1", codecFeatures: transportOptIn === "1" && req.headers.get("x-agent-hub-codecs") === BROKER_CODEC_FEATURES_OPT_IN, codecSession: encodeBase64url(randomBytes(32)), pendingCodecMembers: new Set(), codecFlushQueued: false, challenge: { v: 1, type: "challenge", room, broker: publicUrl, nonce: encodeBase64url(randomBytes(32)), expiresAt: now() + BROKER_SOCKET_LIMITS.authMs, rosterDigest: current?.digest ?? null }, phase: "challenge", busy: false, inbox: [], inboxBytes: 0 };
294
344
  clients.add(client);
295
345
  if (host.upgrade(req, { data: client })) return;
296
346
  clients.delete(client); return new Response("websocket required", { status: 400 });
@@ -408,7 +408,9 @@ export class BrokerStore {
408
408
  // ponytail: while A awaits C, A's broadcast reply to B may replace C's request;
409
409
  // C waits for A's next state change/reconnect. Task #736 idle refresh or
410
410
  // per-(sender,recipient) coalescing closes this latest-per-sender ceiling.
411
- this.removeEphemeral(proof.room, proof.sender);
411
+ // Codec advertisements must not evict a legacy snapshot that a mixed
412
+ // member has not received yet. Both groups share the existing queue caps.
413
+ this.removeEphemeral(proof.room, proof.sender, undefined, proof.codecFeatures === true);
412
414
  const connected = new Set(onlineRecipients);
413
415
  recipients = recipients.filter(recipient => connected.has(recipient));
414
416
  }
@@ -438,12 +440,13 @@ export class BrokerStore {
438
440
  this.transaction(() => { this.removeEphemeral(room, undefined, recipient); });
439
441
  }
440
442
 
441
- private removeEphemeral(room?: string, sender?: string, recipient?: string): void {
443
+ private removeEphemeral(room?: string, sender?: string, recipient?: string, codecFeatures?: boolean): void {
442
444
  const rows = this.db.query<{ rowid: number; wire: Uint8Array; recipients: string }, [string | null, string | null, string | null, string | null]>(
443
445
  "SELECT rowid,wire,recipients FROM queued_frames WHERE (? IS NULL OR room=?) AND (? IS NULL OR sender=?)",
444
446
  ).all(room ?? null, room ?? null, sender ?? null, sender ?? null);
445
447
  for (const row of rows) {
446
- if (!decodeFrame(row.wire).ephemeral) continue;
448
+ const frame = decodeFrame(row.wire);
449
+ if (!frame.ephemeral || codecFeatures !== undefined && (frame.codecFeatures === true) !== codecFeatures) continue;
447
450
  const remaining = recipient === undefined ? [] : parseRecipients(row.recipients).filter(id => id !== recipient);
448
451
  if (remaining.length) this.db.query("UPDATE queued_frames SET recipients=? WHERE rowid=?").run(JSON.stringify(remaining), row.rowid);
449
452
  else this.db.query("DELETE FROM queued_frames WHERE rowid=?").run(row.rowid);
@@ -22,12 +22,21 @@ export async function usedClaudeHomes(registry: Registry, home: string, env: Nod
22
22
  return { ...found, complete: found.complete && !notices.length, notices: [...notices, ...found.notices] };
23
23
  }
24
24
  export async function autonomyHomeStatus(registry: Registry, home: string, env: NodeJS.ProcessEnv, packageRoot: string, probe: ClaudeHomeProbe) {
25
- const found = await usedClaudeHomes(registry, home, env), deadline = performance.now() + 6000;
25
+ const found = await usedClaudeHomes(registry, home, env);
26
+ // Doctor also inspects the existing default home; setup's selected write inventory stays unchanged.
27
+ const defaults = await discoverClaudeHomes({ home, env: { ...env, CLAUDE_CONFIG_DIR: undefined } });
28
+ // Discovery canonicalizes existing homes through realPath, so lexical aliases share one inspection.
29
+ const inspected = new Map(found.homes.map(selected => [selected.path, selected]));
30
+ for (const selected of defaults.homes.filter(selected => selected.exists)) {
31
+ const previous = inspected.get(selected.path);
32
+ inspected.set(selected.path, previous ? { ...previous, sources: [...new Set([...previous.sources, ...selected.sources])] } : selected);
33
+ }
34
+ const notices = [...new Set([...found.notices, ...defaults.notices])], deadline = performance.now() + 6000;
26
35
  const homes = [];
27
- for (const selected of found.homes) {
36
+ for (const selected of inspected.values()) {
28
37
  const remaining = Math.floor(deadline - performance.now());
29
38
  if (remaining < 1) homes.push({ home: selected, plugin: { state: "unknown", reason: "The machine profile inventory time bound was reached." }, hook: checkClaudeProfileHook(selected) });
30
39
  else homes.push(await checkClaudeHome(selected, packageRoot, env, probe, Math.min(remaining, 6000)));
31
40
  }
32
- return { homes, notices: found.notices, complete: found.complete && performance.now() <= deadline };
41
+ return { homes, notices, complete: found.complete && defaults.complete && performance.now() <= deadline };
33
42
  }
@@ -3,7 +3,7 @@ import { isAbsolute, join } from "node:path";
3
3
  import { realPath } from "../hub/project.ts";
4
4
  import { launcherOf, recordPath, MAX_COMMAND_MS, type ProcessIdentity } from "./terminal-recovery.ts";
5
5
  import { CLAUDE_PLUGIN_INVENTORY, PLUGIN, nextStep, pluginState, type InstalledPlugin, type Marketplace, type PluginState, type SetupStep } from "./setup.ts";
6
- import { checkClaudeProfileHook, type ClaudeProfileHookCheck } from "./claude-profile-hook.ts";
6
+ import { checkClaudeProfileHook, type ClaudeProfileHookCheck, type ClaudeProfileHookOptions } from "./claude-profile-hook.ts";
7
7
 
8
8
  export type ClaudeHomeSource = "environment" | "default" | "recorded-launch";
9
9
  export interface ClaudeHome { path: string; exists: boolean; sources: ClaudeHomeSource[] }
@@ -93,12 +93,12 @@ export interface ClaudeHomeCheck {
93
93
  }
94
94
 
95
95
  /** Read-only per-home plugin inspection. Installation never proves that a live permission hook is present. */
96
- export async function checkClaudeHome(home: ClaudeHome, packageRoot: string, env: NodeJS.ProcessEnv, probe: ClaudeHomeProbe, timeoutMs = MAX_COMMAND_MS, probeMissingDefault = false): Promise<ClaudeHomeCheck> {
96
+ export async function checkClaudeHome(home: ClaudeHome, packageRoot: string, env: NodeJS.ProcessEnv, probe: ClaudeHomeProbe, timeoutMs = MAX_COMMAND_MS, probeMissingDefault = false, hookOptions?: ClaudeProfileHookOptions): Promise<ClaudeHomeCheck> {
97
97
  if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > MAX_COMMAND_MS) throw new Error("invalid Claude plugin probe time bound");
98
- const hook = checkClaudeProfileHook(home);
98
+ const hook = checkClaudeProfileHook(home, hookOptions);
99
99
  const unknown = (): ClaudeHomeCheck => ({ home, plugin: { state: "unknown", reason: "Claude plugin inventory is unavailable or invalid for this home." }, hook });
100
100
  if (hook.state === "refused") return unknown();
101
- if (!home.exists && !(probeMissingDefault && home.sources.includes("default"))) return { home, plugin: { state: "missing" }, next: nextStep([], [], packageRoot), hook };
101
+ if (!home.exists && !(probeMissingDefault && home.sources.includes("default"))) return { home, plugin: { state: "missing" }, next: nextStep([], [], packageRoot, validPath(env.HOME) ? env.HOME : undefined), hook };
102
102
  const scoped = claudeHomeEnvironment(home, env), deadline = performance.now() + timeoutMs;
103
103
  const read = async (argv: readonly string[]): Promise<unknown[] | undefined> => {
104
104
  const remaining = Math.floor(deadline - performance.now());
@@ -118,6 +118,6 @@ export async function checkClaudeHome(home: ClaudeHome, packageRoot: string, env
118
118
  (row.version === undefined || typeof row.version === "string" && row.version.length <= 64 && (row.id !== PLUGIN ? !/[\u0000-\u001f\u007f-\u009f]/.test(row.version) : /^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?$/.test(row.version))) &&
119
119
  (row.installPath === undefined || validPath(row.installPath))) ||
120
120
  !marketplaces.every(row => object(row) && typeof row.name === "string" && row.name.length <= 256 && (row.path === undefined || validPath(row.path)))) return unknown();
121
- return { home, plugin: pluginState(plugins as InstalledPlugin[], packageRoot), next: nextStep(plugins as InstalledPlugin[], marketplaces as Marketplace[], packageRoot), hook };
121
+ return { home, plugin: pluginState(plugins as InstalledPlugin[], packageRoot), next: nextStep(plugins as InstalledPlugin[], marketplaces as Marketplace[], packageRoot, validPath(env.HOME) ? env.HOME : undefined), hook };
122
122
  } catch { return unknown(); }
123
123
  }
@@ -1,9 +1,9 @@
1
- import { closeSync, constants, fchmodSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readSync, renameSync, rmSync, writeFileSync, type Stats } from "node:fs";
1
+ import { closeSync, constants, fchmodSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readSync, renameSync, rmSync, statSync, writeFileSync, type Stats } from "node:fs";
2
2
  import { createHash, randomUUID } from "node:crypto";
3
3
  import { dirname, isAbsolute, join } from "node:path";
4
- import { canonicalJson } from "../hub/packs.ts";
5
4
  import { factsHook } from "./facts-hook.ts";
6
5
  import { shellQuote } from "./shell-quote.ts";
6
+ import { stableBunRuntime, stablePackageRoot } from "./install-paths.ts";
7
7
  import type { ClaudeHome } from "./claude-homes.ts";
8
8
 
9
9
  export interface ClaudeProfileHookOptions { script?: string; runtime?: string }
@@ -25,11 +25,29 @@ interface HookPlan {
25
25
  }
26
26
 
27
27
  function hookEntry(options: ClaudeProfileHookOptions): Record<string, unknown> {
28
- const script = options.script ?? join(import.meta.dir, "claude-profile-hook.ts"), runtime = options.runtime ?? process.execPath;
28
+ const script = options.script ?? join(stablePackageRoot(join(import.meta.dir, "../..")), "src/cli/claude-profile-hook.ts"), runtime = options.runtime ?? stableBunRuntime();
29
29
  if (!validPath(script) || !validPath(runtime)) throw new Error("invalid profile hook command");
30
30
  return { matcher: "*", hooks: [{ type: "command", command: `${shellQuote(runtime)} ${shellQuote(script)} --run`, timeout: 5 }] };
31
31
  }
32
32
 
33
+ interface ManagedProfileHook { entry: Record<string, unknown>; hook: Record<string, unknown>; runtime: string; script: string }
34
+ /** Own only the generated command grammar; matcher, timeout and caller metadata remain theirs. */
35
+ function managedProfileHook(candidate: unknown): ManagedProfileHook | undefined {
36
+ if (!object(candidate) || !Array.isArray(candidate.hooks) || candidate.hooks.length !== 1) return;
37
+ const hook = candidate.hooks[0];
38
+ if (!object(hook) || hook.type !== "command" || typeof hook.command !== "string") return;
39
+ const match = /^'((?:[^']|'\\'')*)' '((?:[^']|'\\'')*)' --run$/.exec(hook.command);
40
+ if (!match) return;
41
+ const runtime = match[1]!.replace(/'\\''/g, "'"), script = match[2]!.replace(/'\\''/g, "'");
42
+ if (!validPath(runtime) || !validPath(script) || !script.endsWith("/src/cli/claude-profile-hook.ts")
43
+ || hook.command !== `${shellQuote(runtime)} ${shellQuote(script)} --run`) return;
44
+ return { entry: candidate, hook, runtime, script };
45
+ }
46
+
47
+ function hookPathPresent(file: string): boolean {
48
+ try { return statSync(file).isFile(); } catch { return false; }
49
+ }
50
+
33
51
  function settingsSource(file: string): { value: Record<string, unknown>; source?: HookPlan["source"] } {
34
52
  let fd: number | undefined;
35
53
  try {
@@ -67,13 +85,24 @@ function planClaudeProfileHook(home: ClaudeHome, options: ClaudeProfileHookOptio
67
85
  if (value.hooks !== undefined && !object(value.hooks)) return refuse("Claude's hook configuration is malformed.");
68
86
  const hooks = value.hooks ?? {}, existing = hooks.PreToolUse ?? [];
69
87
  if (!Array.isArray(existing)) return refuse("Claude's PreToolUse hook list is malformed.");
70
- const entry = hookEntry(options), expected = canonicalJson(entry);
71
- const matches = existing.filter(candidate => canonicalJson(candidate) === expected).length;
72
- const next = matches === 1 ? undefined : `${JSON.stringify({ ...value, hooks: { ...hooks, PreToolUse: [...existing.filter(candidate => canonicalJson(candidate) !== expected), entry] } }, null, 2)}\n`;
88
+ const entry = hookEntry(options), expectedCommand = (entry.hooks as { command: string }[])[0]!.command;
89
+ const managed = existing.map(managedProfileHook), first = managed.find(candidate => candidate !== undefined);
90
+ const count = managed.filter(candidate => candidate !== undefined).length;
91
+ const stale = managed.some(candidate => candidate && (!hookPathPresent(candidate.runtime) || !hookPathPresent(candidate.script)));
92
+ const configured = count === 1 && first?.hook.command === expectedCommand && !stale;
93
+ const normalized = existing.flatMap((candidate, index) => {
94
+ const owned = managed[index];
95
+ if (!owned) return [candidate];
96
+ return owned === first ? [{ ...owned.entry, hooks: [{ ...owned.hook, command: expectedCommand }] }] : [];
97
+ });
98
+ if (!first) normalized.push(entry);
99
+ const next = configured ? undefined : `${JSON.stringify({ ...value, hooks: { ...hooks, PreToolUse: normalized } }, null, 2)}\n`;
73
100
  if (next !== undefined && Buffer.byteLength(next, "utf8") > MAX_SETTINGS_BYTES) return refuse("Claude's settings would exceed the bounded profile settings size.");
74
- return { file, home: directory, parent, source, next, check: matches === 1
75
- ? { state: "installed", reason: "The project-neutral profile hook is configured; this does not prove live permission enforcement." }
76
- : { state: "missing", reason: matches ? "Duplicate managed profile hooks need normalization." : "The project-neutral profile hook is not configured." } };
101
+ let reason = "The project-neutral profile hook is not configured for this install; run ahub setup.";
102
+ if (count > 1) reason = "Duplicate managed profile hooks need normalization; run ahub setup.";
103
+ if (stale) reason = "A managed profile hook script or runtime is missing; run ahub setup to refresh the selected home's hook.";
104
+ if (configured) reason = "The project-neutral profile hook is configured; this does not prove live permission enforcement.";
105
+ return { file, home: directory, parent, source, next, check: { state: configured ? "installed" : "missing", reason } };
77
106
  } catch { return refuse("Claude profile settings could not be safely inspected; no settings were changed."); }
78
107
  }
79
108
 
@@ -0,0 +1,71 @@
1
+ import { lstatSync, readlinkSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
+ import { realPath } from "../hub/project.ts";
5
+
6
+ export interface InstallPathOptions {
7
+ env?: NodeJS.ProcessEnv;
8
+ home?: string;
9
+ globalFlag?: "-g" | "--global";
10
+ }
11
+ // Contract: #1245 limits lexical symlink inspection to eight hops; unknown paths use the resolved fallback.
12
+ const MAX_LINK_HOPS = 8;
13
+ const samePath = (left: string, right: string): boolean => {
14
+ try { return realPath(left) === realPath(right); } catch { return false; }
15
+ };
16
+
17
+ /** Follow only the leaf links, preserving lexical parent aliases such as current/. */
18
+ function lexicalTarget(entry: string): string | undefined {
19
+ let link = resolve(entry);
20
+ const seen = new Set<string>();
21
+ try {
22
+ for (let hops = 0; hops <= MAX_LINK_HOPS; hops++) {
23
+ if (seen.has(link)) return;
24
+ seen.add(link);
25
+ if (!lstatSync(link).isSymbolicLink()) return link;
26
+ if (hops === MAX_LINK_HOPS) return;
27
+ link = resolve(dirname(link), readlinkSync(link));
28
+ }
29
+ } catch { /* Missing, unreadable and cyclic aliases are not evidence of this install. */ }
30
+ }
31
+
32
+ /** The PATH entry's canonical target must also be a CLI entry, not just a lookalike parent. */
33
+ function pathPackageRoot(invocation: string): string | undefined {
34
+ const target = lexicalTarget(invocation);
35
+ if (!target?.endsWith(`${sep}src${sep}cli${sep}main.js`)) return;
36
+ try {
37
+ const canonical = realPath(invocation);
38
+ if (!canonical.endsWith(`${sep}src${sep}cli${sep}main.js`)) return;
39
+ const candidate = dirname(dirname(dirname(target)));
40
+ return samePath(candidate, dirname(dirname(dirname(canonical)))) ? candidate : undefined;
41
+ } catch { return; }
42
+ }
43
+
44
+ /** Bun resolves argv[1], Bun.main and import.meta.dir, through both bun <link> and a shebang (native probe, #1245). */
45
+ export function stablePackageRoot(root: string, options: { invocation?: string } = {}): string {
46
+ const invocation = options.invocation ?? Bun.which("ahub", { PATH: process.env.PATH });
47
+ const candidate = invocation ? pathPackageRoot(invocation) : undefined;
48
+ return candidate && samePath(candidate, root) ? candidate : root;
49
+ }
50
+
51
+ export function stableBunRuntime(options: { runtime?: string; pathRuntime?: string } = {}): string {
52
+ const runtime = options.runtime ?? process.execPath, candidate = options.pathRuntime ?? Bun.which("bun", { PATH: process.env.PATH });
53
+ return candidate && lexicalTarget(candidate) && samePath(candidate, runtime) ? candidate : runtime;
54
+ }
55
+
56
+ /** Advice only; a staged daemon upgrade does not update the person's global CLI installation. */
57
+ export function globalCliUpdateStep(version?: string, options: InstallPathOptions = {}): string {
58
+ const env = options.env ?? process.env;
59
+ const invocation = Bun.which("ahub", { PATH: env.PATH ?? "" });
60
+ const root = invocation ? pathPackageRoot(invocation) : undefined;
61
+ const generic = `update the global CLI${version ? ` to ${version}` : ""} the way it was installed`;
62
+ if (!root) return generic;
63
+ const global = join(env.BUN_INSTALL || join(options.home ?? homedir(), ".bun"), "install/global/node_modules");
64
+ let bunGlobal = false;
65
+ try {
66
+ const within = relative(realPath(global), realPath(root));
67
+ bunGlobal = within !== "" && within !== ".." && !within.startsWith(`..${sep}`) && !isAbsolute(within);
68
+ } catch { /* An unproven Bun prefix must not suggest updating a different CLI. */ }
69
+ if (bunGlobal) return `run \`bun add ${options.globalFlag ?? "-g"} @staix/agent-hub${version ? `@${version}` : ""}\``;
70
+ return `${generic} (running from ${root})`;
71
+ }
package/src/cli/main.ts CHANGED
@@ -43,6 +43,7 @@ import { readAutonomyDefaults, writeAutonomyDefaults } from "../hub/autonomy-def
43
43
  import { inheritMachineAutonomy, prepareAutonomyProject, runAllProjectAutonomy, type PreparedAutonomyProject } from "./autonomy-projects.ts";
44
44
  import { offlineAutonomyStatus } from "./autonomy-offline.ts";
45
45
  import { autonomyHomeStatus, usedClaudeHomes } from "./autonomy-homes.ts";
46
+ import { shellQuote } from "./shell-quote.ts";
46
47
  import { discoverClaudeHomes } from "./claude-homes.ts";
47
48
  import { setupClaudeHomes } from "./setup-homes.ts";
48
49
  import { registerAconPlugin } from "./acon-register.ts";
@@ -89,7 +90,7 @@ import { createInterface as lineReader } from "node:readline";
89
90
  import { activeOperation, assertLifecycleAvailable, operationIdPattern, readOperation, writeOperation, recoveryLock, recoveryRunner, signedRunner, stopSignedRunner, supportsAction } from "../hub/recovery-store.ts";
90
91
  import { childEnv, personEnv } from "../hub/child-process.ts";
91
92
  import { abortRecovery, createOperation, disposeRecovery, recordRecoveryDelegation, repairedFreshSession, reattachRecovery, liveProjects, nextChoices, nextActionsText, publicOperation, recoveryCommand, registeredProjects, runRecovery, type RecoveryOperation, type Inspection } from "./upgrade.ts";
92
- import { endPlannedPeer, makeRecoveryDriver, makeUpgradePlan, preserveSource } from "./upgrade-runtime.ts";
93
+ import { endPlannedPeer, makeRecoveryDriver, makeUpgradePlan, preserveSource, reattachPlannedTerminals } from "./upgrade-runtime.ts";
93
94
  import { exactVersion, latestRelease, newerVersion } from "./recovery-package.ts";
94
95
  import { globalUpdateAdvice, runtimeVersionWarnings } from "./runtime-version-warning.ts";
95
96
  import { operationScreen, planScreen, type ScreenIO, type UpgradeHost } from "./upgrade-interactive.ts";
@@ -722,6 +723,7 @@ function upgradeHost(kind: "restart" | "upgrade", version: string): UpgradeHost
722
723
  endInspection: recoveryEndInspection,
723
724
  afterCompleted: (op) => globalUpdateLines(op.plan.version),
724
725
  endPeer: (planned, peer) => endPlannedPeer(planned, peer),
726
+ reattachTerminals: (planned, terminals) => reattachPlannedTerminals(planned, terminals),
725
727
  runnerStoppable: (id) => signedRunner(id) !== undefined,
726
728
  stopRunner: (id) => stopSignedRunner(id),
727
729
  entry: join(import.meta.dir, "main.js"),
@@ -2931,6 +2933,23 @@ const commands: Record<string, () => Promise<void> | void> = {
2931
2933
  }
2932
2934
  const plugin = pluginState(parseList<InstalledPlugin>(spawnSync(DOCTOR_PLUGIN_PROBE.command, [...DOCTOR_PLUGIN_PROBE.argv], { encoding: "utf8" }).stdout ?? ""), join(import.meta.dir, "..", ".."));
2933
2935
  row(plugin.state === "current", DOCTOR_PLUGIN_PROBE.name, plugin.state === "missing" ? "missing: run ahub setup" : plugin.state === "current" ? `agent-hub@agent-hub ${plugin.version}` : `agent-hub@agent-hub is stale (${plugin.why}): run ahub setup`);
2936
+ let profileRegistry: Registry | undefined;
2937
+ try {
2938
+ profileRegistry = new Registry();
2939
+ const profiles = await autonomyHomeStatus(profileRegistry, homedir(), process.env, join(import.meta.dir, "..", ".."), (argv, options) => {
2940
+ const result = spawnSync(argv[0]!, argv.slice(1), { encoding: "utf8", env: options.env, timeout: options.timeoutMs, maxBuffer: 1024 * 1024 });
2941
+ return { status: result.status ?? -1, stdout: result.stdout ?? "" };
2942
+ });
2943
+ for (const profile of profiles.homes) {
2944
+ const installed = profile.hook.state === "installed";
2945
+ checks.push({ name: `Claude profile hook: ${profile.home.path}`, section: "Hub", level: installed ? "ok" : "warn",
2946
+ detail: installed ? profile.hook.reason : `${profile.hook.reason} Run CLAUDE_CONFIG_DIR=${shellQuote(profile.home.path)} ahub setup.` });
2947
+ }
2948
+ if (!profiles.complete) checks.push({ name: "Claude profile discovery", section: "Hub", level: "unknown",
2949
+ detail: profiles.notices.join("; ") || "Claude profile discovery is incomplete; inspect the registered projects and selected homes before setup." });
2950
+ } catch {
2951
+ checks.push({ name: "Claude profile discovery", section: "Hub", level: "unknown", detail: "Claude profile inventory is unavailable; inspect the machine registry and selected homes before setup." });
2952
+ } finally { profileRegistry?.close(); }
2934
2953
  if (up) checks.push(...runtimeVersionWarnings(daemonVersion, VERSION, plugin.state === "missing" ? undefined : plugin.version));
2935
2954
 
2936
2955
  const config = loadConfig(cwd);
package/src/cli/output.ts CHANGED
@@ -5,7 +5,7 @@ import { boardColumns, type BoardPeerInput } from "../ui/task-board.ts";
5
5
  import { peerStyle } from "../ui/peer-palette.ts";
6
6
  import { cells, TABLES, table, fitLine, wrap, duration, relative, plural, terminalText, initialConsoleState, stateTone, type Span, type Tone } from "./console-state.ts";
7
7
  import { taskProgress } from "../ui/task-progress.ts";
8
- import { backendLabel, memorySyncLine, reviewInputHealthLine } from "./status-lines.ts";
8
+ import { backendLabel, memorySyncLine, reviewInputHealthLine, roomsUnavailableLine } from "./status-lines.ts";
9
9
  import { ownerIsolationText } from "./role-holders.ts";
10
10
  import { schedulePeriodText, scheduleWhen } from "../hub/schedules.ts";
11
11
  import { formatReport, formatTaskReport, type Report, type TaskReport } from "../hub/report.ts";
@@ -14,6 +14,8 @@ import type { SpecsStatus } from "../hub/specs-task.ts";
14
14
 
15
15
  export interface DoctorCheck { section: "Tools" | "Hub" | "Config" | "Models" | "Memory"; level: "ok" | "warn" | "fail" | "unknown"; name: string; detail: string }
16
16
  export interface StatusOutput {
17
+ roomsUnavailable?: unknown;
18
+ roomsOpening?: boolean;
17
19
  controlWriteTiming?: { active: number; slowRequests: number; blockedLoopSamples: number; droppedActive: number; droppedRecent: number; droppedLogs: number; droppedSpans?: number; droppedMetadata?: number; expiredRequests?: number };
18
20
  specs?: SpecsStatus & { missingBlockHint?: boolean };
19
21
  queueBackpressure?: QueueBackpressureStatus;
@@ -263,6 +265,8 @@ export function renderStatus(data: StatusOutput, columns?: number, now = Date.no
263
265
  const text = ownerIsolationText(isolated, data.ownerIsolationNotes?.[id]);
264
266
  if (text) out.push(...detail(`${id} owner isolation`, text, columns, isolated ? undefined : "attention"));
265
267
  }
268
+ if (data.roomsOpening === true) out.push(...lines("rooms: opening; wait for local room storage initialization", columns, "muted"));
269
+ if (data.roomsUnavailable !== undefined) out.push(...lines(roomsUnavailableLine(data.roomsUnavailable), columns, "failure"));
266
270
  if (data.deliveryError) out.push(...detail("delivery storage", `${data.deliveryError}; dispatch is stopped`, columns, "failure"));
267
271
  for (const [peer, count] of Object.entries(data.deliveryFailures ?? {})) {
268
272
  if (Number.isSafeInteger(count) && count > 0) out.push(...detail(`${peer} failed deliveries`, `${count}; next: ahub queue discard --peer ${peer} --state failed`, columns, "attention"));