@staix/agent-hub 0.33.0 → 0.34.1

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.
@@ -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);
@@ -212,24 +212,32 @@ export function stageCurrentRuntime(sourceRoot: string, version: string, home =
212
212
  } finally { if (!registry) owner.close(); }
213
213
  }
214
214
 
215
+ /** #1253: npm 12 wraps `npm view --json` output for one package in a one-element array; npm 11 printed the value itself. Anything else is not one release. */
216
+ function npmViewJson(stdout: string): unknown {
217
+ let value: unknown;
218
+ try { value = JSON.parse(stdout); } catch { return undefined; }
219
+ if (!Array.isArray(value)) return value;
220
+ return value.length === 1 ? value[0] : undefined;
221
+ }
222
+
215
223
  export async function registryRelease(version: string, run: RunCommand = runCommand): Promise<{ version: string; integrity: string }> {
216
224
  exactVersion(version);
217
225
  const result = await run(["npm", "view", `@staix/agent-hub@${version}`, "version", "dist.integrity", "--json"], { timeoutMs: 30_000 });
218
226
  if (result.code !== 0) throw new Error(`registry metadata unavailable for @staix/agent-hub@${version}`);
219
- const data = JSON.parse(result.stdout);
220
- if (data.version !== version || typeof data["dist.integrity"] !== "string" || !data["dist.integrity"].startsWith("sha512-")) {
221
- throw new Error("registry returned an unexpected release identity");
227
+ const data = npmViewJson(result.stdout) as Record<string, unknown> | undefined;
228
+ const integrity = data?.["dist.integrity"];
229
+ if (data?.version !== version || typeof integrity !== "string" || !integrity.startsWith("sha512-")) {
230
+ throw new Error(`registry returned an unexpected release identity for @staix/agent-hub@${version} (expected one release with a sha512 integrity from \`npm view --json\`); check \`npm view @staix/agent-hub@${version} version dist.integrity --json\` and your npm version`);
222
231
  }
223
- return { version, integrity: data["dist.integrity"] };
232
+ return { version, integrity };
224
233
  }
225
234
 
226
235
  /** #272: the registry's latest release, the target when `--to` is left out. */
227
236
  export async function latestRelease(run: RunCommand = runCommand): Promise<string> {
228
237
  const result = await run(["npm", "view", "@staix/agent-hub", "version", "--json"], { timeoutMs: 30_000 });
229
238
  if (result.code !== 0) throw new Error("registry metadata unavailable for @staix/agent-hub; name the release with --to <version>");
230
- let version: unknown;
231
- try { version = JSON.parse(result.stdout); } catch { /* reported below */ }
232
- if (typeof version !== "string") throw new Error("registry returned an unexpected latest release; name the release with --to <version>");
239
+ const version = npmViewJson(result.stdout);
240
+ if (typeof version !== "string") throw new Error("registry returned an unexpected latest release (expected one version from `npm view --json`); check `npm view @staix/agent-hub version --json`, or name the release with --to <version>");
233
241
  return exactVersion(version);
234
242
  }
235
243
 
@@ -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
  }
@@ -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 {
@@ -652,7 +664,7 @@ function blocker(code: RecoveryBlocker["code"], message: string, peer?: Terminal
652
664
  code === "ownership-unknown" ? "confirm the terminal owner in Orca, then resume recovery" :
653
665
  code === "ambiguous-terminal" ? "close or identify the duplicate terminal, then resume recovery" :
654
666
  code === "ambiguous-create" ? "inspect the existing terminal and attach the original session before resuming" :
655
- 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` :
656
668
  code === "terminal-unready" ? "finish or cancel the active terminal turn, then resume recovery" :
657
669
  code === "launcher-exited" ? "the session was not restored; the coordinator's error names the choices" :
658
670
  code === "terminal-gone" ? "the terminal was closed; inspect the project's terminals in Orca before resuming recovery" :
@@ -660,6 +672,48 @@ function blocker(code: RecoveryBlocker["code"], message: string, peer?: Terminal
660
672
  return { code, message, ...(peer ? { peer } : {}), ...(handle ? { handle, terminalReference: handle } : {}), nextAction };
661
673
  }
662
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
+
663
717
  function verifyBindingData(actual: Record<string, unknown>, binding: TerminalBinding): RecoveryBlocker | undefined {
664
718
  const handle = nestedString(actual, ["handle"]);
665
719
  const incarnation = nestedString(actual, ["incarnationId"]);
@@ -733,6 +787,7 @@ export async function inspectTerminals(projectRoot: string, sessions: Partial<Re
733
787
  const config = normalizeOptions(options);
734
788
  const blockers: RecoveryBlocker[] = [];
735
789
  const bindings: TerminalBinding[] = [];
790
+ const orphaned: OrphanedTerminal[] = [];
736
791
  const byPeer: Partial<Record<TerminalPeer, TerminalBinding>> = {};
737
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);
738
793
  if (!requested.length) return { bindings, byPeer, blockers, manualRequired: false };
@@ -814,7 +869,8 @@ export async function inspectTerminals(projectRoot: string, sessions: Partial<Re
814
869
  continue;
815
870
  }
816
871
  if (terminal.orphaned === true && terminal.connected === true) {
817
- 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 });
818
874
  continue;
819
875
  }
820
876
  if (terminal.orphaned === true || terminal.connected === false) {
@@ -830,7 +886,7 @@ export async function inspectTerminals(projectRoot: string, sessions: Partial<Re
830
886
  bindings.push(binding);
831
887
  byPeer[peer] = binding;
832
888
  }
833
- return { bindings, byPeer, blockers, manualRequired: blockers.length > 0 };
889
+ return { bindings, byPeer, blockers, manualRequired: blockers.length > 0, ...(orphaned.length ? { orphaned } : {}) };
834
890
  }
835
891
 
836
892
  /**
@@ -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;
@@ -130,7 +130,7 @@ export function resolveTarget(name: string, projects: readonly RegisteredProject
130
130
  export function handoffConnectFailure(error: unknown, target: { id: string; root: string }): { code: HandoffRefusalCode; reach: "unreachable"; cause: string; message: string } {
131
131
  const code = typeof (error as { code?: unknown })?.code === "number" || typeof (error as { code?: unknown })?.code === "string" ? (error as { code: number | string }).code : undefined;
132
132
  if (code === 4426) return { code: "older_target", reach: "unreachable", cause: "wire version differs",
133
- message: `the hub of ${target.id} speaks another wire version than this one: upgrade whichever is older (\`ahub upgrade\` in ${target.root}, and the global CLI with \`bun add -g @staix/agent-hub\`), then hand the task off again` };
133
+ message: `the hub of ${target.id} speaks another wire version than this one: upgrade whichever is older (\`ahub upgrade\` in ${target.root}, and the global CLI the way it was installed; \`ahub doctor\` names the exact step), then hand the task off again` };
134
134
  if (code === 4404) return { code: "mismatch", reach: "unreachable", cause: "another hub or instance answered",
135
135
  message: `the hub answering for ${target.root} is not the instance this project's registry names (it was restarted, or another project took its port): run \`ahub projects\` to see what is registered, and \`ahub up\` in ${target.root} if it is not running` };
136
136
  if (code === "AHUB_CONNECT_TIMEOUT") return { code: "not_running", reach: "unreachable", cause: "no answer in time",