@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.
- package/CHANGELOG.md +17 -0
- package/README.md +2 -2
- package/docs/dogfood.md +2 -1
- package/docs/operations.md +80 -9
- package/docs/quickstart.md +26 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +5 -2
- package/docs/verification/2026-11-07-0.34.0.md +71 -0
- package/docs/verification/2026-11-07-0.34.1.md +54 -0
- package/docs/verification/2026-11-07-compat-receipt-audit-1248.md +256 -0
- package/docs/verified.json +9 -9
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +1 -1
- package/src/adapters/definitions/native-cli-claude-setup.ts +14 -5
- package/src/cli/autonomy-homes.ts +12 -3
- package/src/cli/claude-homes.ts +5 -5
- package/src/cli/claude-profile-hook.ts +38 -9
- package/src/cli/install-paths.ts +71 -0
- package/src/cli/main.ts +20 -1
- package/src/cli/recovery-package.ts +15 -7
- package/src/cli/runtime-version-warning.ts +8 -6
- package/src/cli/setup-homes.ts +13 -10
- package/src/cli/terminal-recovery.ts +59 -3
- package/src/cli/upgrade-interactive.ts +29 -6
- package/src/cli/upgrade-output.ts +3 -2
- package/src/cli/upgrade-runtime.ts +34 -4
- package/src/cli/upgrade.ts +3 -1
- package/src/hub/handoff.ts +1 -1
|
@@ -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 =
|
|
220
|
-
|
|
221
|
-
|
|
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
|
|
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
|
-
|
|
231
|
-
|
|
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:
|
|
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}):
|
|
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
|
}
|
package/src/cli/setup-homes.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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`.
|
|
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:
|
|
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
|
|
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");
|
package/src/cli/upgrade.ts
CHANGED
|
@@ -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;
|
package/src/hub/handoff.ts
CHANGED
|
@@ -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
|
|
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",
|