@staix/agent-hub 0.32.0 → 0.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +33 -0
- package/README.md +2 -2
- package/docs/design/bus.md +68 -2
- package/docs/dogfood.md +3 -1
- package/docs/memory.md +9 -3
- package/docs/operations.md +124 -20
- package/docs/quickstart.md +32 -2
- package/docs/security.md +28 -9
- package/docs/specs/2026-09-19-agent-hub-design.md +36 -6
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +7 -3
- package/docs/verification/2026-11-06-0.33.0.md +222 -0
- package/docs/verification/2026-11-06-claude-implementer-2.1.319.md +57 -0
- package/docs/verification/2026-11-06-codex-0167-reviewer-isolation.md +56 -0
- package/docs/verification/2026-11-06-codex-0167-reviewer-tool-call.md +66 -0
- package/docs/verification/2026-11-06-kimi-251-reviewer-compat.md +88 -0
- package/docs/verification/2026-11-06-orca-1.4.250-cli-contract.md +50 -0
- package/docs/verification/2026-11-06-qwen-0268-acp.md +75 -0
- package/docs/verification/2026-11-06-qwen-0268-dual-output.md +44 -0
- package/docs/verification/2026-11-07-0.34.0.md +71 -0
- package/docs/verification/2026-11-07-compat-receipt-audit-1248.md +256 -0
- package/docs/verification/claude-shell-wrapper-native.py +1 -1
- package/docs/verified.json +11 -11
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +528 -206
- package/src/adapters/compat.ts +11 -11
- package/src/adapters/definitions/native-cli-claude-setup.ts +14 -5
- package/src/broker/protocol.ts +5 -0
- package/src/broker/server.ts +54 -4
- package/src/broker/store.ts +6 -3
- 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/output.ts +5 -1
- package/src/cli/room.ts +17 -5
- package/src/cli/runtime-version-warning.ts +8 -6
- package/src/cli/setup-homes.ts +13 -10
- package/src/cli/status-lines.ts +8 -1
- package/src/cli/terminal-recovery.ts +96 -18
- 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/bus.ts +7 -0
- package/src/hub/daemon.ts +13 -4
- package/src/hub/handoff.ts +1 -1
- package/src/rooms/application.ts +15 -0
- package/src/rooms/commands.ts +76 -16
- package/src/rooms/health.ts +32 -0
- package/src/rooms/link.ts +267 -43
- package/src/rooms/remote-peer.ts +18 -3
- package/src/rooms/service.ts +27 -12
- package/src/rooms/status.ts +57 -1
- package/src/rooms/store.ts +20 -1
- package/src/rooms/wire.ts +10 -8
- package/src/ui/index.html +1 -1
package/src/cli/room.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { projectRoomUnavailable, projectRoomDiagnostics, type RoomStatusDiagnostics } from "../rooms/status.ts";
|
|
1
2
|
import { decodeBase64url } from "../rooms/crypto.ts";
|
|
2
3
|
import { roomReceiptReasonText, type RoomReceiptReason } from "../rooms/application.ts";
|
|
3
4
|
import { frame, type Envelope } from "../hub/envelope.ts";
|
|
@@ -17,8 +18,9 @@ export type RoomRequest = (message: Record<string, unknown>) => Promise<any>;
|
|
|
17
18
|
export interface RoomConfirmationIO { isTTY(): boolean; answer(prompt: string): Promise<string> }
|
|
18
19
|
|
|
19
20
|
export class RoomCliError extends Error {
|
|
20
|
-
constructor(readonly code: "usage" | "refused" | "invalid_reply" | "inbox_list_limit" | "confirmation_required" | "not_confirmed" | "confirmation_mismatch" | "epoch_rotated_hold_unavailable") {
|
|
21
|
-
const
|
|
21
|
+
constructor(readonly code: "usage" | "refused" | "invalid_reply" | "inbox_list_limit" | "confirmation_required" | "not_confirmed" | "confirmation_mismatch" | "epoch_rotated_hold_unavailable" | "rooms_unavailable", availability?: unknown) {
|
|
22
|
+
const unavailable = projectRoomUnavailable(availability);
|
|
23
|
+
const message = { rooms_unavailable: `unavailable (${unavailable?.code ?? "unknown"}); ${unavailable?.nextStep ?? "Run ahub status to inspect room storage before retrying."}`, refused: "operation refused", invalid_reply: "invalid control reply", inbox_list_limit: "inbox list exceeds the limit; choose a room name", confirmation_required: "confirmation required; use a TTY or --confirm with the member fingerprint prefix", not_confirmed: "not confirmed; nothing executed", confirmation_mismatch: "confirmation does not match the member fingerprint; nothing executed",
|
|
22
24
|
epoch_rotated_hold_unavailable: "epoch rotated but delivery journal holds are unavailable; inspect ahub room status and ahub queue show before another mutation" };
|
|
23
25
|
super(code === "usage" ? ROOM_USAGE : `room: ${message[code]}`);
|
|
24
26
|
this.name = "RoomCliError";
|
|
@@ -145,7 +147,7 @@ type DeliveryState = "pending" | "dispatching" | "accepted" | "completed" | "com
|
|
|
145
147
|
interface DeliveryRow { direction: "inbound" | "outbound"; id: string; application: string; peer: string; state: DeliveryState; disposition?: "accept" | "completed" | "retry" | "discard"; lateReceipt?: "completed (admitted)" | "completed (inbox)" | "failed_safe"; refusalReason?: RoomReceiptReason; holdReason?: "epoch rotated" }
|
|
146
148
|
interface DeliverySummary { direction: "inbound"; pending: number; needsReview: number; truncated: boolean }
|
|
147
149
|
interface RemotePeerRow { id: string; state: "idle" | "busy" | "paused" | "offline"; presence?: "idle" | "busy" | "paused" | "offline" | "unknown"; liveness?: "untracked" }
|
|
148
|
-
interface RoomRow { name: string; room: string; owner: string; member: string; epoch: string; state: string; deliveries?: (DeliveryRow | DeliverySummary)[]; remotePeers?: RemotePeerRow[] }
|
|
150
|
+
interface RoomRow extends RoomStatusDiagnostics { name: string; room: string; owner: string; member: string; epoch: string; state: string; deliveries?: (DeliveryRow | DeliverySummary)[]; remotePeers?: RemotePeerRow[] }
|
|
149
151
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
150
152
|
const REMOTE_ID = /^[a-z][a-z0-9-]{0,31}@[a-z][a-z0-9-]{0,31}$/;
|
|
151
153
|
const REFUSAL_REASONS: readonly RoomReceiptReason[] = ["admitted", "not_exposed", "boundary_refused", "invalid_message", "rate_limited", "hop", "person_inbox_unsupported", "inbox_admitted", "inbox_full", "inbox_oversize", "inbox_expired", "inbox_age_unavailable"];
|
|
@@ -194,7 +196,10 @@ function record(value: unknown): Record<string, unknown> {
|
|
|
194
196
|
}
|
|
195
197
|
function success(value: unknown): Record<string, unknown> {
|
|
196
198
|
const row = record(value);
|
|
197
|
-
if (row.ok !== true)
|
|
199
|
+
if (row.ok !== true) {
|
|
200
|
+
if (row.code === "rooms_unavailable") throw new RoomCliError("rooms_unavailable", row.roomsUnavailable);
|
|
201
|
+
fail(row.code === "epoch_rotated_hold_unavailable" ? "epoch_rotated_hold_unavailable" : "refused");
|
|
202
|
+
}
|
|
198
203
|
return row;
|
|
199
204
|
}
|
|
200
205
|
function roomRows(value: unknown): RoomRow[] {
|
|
@@ -206,7 +211,7 @@ function roomRows(value: unknown): RoomRow[] {
|
|
|
206
211
|
|| typeof row.epoch !== "string" || !/^(0|[1-9][0-9]{0,19})$/.test(row.epoch)
|
|
207
212
|
|| typeof row.state !== "string" || !/^[a-z][a-z_-]{0,31}$/.test(row.state)) return fail("invalid_reply");
|
|
208
213
|
// Ignore all other fields, even from a newer daemon: read output never grows into room bodies or secrets.
|
|
209
|
-
return { name: row.name, room: row.room, owner: row.owner, member: row.member, epoch: row.epoch, state: row.state,
|
|
214
|
+
return { name: row.name, room: row.room, owner: row.owner, member: row.member, epoch: row.epoch, state: row.state, ...projectRoomDiagnostics(row),
|
|
210
215
|
...(row.deliveries !== undefined ? { deliveries: deliveryRows(row.deliveries) } : {}), ...(row.remotePeers !== undefined ? { remotePeers: remotePeerRows(row.remotePeers) } : {}) };
|
|
211
216
|
});
|
|
212
217
|
}
|
|
@@ -274,6 +279,13 @@ export async function runRoomCommand(args: readonly string[], request: RoomReque
|
|
|
274
279
|
console.log(`Rooms (${rows.length})`);
|
|
275
280
|
for (const row of rows) {
|
|
276
281
|
console.log(`${row.name} ${row.room} epoch ${row.epoch} ${row.state}\n owner ${row.owner}\n member ${row.member}`);
|
|
282
|
+
if (command.op === "status") {
|
|
283
|
+
const health = row.health, age = (value: number | null | undefined) => typeof value === "number" ? `${value}ms` : "unknown";
|
|
284
|
+
if (row.state === "online" && (row.linkCause === null || row.linkCause === undefined)) console.log(" link online");
|
|
285
|
+
else console.log(` link cause ${row.linkCause ?? "unknown"}; next: ${row.linkNextStep ?? "Check broker availability, then read ahub room status again."}`);
|
|
286
|
+
console.log(` outbox ${health?.outboxDepth ?? "unknown"}; oldest ${age(health?.oldestOutboxAgeMs)}; last receipt ${age(health?.lastReceiptAgeMs)}`);
|
|
287
|
+
console.log(` refused: ${health?.refused ? Object.entries(health.refused).map(([reason, count]) => `${reason} ${count}`).join(", ") : "unknown"}`);
|
|
288
|
+
}
|
|
277
289
|
for (const peer of row.remotePeers ?? []) console.log(` peer ${peer.id}: ${peer.presence ?? peer.state}${peer.liveness === "untracked" ? "; liveness not tracked (broker without ephemeral); upgrade the broker to track liveness" : ""}`);
|
|
278
290
|
for (const delivery of row.deliveries ?? []) {
|
|
279
291
|
if (!("id" in delivery)) console.log(` inbound: pending ${delivery.pending}, needs_review ${delivery.needsReview}${delivery.truncated ? "; truncated" : ""}`);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { globalCliUpdateStep, type InstallPathOptions } from "./install-paths.ts";
|
|
1
2
|
import type { DoctorCheck } from "./output.ts";
|
|
2
3
|
import { parseList, pluginState, type InstalledPlugin } from "./setup.ts";
|
|
3
4
|
|
|
@@ -5,7 +6,7 @@ const VERSION = /^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?(
|
|
|
5
6
|
const version = (value: unknown): string | undefined => typeof value === "string" && value.length <= 64 && VERSION.exec(value)?.[0] === value ? value : undefined;
|
|
6
7
|
|
|
7
8
|
/** Authenticated daemon status and the existing plugin inventory only. Suggestions never run an update. */
|
|
8
|
-
export function runtimeVersionWarnings(daemonVersion: unknown, cliVersion: unknown, pluginVersion: unknown): DoctorCheck[] {
|
|
9
|
+
export function runtimeVersionWarnings(daemonVersion: unknown, cliVersion: unknown, pluginVersion: unknown, install: InstallPathOptions = {}): DoctorCheck[] {
|
|
9
10
|
const running = version(daemonVersion), cli = version(cliVersion), plugin = version(pluginVersion);
|
|
10
11
|
const rows: DoctorCheck[] = [];
|
|
11
12
|
const unknown = (name: string, detail: string) => rows.push({ section: "Hub", level: "unknown", name, detail });
|
|
@@ -18,7 +19,7 @@ export function runtimeVersionWarnings(daemonVersion: unknown, cliVersion: unkno
|
|
|
18
19
|
else try {
|
|
19
20
|
cliOlder = Bun.semver.order(cli, running) < 0;
|
|
20
21
|
if (cliOlder) rows.push({ section: "Hub", level: "warn", name: "CLI versus daemon",
|
|
21
|
-
detail: `running daemon ${running} is newer than this CLI ${cli}; update separately:
|
|
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
|
}
|
package/src/cli/status-lines.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { projectRoomUnavailable } from "../rooms/status.ts";
|
|
1
2
|
import type { ContextView } from "../hub/context-window.ts";
|
|
2
3
|
import { memorySyncNextStep, type MemorySyncCause } from "../hub/memory-sync-state.ts";
|
|
3
4
|
// What `ahub status` prints for a peer and for a model backend, kept pure so it can be checked.
|
|
@@ -35,7 +36,7 @@ export function memorySyncLine(sync: MemorySyncRow, time = (at: number) => new D
|
|
|
35
36
|
const cause = (causes.includes(sync.cause ?? "") ? sync.cause : "unknown") as MemorySyncCause;
|
|
36
37
|
if (cause === "declined" && sync.placementActive === false) {
|
|
37
38
|
const declined = Number.isSafeInteger(sync.declinedCount) && sync.declinedCount! >= 0 ? String(sync.declinedCount) : "unknown";
|
|
38
|
-
return "machine sync blocked: " + declined + " declined rows; per-row placement is not active on this store
|
|
39
|
+
return "machine sync blocked: " + declined + " declined rows; per-row placement is not active on this store. Activation is person-only: preview with `amem migrate --sync-placement --dry-run` and declare every writer via --writer-root. The hub does not yet supply the fresh placementAttestation required for active-placement sync (#1173). Inspect with `ahub memory declined`.";
|
|
39
40
|
}
|
|
40
41
|
const count = Number.isSafeInteger(sync.declinedCount) && sync.declinedCount! >= 0 ? `, ${sync.declinedCount} declined` : "";
|
|
41
42
|
const ids = cause === "declined" && sync.declinedIdsKnown === false ? "; declined ID set unknown" : "";
|
|
@@ -55,3 +56,9 @@ export function reviewInputHealthLine(health: ReviewInputHealthRow): string | un
|
|
|
55
56
|
const state = health.state === "incomplete" || health.state === "corrupt" ? health.state : "unknown";
|
|
56
57
|
return `${state} (${cause}); next: ${next}`;
|
|
57
58
|
}
|
|
59
|
+
|
|
60
|
+
/** Room failures carry a closed cause and locally generated next step only. */
|
|
61
|
+
export function roomsUnavailableLine(value: unknown): string {
|
|
62
|
+
const unavailable = projectRoomUnavailable(value);
|
|
63
|
+
return `rooms: unavailable (${unavailable?.code ?? "unknown"}); next: ${unavailable?.nextStep ?? "Run ahub room status to inspect room storage and broker availability."}`;
|
|
64
|
+
}
|
|
@@ -100,6 +100,18 @@ export interface TerminalInspection {
|
|
|
100
100
|
byPeer: Partial<Record<TerminalPeer, TerminalBinding>>;
|
|
101
101
|
blockers: RecoveryBlocker[];
|
|
102
102
|
manualRequired: boolean;
|
|
103
|
+
/** #1246: terminals whose identity was verified (handle, incarnation, worktree, root, peer) and that read orphaned but still connected: the only ones a person may have switched back. */
|
|
104
|
+
orphaned?: OrphanedTerminal[];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** #1246: one verified orphaned-but-connected terminal, as the plan records it. Never a session or a launch. */
|
|
108
|
+
export interface OrphanedTerminal {
|
|
109
|
+
peer: TerminalPeer;
|
|
110
|
+
handle: string;
|
|
111
|
+
incarnationId: string;
|
|
112
|
+
worktreeId: string;
|
|
113
|
+
/** The terminal's worktree root as Orca reported it and the plan matched it. */
|
|
114
|
+
root: string;
|
|
103
115
|
}
|
|
104
116
|
|
|
105
117
|
export interface IdleResult {
|
|
@@ -206,17 +218,32 @@ function decoded(result: CommandResult): unknown {
|
|
|
206
218
|
}
|
|
207
219
|
}
|
|
208
220
|
|
|
221
|
+
/** Orca's structured error, from the JSON it prints on stdout or stderr. */
|
|
222
|
+
function orcaError(result: CommandResult): Record<string, unknown> {
|
|
223
|
+
const fromStdout = object(object(decoded(result)).error);
|
|
224
|
+
if (fromStdout.code !== undefined) return fromStdout;
|
|
225
|
+
try { return object(object(JSON.parse(bytes(result.stderr))).error); } catch { return {}; }
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function errorCode(result: CommandResult): unknown {
|
|
229
|
+
return orcaError(result).code;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** #1233: Orca prints its error as JSON on stdout with an empty stderr, so its code comes before the exit-status fallback. */
|
|
209
233
|
function errorText(result: CommandResult): string {
|
|
234
|
+
const { code, message } = orcaError(result);
|
|
235
|
+
if (typeof code === "string") return typeof message === "string" && message !== code ? `${code}: ${message.slice(0, 200)}` : code;
|
|
210
236
|
const stderr = bytes(result.stderr).trim();
|
|
211
237
|
return stderr || `exit status ${commandStatus(result)}`;
|
|
212
238
|
}
|
|
213
239
|
|
|
214
|
-
/**
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
240
|
+
/**
|
|
241
|
+
* Orca 1.4.250 answers a terminal it can no longer reach with one of these codes, and its own CLI groups all four as
|
|
242
|
+
* gone (docs/verification/2026-11-06-orca-1.4.250-cli-contract.md). A wait on a pane whose process ended throws
|
|
243
|
+
* terminal_exited, so restoration must read it as gone and check the launcher, not stop on a command error (#1233).
|
|
244
|
+
*/
|
|
245
|
+
const GONE_CODES = new Set(["terminal_handle_stale", "terminal_exited", "terminal_gone", "no_connected_pty"]);
|
|
246
|
+
const GONE_STATES: Record<string, string> = { terminal_handle_stale: "no longer exists", terminal_exited: "exited (terminal_exited)" };
|
|
220
247
|
|
|
221
248
|
function runnerFailure(argv: readonly string[], result: CommandResult): RecoveryBlocker {
|
|
222
249
|
return { code: "command-error", message: `Orca ${argv.slice(0, 2).join(" ")} failed: ${errorText(result)}` };
|
|
@@ -226,9 +253,11 @@ async function run(runner: CommandRunner, argv: readonly string[]): Promise<unkn
|
|
|
226
253
|
const result = await runner(argv);
|
|
227
254
|
if (commandStatus(result) !== 0) {
|
|
228
255
|
// Orca 1.4.223 and, rechecked read-only, 1.4.245 (docs/verification/2026-11-02-orca-1.4.245-cli-contract.md) answer a handle whose terminal was removed with `{"ok":false,"error":{"code":"terminal_handle_stale"}}`.
|
|
229
|
-
|
|
256
|
+
const code = errorCode(result);
|
|
257
|
+
if (typeof code === "string" && GONE_CODES.has(code)) {
|
|
230
258
|
const handle = argv.includes("--terminal") ? argv[argv.indexOf("--terminal") + 1] : undefined;
|
|
231
|
-
|
|
259
|
+
const state = GONE_STATES[code] ?? `is gone (${code})`;
|
|
260
|
+
throw new OrcaCommandError({ code: "terminal-gone", message: `the Orca terminal${handle ? ` ${handle}` : ""} ${state}` });
|
|
232
261
|
}
|
|
233
262
|
throw new OrcaCommandError(runnerFailure(argv, result));
|
|
234
263
|
}
|
|
@@ -236,7 +265,9 @@ async function run(runner: CommandRunner, argv: readonly string[]): Promise<unkn
|
|
|
236
265
|
const root = object(value);
|
|
237
266
|
const payload = responseResult(value);
|
|
238
267
|
if (root.ok === false || payload.ok === false) {
|
|
239
|
-
|
|
268
|
+
const error = root.error ?? payload.error;
|
|
269
|
+
const detail = typeof error === "string" ? error : object(error).code;
|
|
270
|
+
throw new OrcaCommandError({ code: "command-error", message: `Orca ${argv.slice(0, 2).join(" ")} returned ok:false${typeof detail === "string" && detail ? `: ${detail}` : ""}` });
|
|
240
271
|
}
|
|
241
272
|
return value;
|
|
242
273
|
}
|
|
@@ -633,7 +664,7 @@ function blocker(code: RecoveryBlocker["code"], message: string, peer?: Terminal
|
|
|
633
664
|
code === "ownership-unknown" ? "confirm the terminal owner in Orca, then resume recovery" :
|
|
634
665
|
code === "ambiguous-terminal" ? "close or identify the duplicate terminal, then resume recovery" :
|
|
635
666
|
code === "ambiguous-create" ? "inspect the existing terminal and attach the original session before resuming" :
|
|
636
|
-
code === "terminal-orphaned" ? `reattach the pane with orca terminal switch --terminal ${shellQuote(handle!)}; then refresh the
|
|
667
|
+
code === "terminal-orphaned" ? `reattach the pane with orca terminal switch --terminal ${shellQuote(handle!)}; then refresh in ahub upgrade ([r] on the plan, [s] on an open operation), or rerun it` :
|
|
637
668
|
code === "terminal-unready" ? "finish or cancel the active terminal turn, then resume recovery" :
|
|
638
669
|
code === "launcher-exited" ? "the session was not restored; the coordinator's error names the choices" :
|
|
639
670
|
code === "terminal-gone" ? "the terminal was closed; inspect the project's terminals in Orca before resuming recovery" :
|
|
@@ -641,6 +672,48 @@ function blocker(code: RecoveryBlocker["code"], message: string, peer?: Terminal
|
|
|
641
672
|
return { code, message, ...(peer ? { peer } : {}), ...(handle ? { handle, terminalReference: handle } : {}), nextAction };
|
|
642
673
|
}
|
|
643
674
|
|
|
675
|
+
/** #1246: how long a switched pane is re-read before it still counts as orphaned. Orca's `terminal show` can lag a switch by a moment. */
|
|
676
|
+
export const REATTACH_BOUND_MS = 5_000;
|
|
677
|
+
const REATTACH_POLL_MS = 250;
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* #1246: the one mutation the upgrade menu offers for an orphaned-but-connected pane: `orca terminal switch` for exactly
|
|
681
|
+
* this handle, after reading it again and finding the identity the plan verified (handle, incarnation, worktree, root)
|
|
682
|
+
* still orphaned and connected. It then re-reads the pane until `orphaned` is false, at most `boundMs`. A pane that is
|
|
683
|
+
* already not orphaned is `reattached` with no switch. A changed identity, a disconnected pane or any failed Orca call is
|
|
684
|
+
* `unavailable` (nothing, or nothing more, was done); a pane still orphaned at the bound stays `still-orphaned`.
|
|
685
|
+
*/
|
|
686
|
+
export async function reattachOrphanedTerminal(terminal: OrphanedTerminal, options?: CommandRunner | TerminalRecoveryOptions, boundMs = REATTACH_BOUND_MS): Promise<"reattached" | "still-orphaned" | "unavailable"> {
|
|
687
|
+
const config = normalizeOptions(options);
|
|
688
|
+
const read = async (): Promise<Record<string, unknown> | undefined> => {
|
|
689
|
+
const shown = terminalObject(await run(config.runner, ["terminal", "show", "--terminal", terminal.handle, "--json"]));
|
|
690
|
+
const same = nestedString(shown, ["handle"]) === terminal.handle && nestedString(shown, ["incarnationId"]) === terminal.incarnationId && nestedString(shown, ["worktreeId"]) === terminal.worktreeId
|
|
691
|
+
&& rootMatches(shown.worktreePath ?? shown.projectRoot, terminal.root);
|
|
692
|
+
return same ? shown : undefined;
|
|
693
|
+
};
|
|
694
|
+
// The runner is also bounded by its caller; this race keeps a runner that ignores its timeout from holding the screen past the bound.
|
|
695
|
+
const within = async <T>(work: Promise<T>, ms: number): Promise<T> => {
|
|
696
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
697
|
+
try { return await Promise.race([work, new Promise<never>((_, reject) => { timer = setTimeout(() => reject(new Error("reattach bound exceeded")), ms); })]); }
|
|
698
|
+
finally { if (timer) clearTimeout(timer); }
|
|
699
|
+
};
|
|
700
|
+
try {
|
|
701
|
+
const before = await within(read(), boundMs);
|
|
702
|
+
if (!before) return "unavailable";
|
|
703
|
+
if (before.orphaned === false) return "reattached";
|
|
704
|
+
if (before.orphaned !== true || before.connected !== true) return "unavailable";
|
|
705
|
+
await within(run(config.runner, ["terminal", "switch", "--terminal", terminal.handle, "--json"]), boundMs);
|
|
706
|
+
const deadline = Date.now() + boundMs;
|
|
707
|
+
for (;;) {
|
|
708
|
+
const shown = await within(read(), deadline - Date.now());
|
|
709
|
+
if (!shown) return "unavailable";
|
|
710
|
+
if (shown.orphaned === false) return "reattached";
|
|
711
|
+
if (Date.now() >= deadline) return "still-orphaned";
|
|
712
|
+
await Bun.sleep(Math.min(REATTACH_POLL_MS, deadline - Date.now()));
|
|
713
|
+
}
|
|
714
|
+
} catch { return "unavailable"; }
|
|
715
|
+
}
|
|
716
|
+
|
|
644
717
|
function verifyBindingData(actual: Record<string, unknown>, binding: TerminalBinding): RecoveryBlocker | undefined {
|
|
645
718
|
const handle = nestedString(actual, ["handle"]);
|
|
646
719
|
const incarnation = nestedString(actual, ["incarnationId"]);
|
|
@@ -714,6 +787,7 @@ export async function inspectTerminals(projectRoot: string, sessions: Partial<Re
|
|
|
714
787
|
const config = normalizeOptions(options);
|
|
715
788
|
const blockers: RecoveryBlocker[] = [];
|
|
716
789
|
const bindings: TerminalBinding[] = [];
|
|
790
|
+
const orphaned: OrphanedTerminal[] = [];
|
|
717
791
|
const byPeer: Partial<Record<TerminalPeer, TerminalBinding>> = {};
|
|
718
792
|
const requested = (Object.entries(sessions) as [TerminalPeer, string | SessionRef | undefined][]).map(([peer, value]) => [peer, typeof value === "string" ? { sessionId: value } : value] as const).filter((entry): entry is [TerminalPeer, SessionRef] => !!entry[1]?.sessionId);
|
|
719
793
|
if (!requested.length) return { bindings, byPeer, blockers, manualRequired: false };
|
|
@@ -795,7 +869,8 @@ export async function inspectTerminals(projectRoot: string, sessions: Partial<Re
|
|
|
795
869
|
continue;
|
|
796
870
|
}
|
|
797
871
|
if (terminal.orphaned === true && terminal.connected === true) {
|
|
798
|
-
blockers.push(blocker("terminal-orphaned", `terminal ${handle} is orphaned but still connected`, peer, handle));
|
|
872
|
+
blockers.push(blocker("terminal-orphaned", `the ${peer} terminal ${handle} in ${projectRoot} is orphaned but still connected`, peer, handle));
|
|
873
|
+
orphaned.push({ peer, handle, incarnationId, worktreeId, root: expectedTerminalRoot });
|
|
799
874
|
continue;
|
|
800
875
|
}
|
|
801
876
|
if (terminal.orphaned === true || terminal.connected === false) {
|
|
@@ -811,19 +886,22 @@ export async function inspectTerminals(projectRoot: string, sessions: Partial<Re
|
|
|
811
886
|
bindings.push(binding);
|
|
812
887
|
byPeer[peer] = binding;
|
|
813
888
|
}
|
|
814
|
-
return { bindings, byPeer, blockers, manualRequired: blockers.length > 0 };
|
|
889
|
+
return { bindings, byPeer, blockers, manualRequired: blockers.length > 0, ...(orphaned.length ? { orphaned } : {}) };
|
|
815
890
|
}
|
|
816
891
|
|
|
817
892
|
/**
|
|
818
893
|
* One bounded Orca wait. Orca 1.4.223 and, rechecked read-only, 1.4.245 (docs/verification/2026-11-02-orca-1.4.245-cli-contract.md) answer a wait that timed out with exit 1 and `{"ok":false,"error":{"code":"timeout"}}`:
|
|
819
|
-
* that is "not satisfied", not a failure. Any other failure still throws.
|
|
894
|
+
* that is "not satisfied", not a failure. Any other failure still throws. 1.4.250 also answers ok with `satisfied: false`
|
|
895
|
+
* and a `blockedReason` (seen: `agent-update-prompt`) when the TUI waits on a prompt; the reason is passed on (#1233).
|
|
820
896
|
*/
|
|
821
|
-
async function waitSatisfied(runner: CommandRunner, handle: string, timeoutMs: number): Promise<boolean> {
|
|
897
|
+
async function waitSatisfied(runner: CommandRunner, handle: string, timeoutMs: number): Promise<{ satisfied: boolean; blockedReason?: string }> {
|
|
822
898
|
const argv = ["terminal", "wait", "--terminal", handle, "--for", "tui-idle", "--timeout-ms", String(timeoutMs), "--json"];
|
|
823
899
|
const result = await runner(argv);
|
|
824
|
-
if (commandStatus(result) !== 0 && errorCode(result) === "timeout") return false;
|
|
900
|
+
if (commandStatus(result) !== 0 && errorCode(result) === "timeout") return { satisfied: false };
|
|
825
901
|
const root = responseResult(await run(async () => result, argv));
|
|
826
|
-
|
|
902
|
+
if (root.satisfied === true || object(root.wait).satisfied === true) return { satisfied: true };
|
|
903
|
+
const reason = root.blockedReason ?? object(root.wait).blockedReason;
|
|
904
|
+
return typeof reason === "string" ? { satisfied: false, blockedReason: reason.slice(0, 100) } : { satisfied: false };
|
|
827
905
|
}
|
|
828
906
|
|
|
829
907
|
/** Wait only on the captured terminal and only for Orca's supported TUI-idle condition. */
|
|
@@ -832,8 +910,8 @@ export async function waitForIdle(binding: TerminalBinding, timeoutMs: number, o
|
|
|
832
910
|
const timeout = Number.isFinite(timeoutMs) && timeoutMs >= 0 ? Math.min(Math.floor(timeoutMs), MAX_WAIT_MS) : 0;
|
|
833
911
|
try {
|
|
834
912
|
await showBinding(binding, config);
|
|
835
|
-
const satisfied = await waitSatisfied(config.runner, binding.handle, timeout);
|
|
836
|
-
const blockers = satisfied ? [] : [blocker("terminal-unready", `terminal ${binding.handle} did not become idle before the bounded wait`, binding.peer, binding.handle)];
|
|
913
|
+
const { satisfied, blockedReason } = await waitSatisfied(config.runner, binding.handle, timeout);
|
|
914
|
+
const blockers = satisfied ? [] : [blocker("terminal-unready", `terminal ${binding.handle} did not become idle before the bounded wait${blockedReason ? ` (Orca: blocked by ${blockedReason})` : ""}`, binding.peer, binding.handle)];
|
|
837
915
|
return { satisfied, manualRequired: !satisfied, blockers };
|
|
838
916
|
} catch (error) {
|
|
839
917
|
const item = error instanceof OrcaCommandError ? error.blocker : blocker("command-error", String(error), binding.peer, binding.handle);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { globalCliUpdateStep } from "./install-paths.ts";
|
|
1
2
|
import { definitionOf } from "../adapters/registry.ts";
|
|
2
3
|
import { renderPlan, renderOperation, renderBlocked, renderSummary, renderStep, renderWait, renderMenu, choiceInstruction, type RecoveryPresentationOptions, type MenuPresentationItem } from "./upgrade-presentation.ts";
|
|
3
4
|
import { quoted, wrap } from "./console-state.ts";
|
|
@@ -5,6 +6,7 @@ import { operationLines, stepLabel } from "./upgrade-labels.ts";
|
|
|
5
6
|
export { operationLines, stepLabel } from "./upgrade-labels.ts";
|
|
6
7
|
import { recoveryArgv } from "../hub/recovery-store.ts";
|
|
7
8
|
import { cancellableWait, nextChoices, publicOperation, type Inspection, type PlannedProject, type RecoveryOperation, type RecoveryPeer, type UpgradePlan } from "./upgrade.ts";
|
|
9
|
+
import { shellQuote, type OrphanedTerminal } from "./terminal-recovery.ts";
|
|
8
10
|
|
|
9
11
|
/**
|
|
10
12
|
* #272: the interactive screens of `ahub upgrade`, `ahub restart` and bare `ahub recovery`. They add no way to change
|
|
@@ -36,6 +38,11 @@ export interface UpgradeHost {
|
|
|
36
38
|
apply(plan: UpgradePlan, interrupted: () => boolean): Promise<RecoveryOperation>;
|
|
37
39
|
/** End one attached agent now: close a TUI agent's terminal, or ask the hub to stop a headless one. One line of outcome. */
|
|
38
40
|
endPeer(planned: PlannedProject, peer: RecoveryPeer): Promise<string>;
|
|
41
|
+
/**
|
|
42
|
+
* #1246: switch back exactly these verified orphaned-but-connected panes of one project and wait, bounded, until each reads
|
|
43
|
+
* not orphaned. One line of outcome each. Optional: a host without it never offers the key.
|
|
44
|
+
*/
|
|
45
|
+
reattachTerminals?(planned: PlannedProject, terminals: readonly OrphanedTerminal[]): Promise<string[]>;
|
|
39
46
|
/** This selected project's hold, or an authoritative retained legacy machine hold. A reset may name another project. */
|
|
40
47
|
lock(projectRoot?: string): string | undefined;
|
|
41
48
|
read(id: string): RecoveryOperation;
|
|
@@ -106,12 +113,14 @@ function peerAction(planned: PlannedProject, peer: RecoveryPeer): string {
|
|
|
106
113
|
if (peer.state === "offline") return "offline, left as it is";
|
|
107
114
|
if (planned.unrecordedTerminals?.includes(peer.id)) return planned.reconnectOnly?.includes(peer.id)
|
|
108
115
|
? "attached, launcher unrecorded; reconnects by itself (its terminal is left alone)"
|
|
109
|
-
: "attached, launcher unrecorded; end it in its terminal or resume the original conversation, then
|
|
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;
|