@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
package/src/cli/room.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { projectRoomUnavailable, projectRoomDiagnostics, type RoomStatusDiagnostics } from "../rooms/status.ts";
1
2
  import { decodeBase64url } from "../rooms/crypto.ts";
2
3
  import { roomReceiptReasonText, type RoomReceiptReason } from "../rooms/application.ts";
3
4
  import { frame, type Envelope } from "../hub/envelope.ts";
@@ -17,8 +18,9 @@ export type RoomRequest = (message: Record<string, unknown>) => Promise<any>;
17
18
  export interface RoomConfirmationIO { isTTY(): boolean; answer(prompt: string): Promise<string> }
18
19
 
19
20
  export class RoomCliError extends Error {
20
- constructor(readonly code: "usage" | "refused" | "invalid_reply" | "inbox_list_limit" | "confirmation_required" | "not_confirmed" | "confirmation_mismatch" | "epoch_rotated_hold_unavailable") {
21
- const message = { refused: "operation refused", invalid_reply: "invalid control reply", inbox_list_limit: "inbox list exceeds the limit; choose a room name", confirmation_required: "confirmation required; use a TTY or --confirm with the member fingerprint prefix", not_confirmed: "not confirmed; nothing executed", confirmation_mismatch: "confirmation does not match the member fingerprint; nothing executed",
21
+ constructor(readonly code: "usage" | "refused" | "invalid_reply" | "inbox_list_limit" | "confirmation_required" | "not_confirmed" | "confirmation_mismatch" | "epoch_rotated_hold_unavailable" | "rooms_unavailable", availability?: unknown) {
22
+ const unavailable = projectRoomUnavailable(availability);
23
+ const message = { rooms_unavailable: `unavailable (${unavailable?.code ?? "unknown"}); ${unavailable?.nextStep ?? "Run ahub status to inspect room storage before retrying."}`, refused: "operation refused", invalid_reply: "invalid control reply", inbox_list_limit: "inbox list exceeds the limit; choose a room name", confirmation_required: "confirmation required; use a TTY or --confirm with the member fingerprint prefix", not_confirmed: "not confirmed; nothing executed", confirmation_mismatch: "confirmation does not match the member fingerprint; nothing executed",
22
24
  epoch_rotated_hold_unavailable: "epoch rotated but delivery journal holds are unavailable; inspect ahub room status and ahub queue show before another mutation" };
23
25
  super(code === "usage" ? ROOM_USAGE : `room: ${message[code]}`);
24
26
  this.name = "RoomCliError";
@@ -145,7 +147,7 @@ type DeliveryState = "pending" | "dispatching" | "accepted" | "completed" | "com
145
147
  interface DeliveryRow { direction: "inbound" | "outbound"; id: string; application: string; peer: string; state: DeliveryState; disposition?: "accept" | "completed" | "retry" | "discard"; lateReceipt?: "completed (admitted)" | "completed (inbox)" | "failed_safe"; refusalReason?: RoomReceiptReason; holdReason?: "epoch rotated" }
146
148
  interface DeliverySummary { direction: "inbound"; pending: number; needsReview: number; truncated: boolean }
147
149
  interface RemotePeerRow { id: string; state: "idle" | "busy" | "paused" | "offline"; presence?: "idle" | "busy" | "paused" | "offline" | "unknown"; liveness?: "untracked" }
148
- interface RoomRow { name: string; room: string; owner: string; member: string; epoch: string; state: string; deliveries?: (DeliveryRow | DeliverySummary)[]; remotePeers?: RemotePeerRow[] }
150
+ interface RoomRow extends RoomStatusDiagnostics { name: string; room: string; owner: string; member: string; epoch: string; state: string; deliveries?: (DeliveryRow | DeliverySummary)[]; remotePeers?: RemotePeerRow[] }
149
151
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
150
152
  const REMOTE_ID = /^[a-z][a-z0-9-]{0,31}@[a-z][a-z0-9-]{0,31}$/;
151
153
  const REFUSAL_REASONS: readonly RoomReceiptReason[] = ["admitted", "not_exposed", "boundary_refused", "invalid_message", "rate_limited", "hop", "person_inbox_unsupported", "inbox_admitted", "inbox_full", "inbox_oversize", "inbox_expired", "inbox_age_unavailable"];
@@ -194,7 +196,10 @@ function record(value: unknown): Record<string, unknown> {
194
196
  }
195
197
  function success(value: unknown): Record<string, unknown> {
196
198
  const row = record(value);
197
- if (row.ok !== true) fail(row.code === "epoch_rotated_hold_unavailable" ? "epoch_rotated_hold_unavailable" : "refused");
199
+ if (row.ok !== true) {
200
+ if (row.code === "rooms_unavailable") throw new RoomCliError("rooms_unavailable", row.roomsUnavailable);
201
+ fail(row.code === "epoch_rotated_hold_unavailable" ? "epoch_rotated_hold_unavailable" : "refused");
202
+ }
198
203
  return row;
199
204
  }
200
205
  function roomRows(value: unknown): RoomRow[] {
@@ -206,7 +211,7 @@ function roomRows(value: unknown): RoomRow[] {
206
211
  || typeof row.epoch !== "string" || !/^(0|[1-9][0-9]{0,19})$/.test(row.epoch)
207
212
  || typeof row.state !== "string" || !/^[a-z][a-z_-]{0,31}$/.test(row.state)) return fail("invalid_reply");
208
213
  // Ignore all other fields, even from a newer daemon: read output never grows into room bodies or secrets.
209
- return { name: row.name, room: row.room, owner: row.owner, member: row.member, epoch: row.epoch, state: row.state,
214
+ return { name: row.name, room: row.room, owner: row.owner, member: row.member, epoch: row.epoch, state: row.state, ...projectRoomDiagnostics(row),
210
215
  ...(row.deliveries !== undefined ? { deliveries: deliveryRows(row.deliveries) } : {}), ...(row.remotePeers !== undefined ? { remotePeers: remotePeerRows(row.remotePeers) } : {}) };
211
216
  });
212
217
  }
@@ -274,6 +279,13 @@ export async function runRoomCommand(args: readonly string[], request: RoomReque
274
279
  console.log(`Rooms (${rows.length})`);
275
280
  for (const row of rows) {
276
281
  console.log(`${row.name} ${row.room} epoch ${row.epoch} ${row.state}\n owner ${row.owner}\n member ${row.member}`);
282
+ if (command.op === "status") {
283
+ const health = row.health, age = (value: number | null | undefined) => typeof value === "number" ? `${value}ms` : "unknown";
284
+ if (row.state === "online" && (row.linkCause === null || row.linkCause === undefined)) console.log(" link online");
285
+ else console.log(` link cause ${row.linkCause ?? "unknown"}; next: ${row.linkNextStep ?? "Check broker availability, then read ahub room status again."}`);
286
+ console.log(` outbox ${health?.outboxDepth ?? "unknown"}; oldest ${age(health?.oldestOutboxAgeMs)}; last receipt ${age(health?.lastReceiptAgeMs)}`);
287
+ console.log(` refused: ${health?.refused ? Object.entries(health.refused).map(([reason, count]) => `${reason} ${count}`).join(", ") : "unknown"}`);
288
+ }
277
289
  for (const peer of row.remotePeers ?? []) console.log(` peer ${peer.id}: ${peer.presence ?? peer.state}${peer.liveness === "untracked" ? "; liveness not tracked (broker without ephemeral); upgrade the broker to track liveness" : ""}`);
278
290
  for (const delivery of row.deliveries ?? []) {
279
291
  if (!("id" in delivery)) console.log(` inbound: pending ${delivery.pending}, needs_review ${delivery.needsReview}${delivery.truncated ? "; truncated" : ""}`);
@@ -1,3 +1,4 @@
1
+ import { globalCliUpdateStep, type InstallPathOptions } from "./install-paths.ts";
1
2
  import type { DoctorCheck } from "./output.ts";
2
3
  import { parseList, pluginState, type InstalledPlugin } from "./setup.ts";
3
4
 
@@ -5,7 +6,7 @@ const VERSION = /^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?(
5
6
  const version = (value: unknown): string | undefined => typeof value === "string" && value.length <= 64 && VERSION.exec(value)?.[0] === value ? value : undefined;
6
7
 
7
8
  /** Authenticated daemon status and the existing plugin inventory only. Suggestions never run an update. */
8
- export function runtimeVersionWarnings(daemonVersion: unknown, cliVersion: unknown, pluginVersion: unknown): DoctorCheck[] {
9
+ export function runtimeVersionWarnings(daemonVersion: unknown, cliVersion: unknown, pluginVersion: unknown, install: InstallPathOptions = {}): DoctorCheck[] {
9
10
  const running = version(daemonVersion), cli = version(cliVersion), plugin = version(pluginVersion);
10
11
  const rows: DoctorCheck[] = [];
11
12
  const unknown = (name: string, detail: string) => rows.push({ section: "Hub", level: "unknown", name, detail });
@@ -18,7 +19,7 @@ export function runtimeVersionWarnings(daemonVersion: unknown, cliVersion: unkno
18
19
  else try {
19
20
  cliOlder = Bun.semver.order(cli, running) < 0;
20
21
  if (cliOlder) rows.push({ section: "Hub", level: "warn", name: "CLI versus daemon",
21
- detail: `running daemon ${running} is newer than this CLI ${cli}; update separately: bun add --global @staix/agent-hub@${running}` });
22
+ detail: `running daemon ${running} is newer than this CLI ${cli}; update separately: ${globalCliUpdateStep(running, { ...install, globalFlag: "--global" })}` });
22
23
  } catch { unknown("CLI versus daemon", "CLI and daemon versions could not be compared"); }
23
24
  if (!plugin) unknown("plugin versus daemon", "installed plugin version unavailable; run ahub setup with an updated CLI to inspect it");
24
25
  else try {
@@ -36,16 +37,16 @@ export function runtimeVersionWarnings(daemonVersion: unknown, cliVersion: unkno
36
37
  * target release's own coordinator (`AGENTHUB_UPGRADE_HANDOVER=1`, set by every release since #272), the process that prints is the target itself and cannot see the CLI that started it, but that
37
38
  * CLI is older by construction (it handed over because the target was newer), so `handedOver` stands for it. An unknown plugin (no `claude`, unreadable inventory) is never a claim.
38
39
  */
39
- export function globalUpdateAfterUpgrade(target: unknown, cli: unknown, plugin: unknown, handedOver = false): string[] {
40
+ export function globalUpdateAfterUpgrade(target: unknown, cli: unknown, plugin: unknown, handedOver = false, install: InstallPathOptions = {}): string[] {
40
41
  const running = version(target);
41
42
  if (!running) return [];
42
- const warned = new Set(runtimeVersionWarnings(running, handedOver ? undefined : cli, plugin).filter(row => row.level === "warn").map(row => row.name));
43
+ const warned = new Set(runtimeVersionWarnings(running, handedOver ? undefined : cli, plugin, install).filter(row => row.level === "warn").map(row => row.name));
43
44
  const cliOlder = handedOver || warned.has("CLI versus daemon"), pluginOlder = warned.has("plugin versus daemon");
44
45
  if (!cliOlder && !pluginOlder) return [];
45
46
  return [
46
47
  "ahub upgrade does not update the global CLI or the shared Claude plugin.",
47
48
  cliOlder
48
- ? `${handedOver ? "The CLI that started this upgrade is older than the daemon this upgrade installs" : "The global CLI is older than the daemon this upgrade installs"} (${running}): run \`bun add -g @staix/agent-hub@${running}\` and then \`ahub setup\` yourself.`
49
+ ? `${handedOver ? "The CLI that started this upgrade is older than the daemon this upgrade installs" : "The global CLI is older than the daemon this upgrade installs"} (${running}): ${globalCliUpdateStep(running, install)} and then \`ahub setup\` yourself.`
49
50
  : `The installed plugin is older than the daemon this upgrade installs (${running}): run \`ahub setup\` yourself.`,
50
51
  ];
51
52
  }
@@ -59,6 +60,7 @@ export interface GlobalUpdateInputs {
59
60
  handedOver: boolean;
60
61
  /** Where this release's shipped plugin bundle lives, for `pluginState`. */
61
62
  packageRoot: string;
63
+ install?: InstallPathOptions;
62
64
  /** The same `claude plugin list --json` probe `ahub doctor` runs. */
63
65
  probe: () => { status: number | null; stdout?: string | null };
64
66
  }
@@ -74,5 +76,5 @@ export function globalUpdateAdvice(input: GlobalUpdateInputs): string[] {
74
76
  const state = pluginState(parseList<InstalledPlugin>(probed.status === 0 ? probed.stdout ?? "" : ""), input.packageRoot);
75
77
  plugin = state.state === "missing" ? undefined : state.version;
76
78
  } catch { /* an unknown plugin: the CLI half below does not depend on it */ }
77
- try { return globalUpdateAfterUpgrade(input.target, input.cli, plugin, input.handedOver); } catch { return []; }
79
+ try { return globalUpdateAfterUpgrade(input.target, input.cli, plugin, input.handedOver, input.install); } catch { return []; }
78
80
  }
@@ -1,7 +1,8 @@
1
1
  import { checkClaudeHome, claudeHomeEnvironment, type ClaudeHome, type ClaudeHomeProbe } from "./claude-homes.ts";
2
- import { installClaudeProfileHook } from "./claude-profile-hook.ts";
2
+ import { installClaudeProfileHook, type ClaudeProfileHookOptions } from "./claude-profile-hook.ts";
3
+ import { join } from "node:path";
3
4
 
4
- const marketplaceRemovalRefusal = (home: string) => new Error(`Claude setup refused marketplace removal in ${home}; non-user plugin rows were preserved`);
5
+ const marketplaceRemovalRefusal = (home: string, detail: string) => new Error(`Claude setup refused marketplace removal in ${JSON.stringify(home)}; non-user plugin rows were preserved. ${detail}. Selected home plugin inventory: ${JSON.stringify(join(home, "plugins/installed_plugins.json"))}.`);
5
6
 
6
7
  export interface SetupHomesPorts {
7
8
  env: NodeJS.ProcessEnv;
@@ -14,18 +15,20 @@ export interface SetupHomesPorts {
14
15
  ask(prompt: string): Promise<string | null>;
15
16
  print(line: string): void;
16
17
  current?(): void;
18
+ /** Fixed host/test hook command options; never selected from project configuration. */
19
+ hook?: ClaudeProfileHookOptions;
17
20
  }
18
21
  /** Selected homes only. Native plugin operations and the project-neutral hook share one person confirmation. */
19
22
  export async function setupClaudeHomes(ports: SetupHomesPorts): Promise<void> {
20
23
  ports.current?.();
21
24
  if (!ports.homes.length) { ports.print("No Claude configuration home in use was found; set CLAUDE_CONFIG_DIR to select one."); return; }
22
25
  const initial = [];
23
- for (const home of ports.homes) initial.push(await checkClaudeHome(home, ports.packageRoot, ports.env, ports.probe, undefined, true));
26
+ for (const home of ports.homes) initial.push(await checkClaudeHome(home, ports.packageRoot, ports.env, ports.probe, undefined, true, ports.hook));
24
27
  for (const check of initial) {
25
28
  ports.print(`Claude home ${check.home.path}: plugin ${check.plugin.state}; hook ${check.hook.state}.`);
26
29
  for (const notice of ("notices" in check.plugin ? check.plugin.notices : undefined) ?? []) ports.print(`${check.home.path}: ${notice}.`);
27
30
  if (check.plugin.state === "unknown" || check.hook.state === "refused") throw new Error(`Claude setup cannot safely inspect ${check.home.path}; nothing was changed`);
28
- if (check.next?.blocked) throw marketplaceRemovalRefusal(check.home.path);
31
+ if (check.next?.blocked) throw marketplaceRemovalRefusal(check.home.path, check.next.why);
29
32
  }
30
33
  for (const check of initial) if (check.next) ports.print(`${check.home.path}: ${check.next.blocked ? "setup blocked" : check.next.argv.join(" ")} (${check.next.why})`);
31
34
  if (initial.every(check => !check.next && check.hook.state === "installed")) return;
@@ -36,25 +39,25 @@ export async function setupClaudeHomes(ports: SetupHomesPorts): Promise<void> {
36
39
  let current = check;
37
40
  for (let step = 0; current.next && step < 6; step++) {
38
41
  const action = current.next;
39
- if (action.blocked) throw marketplaceRemovalRefusal(check.home.path);
42
+ if (action.blocked) throw marketplaceRemovalRefusal(check.home.path, action.why);
40
43
  ports.print(`${check.home.path}: ${action.argv.join(" ")} (${action.why})`);
41
44
  ports.current?.();
42
45
  if (action.argv[2] === "marketplace" && action.argv[3] === "remove") {
43
- const fresh = await checkClaudeHome({ ...check.home, exists: true }, ports.packageRoot, ports.env, ports.probe, undefined, true);
46
+ const fresh = await checkClaudeHome({ ...check.home, exists: true }, ports.packageRoot, ports.env, ports.probe, undefined, true, ports.hook);
44
47
  if (fresh.plugin.state === "unknown") throw new Error(`Claude plugin inventory is unavailable for ${check.home.path}`);
45
- if (fresh.next?.blocked) throw marketplaceRemovalRefusal(check.home.path);
48
+ if (fresh.next?.blocked) throw marketplaceRemovalRefusal(check.home.path, fresh.next.why);
46
49
  if (JSON.stringify(fresh.next?.argv) !== JSON.stringify(action.argv)) { current = fresh; continue; }
47
50
  current = fresh;
48
51
  ports.current?.();
49
52
  }
50
53
  if (await ports.run(action.argv, claudeHomeEnvironment(check.home, ports.env)) !== 0) throw new Error(`Claude plugin setup failed for ${check.home.path}; no later home was changed`);
51
- current = await checkClaudeHome({ ...check.home, exists: true }, ports.packageRoot, ports.env, ports.probe, undefined, true);
54
+ current = await checkClaudeHome({ ...check.home, exists: true }, ports.packageRoot, ports.env, ports.probe, undefined, true, ports.hook);
52
55
  if (current.plugin.state === "unknown") throw new Error(`Claude plugin inventory is unavailable for ${check.home.path}`);
53
- if (current.next?.blocked) throw marketplaceRemovalRefusal(check.home.path);
56
+ if (current.next?.blocked) throw marketplaceRemovalRefusal(check.home.path, current.next.why);
54
57
  }
55
58
  if (current.next) throw new Error(`Claude setup exceeded six plugin steps for ${check.home.path}`);
56
59
  ports.current?.();
57
- const hook = installClaudeProfileHook(check.home);
60
+ const hook = installClaudeProfileHook(check.home, ports.hook);
58
61
  if (hook.state !== "installed") throw new Error(hook.reason);
59
62
  ports.print(`${check.home.path}: plugin current; hook ${hook.changed ? "installed" : "current"}. ${hook.reason}`);
60
63
  }
@@ -1,3 +1,4 @@
1
+ import { projectRoomUnavailable } from "../rooms/status.ts";
1
2
  import type { ContextView } from "../hub/context-window.ts";
2
3
  import { memorySyncNextStep, type MemorySyncCause } from "../hub/memory-sync-state.ts";
3
4
  // What `ahub status` prints for a peer and for a model backend, kept pure so it can be checked.
@@ -35,7 +36,7 @@ export function memorySyncLine(sync: MemorySyncRow, time = (at: number) => new D
35
36
  const cause = (causes.includes(sync.cause ?? "") ? sync.cause : "unknown") as MemorySyncCause;
36
37
  if (cause === "declined" && sync.placementActive === false) {
37
38
  const declined = Number.isSafeInteger(sync.declinedCount) && sync.declinedCount! >= 0 ? String(sync.declinedCount) : "unknown";
38
- return "machine sync blocked: " + declined + " declined rows; per-row placement is not active on this store (agent-mem has no activation command yet, agent-mem#281). Inspect with `ahub memory declined`.";
39
+ return "machine sync blocked: " + declined + " declined rows; per-row placement is not active on this store. Activation is person-only: preview with `amem migrate --sync-placement --dry-run` and declare every writer via --writer-root. The hub does not yet supply the fresh placementAttestation required for active-placement sync (#1173). Inspect with `ahub memory declined`.";
39
40
  }
40
41
  const count = Number.isSafeInteger(sync.declinedCount) && sync.declinedCount! >= 0 ? `, ${sync.declinedCount} declined` : "";
41
42
  const ids = cause === "declined" && sync.declinedIdsKnown === false ? "; declined ID set unknown" : "";
@@ -55,3 +56,9 @@ export function reviewInputHealthLine(health: ReviewInputHealthRow): string | un
55
56
  const state = health.state === "incomplete" || health.state === "corrupt" ? health.state : "unknown";
56
57
  return `${state} (${cause}); next: ${next}`;
57
58
  }
59
+
60
+ /** Room failures carry a closed cause and locally generated next step only. */
61
+ export function roomsUnavailableLine(value: unknown): string {
62
+ const unavailable = projectRoomUnavailable(value);
63
+ return `rooms: unavailable (${unavailable?.code ?? "unknown"}); next: ${unavailable?.nextStep ?? "Run ahub room status to inspect room storage and broker availability."}`;
64
+ }
@@ -100,6 +100,18 @@ export interface TerminalInspection {
100
100
  byPeer: Partial<Record<TerminalPeer, TerminalBinding>>;
101
101
  blockers: RecoveryBlocker[];
102
102
  manualRequired: boolean;
103
+ /** #1246: terminals whose identity was verified (handle, incarnation, worktree, root, peer) and that read orphaned but still connected: the only ones a person may have switched back. */
104
+ orphaned?: OrphanedTerminal[];
105
+ }
106
+
107
+ /** #1246: one verified orphaned-but-connected terminal, as the plan records it. Never a session or a launch. */
108
+ export interface OrphanedTerminal {
109
+ peer: TerminalPeer;
110
+ handle: string;
111
+ incarnationId: string;
112
+ worktreeId: string;
113
+ /** The terminal's worktree root as Orca reported it and the plan matched it. */
114
+ root: string;
103
115
  }
104
116
 
105
117
  export interface IdleResult {
@@ -206,17 +218,32 @@ function decoded(result: CommandResult): unknown {
206
218
  }
207
219
  }
208
220
 
221
+ /** Orca's structured error, from the JSON it prints on stdout or stderr. */
222
+ function orcaError(result: CommandResult): Record<string, unknown> {
223
+ const fromStdout = object(object(decoded(result)).error);
224
+ if (fromStdout.code !== undefined) return fromStdout;
225
+ try { return object(object(JSON.parse(bytes(result.stderr))).error); } catch { return {}; }
226
+ }
227
+
228
+ function errorCode(result: CommandResult): unknown {
229
+ return orcaError(result).code;
230
+ }
231
+
232
+ /** #1233: Orca prints its error as JSON on stdout with an empty stderr, so its code comes before the exit-status fallback. */
209
233
  function errorText(result: CommandResult): string {
234
+ const { code, message } = orcaError(result);
235
+ if (typeof code === "string") return typeof message === "string" && message !== code ? `${code}: ${message.slice(0, 200)}` : code;
210
236
  const stderr = bytes(result.stderr).trim();
211
237
  return stderr || `exit status ${commandStatus(result)}`;
212
238
  }
213
239
 
214
- /** Orca's structured error code, from the JSON it prints on stdout or stderr. */
215
- function errorCode(result: CommandResult): unknown {
216
- const fromStdout = object(object(decoded(result)).error).code;
217
- if (fromStdout !== undefined) return fromStdout;
218
- try { return object(object(JSON.parse(bytes(result.stderr))).error).code; } catch { return undefined; }
219
- }
240
+ /**
241
+ * Orca 1.4.250 answers a terminal it can no longer reach with one of these codes, and its own CLI groups all four as
242
+ * gone (docs/verification/2026-11-06-orca-1.4.250-cli-contract.md). A wait on a pane whose process ended throws
243
+ * terminal_exited, so restoration must read it as gone and check the launcher, not stop on a command error (#1233).
244
+ */
245
+ const GONE_CODES = new Set(["terminal_handle_stale", "terminal_exited", "terminal_gone", "no_connected_pty"]);
246
+ const GONE_STATES: Record<string, string> = { terminal_handle_stale: "no longer exists", terminal_exited: "exited (terminal_exited)" };
220
247
 
221
248
  function runnerFailure(argv: readonly string[], result: CommandResult): RecoveryBlocker {
222
249
  return { code: "command-error", message: `Orca ${argv.slice(0, 2).join(" ")} failed: ${errorText(result)}` };
@@ -226,9 +253,11 @@ async function run(runner: CommandRunner, argv: readonly string[]): Promise<unkn
226
253
  const result = await runner(argv);
227
254
  if (commandStatus(result) !== 0) {
228
255
  // Orca 1.4.223 and, rechecked read-only, 1.4.245 (docs/verification/2026-11-02-orca-1.4.245-cli-contract.md) answer a handle whose terminal was removed with `{"ok":false,"error":{"code":"terminal_handle_stale"}}`.
229
- if (errorCode(result) === "terminal_handle_stale") {
256
+ const code = errorCode(result);
257
+ if (typeof code === "string" && GONE_CODES.has(code)) {
230
258
  const handle = argv.includes("--terminal") ? argv[argv.indexOf("--terminal") + 1] : undefined;
231
- throw new OrcaCommandError({ code: "terminal-gone", message: `the Orca terminal${handle ? ` ${handle}` : ""} no longer exists` });
259
+ const state = GONE_STATES[code] ?? `is gone (${code})`;
260
+ throw new OrcaCommandError({ code: "terminal-gone", message: `the Orca terminal${handle ? ` ${handle}` : ""} ${state}` });
232
261
  }
233
262
  throw new OrcaCommandError(runnerFailure(argv, result));
234
263
  }
@@ -236,7 +265,9 @@ async function run(runner: CommandRunner, argv: readonly string[]): Promise<unkn
236
265
  const root = object(value);
237
266
  const payload = responseResult(value);
238
267
  if (root.ok === false || payload.ok === false) {
239
- throw new OrcaCommandError({ code: "command-error", message: `Orca ${argv.slice(0, 2).join(" ")} returned ok:false${typeof root.error === "string" ? `: ${root.error}` : ""}` });
268
+ const error = root.error ?? payload.error;
269
+ const detail = typeof error === "string" ? error : object(error).code;
270
+ throw new OrcaCommandError({ code: "command-error", message: `Orca ${argv.slice(0, 2).join(" ")} returned ok:false${typeof detail === "string" && detail ? `: ${detail}` : ""}` });
240
271
  }
241
272
  return value;
242
273
  }
@@ -633,7 +664,7 @@ function blocker(code: RecoveryBlocker["code"], message: string, peer?: Terminal
633
664
  code === "ownership-unknown" ? "confirm the terminal owner in Orca, then resume recovery" :
634
665
  code === "ambiguous-terminal" ? "close or identify the duplicate terminal, then resume recovery" :
635
666
  code === "ambiguous-create" ? "inspect the existing terminal and attach the original session before resuming" :
636
- code === "terminal-orphaned" ? `reattach the pane with orca terminal switch --terminal ${shellQuote(handle!)}; then refresh the recovery plan` :
667
+ code === "terminal-orphaned" ? `reattach the pane with orca terminal switch --terminal ${shellQuote(handle!)}; then refresh in ahub upgrade ([r] on the plan, [s] on an open operation), or rerun it` :
637
668
  code === "terminal-unready" ? "finish or cancel the active terminal turn, then resume recovery" :
638
669
  code === "launcher-exited" ? "the session was not restored; the coordinator's error names the choices" :
639
670
  code === "terminal-gone" ? "the terminal was closed; inspect the project's terminals in Orca before resuming recovery" :
@@ -641,6 +672,48 @@ function blocker(code: RecoveryBlocker["code"], message: string, peer?: Terminal
641
672
  return { code, message, ...(peer ? { peer } : {}), ...(handle ? { handle, terminalReference: handle } : {}), nextAction };
642
673
  }
643
674
 
675
+ /** #1246: how long a switched pane is re-read before it still counts as orphaned. Orca's `terminal show` can lag a switch by a moment. */
676
+ export const REATTACH_BOUND_MS = 5_000;
677
+ const REATTACH_POLL_MS = 250;
678
+
679
+ /**
680
+ * #1246: the one mutation the upgrade menu offers for an orphaned-but-connected pane: `orca terminal switch` for exactly
681
+ * this handle, after reading it again and finding the identity the plan verified (handle, incarnation, worktree, root)
682
+ * still orphaned and connected. It then re-reads the pane until `orphaned` is false, at most `boundMs`. A pane that is
683
+ * already not orphaned is `reattached` with no switch. A changed identity, a disconnected pane or any failed Orca call is
684
+ * `unavailable` (nothing, or nothing more, was done); a pane still orphaned at the bound stays `still-orphaned`.
685
+ */
686
+ export async function reattachOrphanedTerminal(terminal: OrphanedTerminal, options?: CommandRunner | TerminalRecoveryOptions, boundMs = REATTACH_BOUND_MS): Promise<"reattached" | "still-orphaned" | "unavailable"> {
687
+ const config = normalizeOptions(options);
688
+ const read = async (): Promise<Record<string, unknown> | undefined> => {
689
+ const shown = terminalObject(await run(config.runner, ["terminal", "show", "--terminal", terminal.handle, "--json"]));
690
+ const same = nestedString(shown, ["handle"]) === terminal.handle && nestedString(shown, ["incarnationId"]) === terminal.incarnationId && nestedString(shown, ["worktreeId"]) === terminal.worktreeId
691
+ && rootMatches(shown.worktreePath ?? shown.projectRoot, terminal.root);
692
+ return same ? shown : undefined;
693
+ };
694
+ // The runner is also bounded by its caller; this race keeps a runner that ignores its timeout from holding the screen past the bound.
695
+ const within = async <T>(work: Promise<T>, ms: number): Promise<T> => {
696
+ let timer: ReturnType<typeof setTimeout> | undefined;
697
+ try { return await Promise.race([work, new Promise<never>((_, reject) => { timer = setTimeout(() => reject(new Error("reattach bound exceeded")), ms); })]); }
698
+ finally { if (timer) clearTimeout(timer); }
699
+ };
700
+ try {
701
+ const before = await within(read(), boundMs);
702
+ if (!before) return "unavailable";
703
+ if (before.orphaned === false) return "reattached";
704
+ if (before.orphaned !== true || before.connected !== true) return "unavailable";
705
+ await within(run(config.runner, ["terminal", "switch", "--terminal", terminal.handle, "--json"]), boundMs);
706
+ const deadline = Date.now() + boundMs;
707
+ for (;;) {
708
+ const shown = await within(read(), deadline - Date.now());
709
+ if (!shown) return "unavailable";
710
+ if (shown.orphaned === false) return "reattached";
711
+ if (Date.now() >= deadline) return "still-orphaned";
712
+ await Bun.sleep(Math.min(REATTACH_POLL_MS, deadline - Date.now()));
713
+ }
714
+ } catch { return "unavailable"; }
715
+ }
716
+
644
717
  function verifyBindingData(actual: Record<string, unknown>, binding: TerminalBinding): RecoveryBlocker | undefined {
645
718
  const handle = nestedString(actual, ["handle"]);
646
719
  const incarnation = nestedString(actual, ["incarnationId"]);
@@ -714,6 +787,7 @@ export async function inspectTerminals(projectRoot: string, sessions: Partial<Re
714
787
  const config = normalizeOptions(options);
715
788
  const blockers: RecoveryBlocker[] = [];
716
789
  const bindings: TerminalBinding[] = [];
790
+ const orphaned: OrphanedTerminal[] = [];
717
791
  const byPeer: Partial<Record<TerminalPeer, TerminalBinding>> = {};
718
792
  const requested = (Object.entries(sessions) as [TerminalPeer, string | SessionRef | undefined][]).map(([peer, value]) => [peer, typeof value === "string" ? { sessionId: value } : value] as const).filter((entry): entry is [TerminalPeer, SessionRef] => !!entry[1]?.sessionId);
719
793
  if (!requested.length) return { bindings, byPeer, blockers, manualRequired: false };
@@ -795,7 +869,8 @@ export async function inspectTerminals(projectRoot: string, sessions: Partial<Re
795
869
  continue;
796
870
  }
797
871
  if (terminal.orphaned === true && terminal.connected === true) {
798
- blockers.push(blocker("terminal-orphaned", `terminal ${handle} is orphaned but still connected`, peer, handle));
872
+ blockers.push(blocker("terminal-orphaned", `the ${peer} terminal ${handle} in ${projectRoot} is orphaned but still connected`, peer, handle));
873
+ orphaned.push({ peer, handle, incarnationId, worktreeId, root: expectedTerminalRoot });
799
874
  continue;
800
875
  }
801
876
  if (terminal.orphaned === true || terminal.connected === false) {
@@ -811,19 +886,22 @@ export async function inspectTerminals(projectRoot: string, sessions: Partial<Re
811
886
  bindings.push(binding);
812
887
  byPeer[peer] = binding;
813
888
  }
814
- return { bindings, byPeer, blockers, manualRequired: blockers.length > 0 };
889
+ return { bindings, byPeer, blockers, manualRequired: blockers.length > 0, ...(orphaned.length ? { orphaned } : {}) };
815
890
  }
816
891
 
817
892
  /**
818
893
  * One bounded Orca wait. Orca 1.4.223 and, rechecked read-only, 1.4.245 (docs/verification/2026-11-02-orca-1.4.245-cli-contract.md) answer a wait that timed out with exit 1 and `{"ok":false,"error":{"code":"timeout"}}`:
819
- * that is "not satisfied", not a failure. Any other failure still throws.
894
+ * that is "not satisfied", not a failure. Any other failure still throws. 1.4.250 also answers ok with `satisfied: false`
895
+ * and a `blockedReason` (seen: `agent-update-prompt`) when the TUI waits on a prompt; the reason is passed on (#1233).
820
896
  */
821
- async function waitSatisfied(runner: CommandRunner, handle: string, timeoutMs: number): Promise<boolean> {
897
+ async function waitSatisfied(runner: CommandRunner, handle: string, timeoutMs: number): Promise<{ satisfied: boolean; blockedReason?: string }> {
822
898
  const argv = ["terminal", "wait", "--terminal", handle, "--for", "tui-idle", "--timeout-ms", String(timeoutMs), "--json"];
823
899
  const result = await runner(argv);
824
- if (commandStatus(result) !== 0 && errorCode(result) === "timeout") return false;
900
+ if (commandStatus(result) !== 0 && errorCode(result) === "timeout") return { satisfied: false };
825
901
  const root = responseResult(await run(async () => result, argv));
826
- return root.satisfied === true || object(root.wait).satisfied === true;
902
+ if (root.satisfied === true || object(root.wait).satisfied === true) return { satisfied: true };
903
+ const reason = root.blockedReason ?? object(root.wait).blockedReason;
904
+ return typeof reason === "string" ? { satisfied: false, blockedReason: reason.slice(0, 100) } : { satisfied: false };
827
905
  }
828
906
 
829
907
  /** Wait only on the captured terminal and only for Orca's supported TUI-idle condition. */
@@ -832,8 +910,8 @@ export async function waitForIdle(binding: TerminalBinding, timeoutMs: number, o
832
910
  const timeout = Number.isFinite(timeoutMs) && timeoutMs >= 0 ? Math.min(Math.floor(timeoutMs), MAX_WAIT_MS) : 0;
833
911
  try {
834
912
  await showBinding(binding, config);
835
- const satisfied = await waitSatisfied(config.runner, binding.handle, timeout);
836
- const blockers = satisfied ? [] : [blocker("terminal-unready", `terminal ${binding.handle} did not become idle before the bounded wait`, binding.peer, binding.handle)];
913
+ const { satisfied, blockedReason } = await waitSatisfied(config.runner, binding.handle, timeout);
914
+ const blockers = satisfied ? [] : [blocker("terminal-unready", `terminal ${binding.handle} did not become idle before the bounded wait${blockedReason ? ` (Orca: blocked by ${blockedReason})` : ""}`, binding.peer, binding.handle)];
837
915
  return { satisfied, manualRequired: !satisfied, blockers };
838
916
  } catch (error) {
839
917
  const item = error instanceof OrcaCommandError ? error.blocker : blocker("command-error", String(error), binding.peer, binding.handle);
@@ -1,3 +1,4 @@
1
+ import { globalCliUpdateStep } from "./install-paths.ts";
1
2
  import { definitionOf } from "../adapters/registry.ts";
2
3
  import { renderPlan, renderOperation, renderBlocked, renderSummary, renderStep, renderWait, renderMenu, choiceInstruction, type RecoveryPresentationOptions, type MenuPresentationItem } from "./upgrade-presentation.ts";
3
4
  import { quoted, wrap } from "./console-state.ts";
@@ -5,6 +6,7 @@ import { operationLines, stepLabel } from "./upgrade-labels.ts";
5
6
  export { operationLines, stepLabel } from "./upgrade-labels.ts";
6
7
  import { recoveryArgv } from "../hub/recovery-store.ts";
7
8
  import { cancellableWait, nextChoices, publicOperation, type Inspection, type PlannedProject, type RecoveryOperation, type RecoveryPeer, type UpgradePlan } from "./upgrade.ts";
9
+ import { shellQuote, type OrphanedTerminal } from "./terminal-recovery.ts";
8
10
 
9
11
  /**
10
12
  * #272: the interactive screens of `ahub upgrade`, `ahub restart` and bare `ahub recovery`. They add no way to change
@@ -36,6 +38,11 @@ export interface UpgradeHost {
36
38
  apply(plan: UpgradePlan, interrupted: () => boolean): Promise<RecoveryOperation>;
37
39
  /** End one attached agent now: close a TUI agent's terminal, or ask the hub to stop a headless one. One line of outcome. */
38
40
  endPeer(planned: PlannedProject, peer: RecoveryPeer): Promise<string>;
41
+ /**
42
+ * #1246: switch back exactly these verified orphaned-but-connected panes of one project and wait, bounded, until each reads
43
+ * not orphaned. One line of outcome each. Optional: a host without it never offers the key.
44
+ */
45
+ reattachTerminals?(planned: PlannedProject, terminals: readonly OrphanedTerminal[]): Promise<string[]>;
39
46
  /** This selected project's hold, or an authoritative retained legacy machine hold. A reset may name another project. */
40
47
  lock(projectRoot?: string): string | undefined;
41
48
  read(id: string): RecoveryOperation;
@@ -106,12 +113,14 @@ function peerAction(planned: PlannedProject, peer: RecoveryPeer): string {
106
113
  if (peer.state === "offline") return "offline, left as it is";
107
114
  if (planned.unrecordedTerminals?.includes(peer.id)) return planned.reconnectOnly?.includes(peer.id)
108
115
  ? "attached, launcher unrecorded; reconnects by itself (its terminal is left alone)"
109
- : "attached, launcher unrecorded; end it in its terminal or resume the original conversation, then refresh the plan";
116
+ : "attached, launcher unrecorded; end it in its terminal or resume the original conversation, then press [r] refresh";
110
117
  if (planned.reconnectOnly?.includes(peer.id)) return "reconnects by itself (unmanaged session; its terminal is left alone)";
111
118
  if (planned.freshStart?.includes(peer.id)) return "restarts as a new session (no turn to lose)";
112
119
  const terminal = (planned.terminals as { peer: string; handle: string }[]).find((t) => t.peer === peer.id);
113
120
  if (terminal) return `resumes its session in a new terminal (replaces ${terminal.handle})`;
114
- if (peerKind(planned, peer) === "unmanaged") return "attached, terminal ownership unverified; resolve its evidence or end it in its terminal or resume the original conversation, then refresh the plan";
121
+ const orphan = planned.orphanedTerminals?.find((t) => t.peer === peer.id);
122
+ if (orphan) return `attached, terminal ${orphan.handle} is orphaned but still connected; reattach it with orca terminal switch --terminal ${shellQuote(orphan.handle)} (the upgrade menu's [t] does it), then press [r] refresh`;
123
+ if (peerKind(planned, peer) === "unmanaged") return "attached, terminal ownership unverified; resolve its evidence or end it in its terminal or resume the original conversation, then press [r] refresh";
115
124
  return definitionOf(peer.id)?.session.restoreOnStart ? "restarts headless on its recorded session" : "restarts headless as a new session";
116
125
  }
117
126
 
@@ -335,15 +344,18 @@ export async function planScreen(host: UpgradeHost, io: ScreenIO): Promise<void>
335
344
  if (io.presentation) emit(io, renderPlan(plan, host.version, io.presentation, planLines(plan, host.version)));
336
345
  else for (const line of planLines(plan, host.version)) human(io, line);
337
346
  const blocked = plan.blockers.length > 0 || plan.projects.some((p) => p.blockers.length > 0);
338
- if (!plan.projects.length) human(io, plan.kind === "upgrade" && plan.scope === undefined ? `no hub is running, so nothing is carried over: install with \`bun add -g @staix/agent-hub@${plan.version}\`, then \`ahub setup\`` : "this project's hub is not running: `ahub up` starts it");
347
+ if (!plan.projects.length) human(io, plan.kind === "upgrade" && plan.scope === undefined ? `no hub is running, so nothing is carried over: ${globalCliUpdateStep(plan.version)}, then \`ahub setup\`` : "this project's hub is not running: `ahub up` starts it");
339
348
  const endable = plan.projects.flatMap((p) => p.source.peers.filter((peer) => ["tui", "headless"].includes(peerKind(p, peer))).map((peer) => ({ planned: p, peer })));
340
- const keys = [...(blocked ? [] : ["[a] apply"]), "[r] refresh", ...(endable.length ? ["[k] end agents"] : []), "[j] plan as JSON", ...(plan.projects.length ? ["[x] reset a project's hub"] : []), "[q] quit"];
349
+ // #1246: only the terminals the plan verified as orphaned but still connected, and only on a host that can switch them.
350
+ const orphaned = host.reattachTerminals ? plan.projects.flatMap((p) => (p.orphanedTerminals ?? []).map((terminal) => ({ planned: p, terminal }))) : [];
351
+ const keys = [...(blocked ? [] : ["[a] apply"]), "[r] refresh", ...(orphaned.length ? [`[t] reattach ${orphaned.length} orphaned terminal${orphaned.length === 1 ? "" : "s"}`] : []), ...(endable.length ? ["[k] end agents"] : []), "[j] plan as JSON", ...(plan.projects.length ? ["[x] reset a project's hub"] : []), "[q] quit"];
341
352
  const preferred = "r"; // Enter never closes terminals, stops a hub, resumes or reattaches implicitly.
342
353
  if (io.presentation) {
343
- if (blocked) human(io, "Blocked: take the next action each blocker names, then refresh.");
354
+ if (blocked) human(io, "Blocked: take the next action each blocker names, then press [r] refresh.");
344
355
  const items: MenuPresentationItem[] = [
345
356
  ...(!blocked ? [{ key: "a", label: "apply this reviewed plan" }] : []),
346
357
  { key: "r", label: "refresh the plan", recommended: preferred === "r" },
358
+ ...(orphaned.length ? [{ key: "t", label: `reattach ${orphaned.length} orphaned terminal${orphaned.length === 1 ? "" : "s"} (orca terminal switch; nothing else changes)` }] : []),
347
359
  ...(endable.length ? [{ key: "k", label: "end selected agents before upgrading", destructive: true }] : []),
348
360
  { key: "j", label: "plan as JSON" },
349
361
  ...(plan.projects.length ? [{ key: "x", label: "reset a project's hub", destructive: true }] : []),
@@ -351,12 +363,23 @@ export async function planScreen(host: UpgradeHost, io: ScreenIO): Promise<void>
351
363
  ];
352
364
  emit(io, renderMenu(items, io.presentation));
353
365
  }
354
- const entered = (await ask(io, io.presentation ? "> " : `${blocked ? "Blocked: take the next action each blocker names, then refresh.\n" : ""}${keys.join(" ")}: `))?.toLowerCase();
366
+ const entered = (await ask(io, io.presentation ? "> " : `${blocked ? "Blocked: take the next action each blocker names, then press [r] refresh.\n" : ""}${keys.join(" ")}: `))?.toLowerCase();
355
367
  const answer = entered === undefined ? "q" : io.presentation && entered === "" ? preferred : entered;
356
368
  if (answer === "q") return touched ? undefined : human(io, "nothing was changed");
357
369
  if (answer === "j") io.out(JSON.stringify(plan, null, 2));
358
370
  else if (answer === "x" && plan.projects.length) { touched = true; await resetFlow(host, io, plan.projects.map((p) => p.project)); }
359
371
  else if (answer === "k" && endable.length) touched = (await endAgents(host, io, endable)) || touched;
372
+ else if (answer === "t" && orphaned.length) {
373
+ touched = true;
374
+ for (const p of plan.projects) {
375
+ const mine = (p.orphanedTerminals ?? []);
376
+ if (!mine.length) continue;
377
+ if (io.interrupted()) { human(io, "interrupted: no further terminal was switched"); break; }
378
+ try { for (const line of await host.reattachTerminals!(p, mine)) human(io, ` ${line}`); }
379
+ catch (error) { human(io, ` ${p.project.id}: not reattached (${(error as Error).message}). Next: inspect the terminal in Orca, then press [r] refresh`); }
380
+ }
381
+ // The loop plans again: a terminal that reads not orphaned is no blocker, one that does not stays one.
382
+ }
360
383
  else if (answer === "a" && !blocked) {
361
384
  touched = true;
362
385
  let op: RecoveryOperation;
@@ -1,5 +1,6 @@
1
+ import { globalCliUpdateStep } from "./install-paths.ts";
1
2
  // Human output for the commands #814 T3 converts: the non-interactive restart/upgrade plan, `recovery status`, and
2
- // `budget execution configure|disable`. Pure: data in, lines out. It only composes the renderers the interactive
3
+ // `budget execution configure|disable`. Recovery rendering is data in, lines out; CLI update advice inspects the install path. It composes the renderers the interactive
3
4
  // screens already use (`planLines`, `renderPlan`, `renderOperation`, `renderBlocked`, `choiceInstruction`), so the
4
5
  // non-interactive text never says something the screens would not. `--json` is the caller's branch and prints the
5
6
  // document exactly as before.
@@ -22,7 +23,7 @@ export function renderUpgradePlan(plan: UpgradePlan, current: string, options: R
22
23
  const out = renderPlan(plan, current, options, planLines(plan, current));
23
24
  const blocked = plan.blockers.length > 0 || plan.projects.some(project => project.blockers.length > 0);
24
25
  if (!plan.projects.length) out.push(...text(plan.kind === "upgrade"
25
- ? `No hub is running, so nothing is carried over: install with \`bun add -g @staix/agent-hub@${plan.version}\`, then \`ahub setup\`.`
26
+ ? `No hub is running, so nothing is carried over: ${globalCliUpdateStep(plan.version)}, then \`ahub setup\`.`
26
27
  : "This project's hub is not running: `ahub up` starts it.", "muted", options));
27
28
  out.push(...text(blocked
28
29
  ? "Blocked: take the next action each blocker names, then run this again. Nothing was changed."
@@ -17,7 +17,7 @@ import { awaitStopped, inspectProject } from "../hub/lifecycle.ts";
17
17
  import type { Project } from "../hub/registry.ts";
18
18
  import { canonicalPath, hubHome, realPath } from "../hub/project.ts";
19
19
  import { packageDigest, preserveRuntimePackage, preserveRuntimeSource, stageCurrentRuntime, registryRelease, runCommand, stageRelease, verifyPackage, type RunCommand } from "./recovery-package.ts";
20
- import { inspectSavedTerminal, inspectTerminals, closeTerminal, createTerminal, launcherOf, recordPath, waitForIdle, shellQuote, type SessionRef, type TerminalBinding, type TerminalRecoveryOptions } from "./terminal-recovery.ts";
20
+ import { inspectSavedTerminal, inspectTerminals, closeTerminal, createTerminal, launcherOf, recordPath, reattachOrphanedTerminal, REATTACH_BOUND_MS, waitForIdle, shellQuote, type OrphanedTerminal, type SessionRef, type TerminalBinding, type TerminalRecoveryOptions } from "./terminal-recovery.ts";
21
21
  import { ReattachmentRequired, MissingRecordedSession, FinalRefusal, planFingerprint, recoveryScope, registeredProjects, targetReadsWaivers, type Inspection, type PlannedProject, type ProjectProgress, type RecoveryDriver, type RecoveryOperation, type RecoveryPeer, type UpgradePlan } from "./upgrade.ts";
22
22
  import { SHARED_CHANNEL_VERSION_ARGV, nativeRecordedSessionState } from "../adapters/definitions/native-recovery.ts";
23
23
  import { refreshManager } from "../hub/manager.ts";
@@ -65,9 +65,9 @@ function legacyTargetLists(root: string, probe: { file: string; exportName: stri
65
65
  return !!values && new RegExp(`["']${name}["']`).test(values);
66
66
  }
67
67
 
68
- function terminalOptions(run: RunCommand = runCommand): TerminalRecoveryOptions {
68
+ function terminalOptions(run: RunCommand = runCommand, timeoutMs = 610_000): TerminalRecoveryOptions {
69
69
  return { orcaExecutable: orcaExecutable(), runner: async (args) => {
70
- const result = await run([orcaExecutable(), ...args], { timeoutMs: 610_000 });
70
+ const result = await run([orcaExecutable(), ...args], { timeoutMs });
71
71
  return { status: result.code, stdout: result.stdout, stderr: result.stderr };
72
72
  } };
73
73
  }
@@ -420,12 +420,42 @@ export async function makeUpgradePlan(kind: "restart" | "upgrade", version: stri
420
420
  ? `${unresumable(binding)}; manual-required; next action: make that store readable, then make a new plan`
421
421
  : `${unresumable(binding)}, and the hub cannot show that no turn ran on it, so its conversation may not come back after the restart; manual-required; next action: end that Codex session and close its Orca terminal ${binding.handle} (ahub codex starts a new one later), then make a new plan`);
422
422
  }
423
- body.projects.push({ project, source, terminals: terminals.bindings, blockers, ...(reconnectOnly.length ? { reconnectOnly } : {}), ...(unrecordedTerminals.length ? { unrecordedTerminals } : {}), ...(freshStart.length ? { freshStart } : {}) });
423
+ body.projects.push({ project, source, terminals: terminals.bindings, blockers, ...(reconnectOnly.length ? { reconnectOnly } : {}), ...(unrecordedTerminals.length ? { unrecordedTerminals } : {}), ...(freshStart.length ? { freshStart } : {}), ...(terminals.orphaned?.length ? { orphanedTerminals: terminals.orphaned } : {}) });
424
424
  }
425
425
  if (!body.projects.length) body.blockers.push("no running registered projects in scope");
426
426
  return { ...body, fingerprint: planFingerprint(body) };
427
427
  }
428
428
 
429
+ /** #1246: one line of outcome per `reattachOrphanedTerminal` result. */
430
+ const REATTACH_LINES: Record<Awaited<ReturnType<typeof reattachOrphanedTerminal>>, (name: string, handle: string) => string> = {
431
+ reattached: (name, handle) => `${name}: terminal ${handle} reattached`,
432
+ "still-orphaned": (name, handle) => `${name}: terminal ${handle} is still orphaned after the switch; it stays a blocker. Next: inspect it in Orca, then press [r] refresh`,
433
+ unavailable: (name, handle) => `${name}: terminal ${handle} not switched or not confirmed (its identity changed, it is not connected, or Orca did not answer). Next: inspect it in Orca, then press [r] refresh`,
434
+ };
435
+
436
+ /**
437
+ * #1246: switch back the orphaned-but-connected panes the plan verified, and nothing else. Each handle is read again by
438
+ * `reattachOrphanedTerminal` (identity, still orphaned and connected) before `orca terminal switch`, then polled for at
439
+ * most 5 s, and every Orca call is itself bounded by that time. A recovery hold on this project (or the machine) refuses all of them. One line of outcome per terminal; the caller plans again.
440
+ */
441
+ export async function reattachPlannedTerminals(planned: PlannedProject, terminals: readonly OrphanedTerminal[], deps: { run?: RunCommand; home?: string; lock?: (projectRoot: string) => string | undefined; boundMs?: number } = {}): Promise<string[]> {
442
+ const { run = runCommand, home = hubHome(), boundMs = REATTACH_BOUND_MS } = deps;
443
+ // The project's own hold (project_locks) as well as a legacy machine hold: recoveryLock without a root reads only the latter.
444
+ const lock = deps.lock ?? ((projectRoot: string) => recoveryLock(home, projectRoot));
445
+ const verified = terminals.filter((t) => planned.orphanedTerminals?.some((o) => o.handle === t.handle && o.incarnationId === t.incarnationId && o.worktreeId === t.worktreeId && o.root === t.root && o.peer === t.peer));
446
+ const lines: string[] = [];
447
+ for (const terminal of terminals) {
448
+ const name = `${planned.project.id}/${terminal.peer}`;
449
+ if (!verified.includes(terminal)) { lines.push(`${name}: terminal ${terminal.handle} is not one the plan verified; not switched. Next: press [r] refresh to re-plan`); continue; }
450
+ let owner: string | undefined;
451
+ try { owner = lock(planned.project.root); } catch (error) { lines.push(`${name}: terminal ${terminal.handle} not switched (${(error as Error).message}). Next: check the recovery state, then press [r] refresh`); continue; }
452
+ if (owner) { lines.push(`${name}: terminal ${terminal.handle} not switched (recovery operation ${owner} holds the lock). Next: wait for or resume that operation, then press [r] refresh`); continue; }
453
+ const result = await reattachOrphanedTerminal(terminal, terminalOptions(run, boundMs), boundMs);
454
+ lines.push(REATTACH_LINES[result](name, terminal.handle));
455
+ }
456
+ return lines;
457
+ }
458
+
429
459
  /** Freeze the coordinator/source package before its mutable global installation is replaced. */
430
460
  export function preserveSource(plan: UpgradePlan, home = hubHome()): string {
431
461
  if (plan.projects?.length === 0) throw new Error("no running projects to recover");
@@ -15,7 +15,7 @@ import { hubHome } from "../hub/project.ts";
15
15
  import { readRecoveryWaits } from "./recovery-waits.ts";
16
16
  import { acquireRecoveryLock, claimRunner, coordinatorCurrent, operationIdPattern, readOperation, recoveryCommand, recoveryLock, releaseRecoveryLock, writeOperation } from "../hub/recovery-store.ts";
17
17
  import { resolveAcceptedBinding, piArchiveExit, recordedPeerGuidance, type RecoveryNativePeer } from "./recovery-guidance.ts";
18
- import type { TerminalBinding } from "./terminal-recovery.ts";
18
+ import type { OrphanedTerminal, TerminalBinding } from "./terminal-recovery.ts";
19
19
  import { RecoveryPresentationError, type RecoveryPresentationCause, type RecoveryWaitTiming } from "./upgrade-presentation-data.ts";
20
20
 
21
21
  const projectDefinition = (peer: string, root?: string) => {
@@ -65,6 +65,8 @@ export interface PlannedProject {
65
65
  unrecordedTerminals?: string[];
66
66
  /** #215: Codex threads with no rollout and no turn the hub recorded; they restart as a new session, nothing to lose. */
67
67
  freshStart?: string[];
68
+ /** #1246: verified terminals that read orphaned but still connected; the plan menu may switch exactly these. Part of the fingerprint. */
69
+ orphanedTerminals?: OrphanedTerminal[];
68
70
  }
69
71
  export interface UpgradePlan {
70
72
  schema: 1;