@staix/agent-hub 0.12.33 → 0.12.35
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 +22 -0
- package/README.md +3 -3
- package/docs/agent-notes/adapters.md +9 -3
- package/docs/agent-notes/budget.md +7 -3
- package/docs/agent-notes/bus.md +2 -1
- package/docs/agent-notes/daemon.md +7 -3
- package/docs/agent-notes/local-worker.md +1 -1
- package/docs/agent-notes/tasks.md +6 -2
- package/docs/agent-notes/tests.md +2 -1
- package/docs/gsd-collaboration-analysis.md +5 -1
- package/docs/operations.md +80 -16
- package/docs/quickstart.md +10 -2
- package/docs/sdlc.md +59 -34
- package/docs/security.md +7 -4
- package/docs/smoke.md +1 -1
- package/docs/specs/2026-09-19-agent-hub-design.md +407 -10
- package/docs/verification/2026-10-18-0.12.34.md +48 -0
- package/docs/verification/2026-10-18-0.12.35.md +49 -0
- package/docs/verification/2026-10-18-493-claude-busy-channel.md +91 -0
- package/docs/verified.json +35 -19
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +23567 -16606
- package/src/adapters/acp.ts +3 -1
- package/src/adapters/claude-channel.ts +59 -29
- package/src/adapters/codex-appserver.ts +82 -12
- package/src/adapters/definitions/builtin-metadata.ts +101 -7
- package/src/adapters/definitions/native-cli-claude-launch.ts +90 -0
- package/src/adapters/definitions/native-cli-claude-setup.ts +80 -0
- package/src/adapters/definitions/native-cli-codex-launch.ts +14 -0
- package/src/adapters/definitions/native-cli-doctor.ts +46 -0
- package/src/adapters/definitions/native-cli-entry-claude.ts +52 -0
- package/src/adapters/definitions/native-cli-entry-codex.ts +34 -0
- package/src/adapters/definitions/native-cli-entry-jcode.ts +27 -0
- package/src/adapters/definitions/native-cli-entry-kimi.ts +57 -0
- package/src/adapters/definitions/native-cli-entry-local.ts +15 -0
- package/src/adapters/definitions/native-cli-entry-pi.ts +53 -0
- package/src/adapters/definitions/native-cli-entry-qwen.ts +21 -0
- package/src/adapters/definitions/native-cli-entry-types.ts +41 -0
- package/src/adapters/definitions/native-cli-entry.ts +19 -0
- package/src/adapters/definitions/native-cli-jcode-mcp.ts +58 -0
- package/src/adapters/definitions/native-cli-launch-common.ts +17 -0
- package/src/adapters/definitions/native-cli-pi.ts +33 -0
- package/src/adapters/definitions/native-cli-preview-claude.ts +37 -0
- package/src/adapters/definitions/native-cli-preview-codex.ts +20 -0
- package/src/adapters/definitions/native-cli-preview-common.ts +57 -0
- package/src/adapters/definitions/native-cli-preview-jcode.ts +20 -0
- package/src/adapters/definitions/native-cli-preview-kimi.ts +28 -0
- package/src/adapters/definitions/native-cli-preview-pi.ts +36 -0
- package/src/adapters/definitions/native-cli-preview-qwen.ts +22 -0
- package/src/adapters/definitions/native-cli-preview.ts +16 -0
- package/src/adapters/definitions/native-config-defaults.ts +19 -0
- package/src/adapters/definitions/native-config-policy.ts +35 -0
- package/src/adapters/definitions/native-context.ts +35 -0
- package/src/adapters/definitions/native-daemon-kimi.ts +5 -0
- package/src/adapters/definitions/native-daemon-policy.ts +18 -0
- package/src/adapters/definitions/native-display.ts +18 -0
- package/src/adapters/definitions/native-factories.ts +48 -0
- package/src/adapters/definitions/native-policy-aux.ts +130 -0
- package/src/adapters/definitions/native-quota.ts +29 -0
- package/src/adapters/definitions/native-recovery-continuity.ts +12 -0
- package/src/adapters/definitions/native-recovery.ts +88 -0
- package/src/adapters/definitions/qwen-native.ts +2 -2
- package/src/adapters/definitions/qwen.ts +8 -4
- package/src/adapters/local-worker.ts +3 -2
- package/src/adapters/pi.ts +3 -1
- package/src/adapters/registry-types.ts +100 -10
- package/src/adapters/registry.ts +127 -11
- package/src/cli/console-state.ts +5 -5
- package/src/cli/identity.ts +8 -7
- package/src/cli/jcode-mcp.ts +2 -58
- package/src/cli/launch.ts +41 -102
- package/src/cli/main.ts +126 -290
- package/src/cli/output.ts +3 -2
- package/src/cli/preview.ts +26 -126
- package/src/cli/recovery-guidance.ts +43 -22
- package/src/cli/setup.ts +2 -80
- package/src/cli/shell-quote.ts +3 -0
- package/src/cli/terminal-recovery.ts +81 -22
- package/src/cli/upgrade-interactive.ts +4 -2
- package/src/cli/upgrade-restore-state.ts +17 -7
- package/src/cli/upgrade-runtime.ts +126 -147
- package/src/cli/upgrade.ts +44 -27
- package/src/hub/budget.ts +59 -31
- package/src/hub/bus.ts +1 -1
- package/src/hub/conductor.ts +19 -9
- package/src/hub/config-apply.ts +22 -15
- package/src/hub/config-reload.ts +18 -3
- package/src/hub/config-trust.ts +1 -1
- package/src/hub/context-window.ts +3 -27
- package/src/hub/control-client.ts +1 -1
- package/src/hub/crash.ts +15 -39
- package/src/hub/daemon.ts +506 -341
- package/src/hub/execution-budget.ts +3 -2
- package/src/hub/hub-tools.ts +19 -8
- package/src/hub/instance-budget.ts +73 -0
- package/src/hub/instance-config.ts +47 -0
- package/src/hub/instance-execution.ts +50 -0
- package/src/hub/instances.ts +100 -0
- package/src/hub/lifecycle-schema.ts +7 -3
- package/src/hub/peer-catalog.ts +4 -13
- package/src/hub/permission-mode.ts +14 -17
- package/src/hub/recovery-continuity.ts +6 -5
- package/src/hub/report.ts +9 -8
- package/src/hub/roster.ts +9 -9
- package/src/hub/routing.ts +94 -19
- package/src/hub/sdlc-queue.ts +2 -2
- package/src/hub/sdlc.ts +84 -38
- package/src/hub/settings.ts +39 -10
- package/src/hub/settled-roles.ts +1 -1
- package/src/hub/snapshots.ts +40 -9
- package/src/hub/start-mode.ts +13 -8
- package/src/hub/status-roster.ts +8 -0
- package/src/hub/tasks.ts +60 -27
- package/src/hub/text-projection.ts +7 -3
- package/src/hub/tooling.ts +4 -60
- package/src/hub/ui.ts +3 -1
- package/src/hub/workflow.ts +30 -52
- package/src/memory/recall.ts +4 -5
- package/src/models/route/signals.ts +3 -1
- package/src/pi/launch.ts +5 -3
- package/src/ui/index.html +4 -3
- package/src/ui/office-model.ts +3 -1
- package/src/ui/peer-palette.ts +16 -6
- package/templates/AGENTS.block.md +2 -1
- package/templates/routing.toml +2 -2
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
import { shellQuote } from "./shell-quote.ts";
|
|
2
|
+
export { shellQuote } from "./shell-quote.ts";
|
|
3
|
+
import { definitionOf, type KindMap } from "../adapters/registry.ts";
|
|
4
|
+
import { readInstanceConfig, readInstanceRoleConfig } from "../hub/instance-config.ts";
|
|
5
|
+
import { effectiveInstanceRoles } from "../hub/instances.ts";
|
|
6
|
+
import { resolveInstanceExecutionCwd } from "../hub/instance-execution.ts";
|
|
1
7
|
import { isAbsolute, join, resolve } from "node:path";
|
|
2
8
|
import { fileURLToPath } from "node:url";
|
|
3
9
|
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
@@ -25,7 +31,7 @@ export type CommandRunner = (argv: readonly string[]) => Promise<CommandResult>;
|
|
|
25
31
|
/** Stable process identity hook. It returns a digest, never raw process metadata. */
|
|
26
32
|
export type ProcessIdentity = (pid: number) => string | undefined | Promise<string | undefined>;
|
|
27
33
|
|
|
28
|
-
export type TerminalPeer =
|
|
34
|
+
export type TerminalPeer = string;
|
|
29
35
|
export interface SessionRef { sessionId: string; sessionFile?: string; backend?: string; model?: string; }
|
|
30
36
|
|
|
31
37
|
export interface LaunchMetadata {
|
|
@@ -45,6 +51,10 @@ export interface TerminalBinding {
|
|
|
45
51
|
incarnationId: string;
|
|
46
52
|
worktreeId: string;
|
|
47
53
|
projectRoot: string;
|
|
54
|
+
/** Authenticated declared-extra execution root; canonical projectRoot remains the authority. */
|
|
55
|
+
executionCwd?: string;
|
|
56
|
+
/** Verified Orca grouping root, only canonical project or the authorized execution worktree. */
|
|
57
|
+
terminalRoot?: string;
|
|
48
58
|
sessionId: string;
|
|
49
59
|
sessionFile?: string;
|
|
50
60
|
backend?: string;
|
|
@@ -126,6 +136,8 @@ export interface TerminalCreateResult {
|
|
|
126
136
|
|
|
127
137
|
export interface TerminalRecoveryOptions {
|
|
128
138
|
runner?: CommandRunner;
|
|
139
|
+
/** Exact execution root returned by authenticated extra startup; checked against the current declaration. */
|
|
140
|
+
executionCwd?: string;
|
|
129
141
|
/** Defaults to `orca`; callers in a managed session may pin another executable. */
|
|
130
142
|
orcaExecutable?: string;
|
|
131
143
|
/** Absolute path to this package's JS entrypoint. */
|
|
@@ -147,6 +159,8 @@ export interface TerminalRecoveryOptions {
|
|
|
147
159
|
export interface RecordedTerminalLaunch {
|
|
148
160
|
peer: TerminalPeer;
|
|
149
161
|
projectRoot: string;
|
|
162
|
+
executionCwd?: string;
|
|
163
|
+
terminalRoot?: string;
|
|
150
164
|
stateDir: string;
|
|
151
165
|
instanceId: string;
|
|
152
166
|
launcherPid: number;
|
|
@@ -231,9 +245,7 @@ export class OrcaCommandError extends Error {
|
|
|
231
245
|
}
|
|
232
246
|
|
|
233
247
|
/** POSIX quoting for the single command-string argument accepted by Orca. */
|
|
234
|
-
|
|
235
|
-
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
236
|
-
}
|
|
248
|
+
|
|
237
249
|
|
|
238
250
|
function normalizeOptions(options?: CommandRunner | TerminalRecoveryOptions): Required<TerminalRecoveryOptions> {
|
|
239
251
|
const config: TerminalRecoveryOptions = typeof options === "function" ? { runner: options } : options ?? {};
|
|
@@ -242,6 +254,7 @@ function normalizeOptions(options?: CommandRunner | TerminalRecoveryOptions): Re
|
|
|
242
254
|
runner: config.runner ?? defaultRunner(orcaExecutable),
|
|
243
255
|
orcaExecutable,
|
|
244
256
|
packageEntrypoint: config.packageEntrypoint ?? defaultEntrypoint,
|
|
257
|
+
executionCwd: config.executionCwd ?? "",
|
|
245
258
|
stateDir: config.stateDir ?? "",
|
|
246
259
|
instanceId: config.instanceId ?? "",
|
|
247
260
|
processIdentity: config.processIdentity ?? defaultProcessIdentity,
|
|
@@ -311,9 +324,30 @@ export function recordPath(stateDir: string): string {
|
|
|
311
324
|
return join(stateDir, RECORD_FILE);
|
|
312
325
|
}
|
|
313
326
|
|
|
327
|
+
/** Extra terminal ownership is supported only for the native proxy transport; other I2 extras stay headless. */
|
|
328
|
+
function executionRoot(peer: string, projectRoot: string, captured?: string): string {
|
|
329
|
+
const builtin = definitionOf(peer);
|
|
330
|
+
if (builtin?.session.launchRecord) return projectRoot;
|
|
331
|
+
const { instances: declarations, roles } = readInstanceRoleConfig(projectRoot);
|
|
332
|
+
const definition = definitionOf(peer, declarations);
|
|
333
|
+
if (!definition?.session.launchRecord) throw new Error(`${peer}: no managed terminal recovery adapter`);
|
|
334
|
+
if (definition.transport !== "codex-appserver" || !declarations?.[peer]) throw new Error(`${peer}: I2 extra instance has no managed terminal support`);
|
|
335
|
+
const expected = resolveInstanceExecutionCwd(projectRoot, peer, declarations, effectiveInstanceRoles(peer, declarations, roles));
|
|
336
|
+
if (captured !== undefined && captured !== expected) throw new Error(`${peer}: declared execution worktree changed; manual recovery required`);
|
|
337
|
+
return expected;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function savedTerminalRoot(binding: Pick<TerminalBinding, "peer" | "projectRoot" | "executionCwd" | "terminalRoot">): string {
|
|
341
|
+
const expected = executionRoot(binding.peer, binding.projectRoot, binding.executionCwd ?? binding.projectRoot);
|
|
342
|
+
if (definitionOf(binding.peer)) return binding.projectRoot;
|
|
343
|
+
const root = binding.terminalRoot ?? binding.projectRoot;
|
|
344
|
+
if (root !== binding.projectRoot && root !== expected) throw new Error(`${binding.peer}: recorded Orca grouping root is not authorized`);
|
|
345
|
+
return root;
|
|
346
|
+
}
|
|
347
|
+
|
|
314
348
|
function validRecord(item: unknown): item is RecordedTerminalLaunch {
|
|
315
349
|
const row = object(item);
|
|
316
|
-
return typeof row.peer === "string" && typeof row.projectRoot === "string" && typeof row.instanceId === "string" && typeof row.handle === "string" && typeof row.incarnationId === "string" && typeof row.worktreeId === "string" && typeof row.launcherPid === "number" && typeof row.launcherSignature === "string" && row.launcherSignature.length > 0 && typeof row.launchId === "string" && row.launchId.length > 0;
|
|
350
|
+
return typeof row.peer === "string" && typeof row.projectRoot === "string" && (row.executionCwd === undefined || typeof row.executionCwd === "string") && (row.terminalRoot === undefined || typeof row.terminalRoot === "string") && typeof row.instanceId === "string" && typeof row.handle === "string" && typeof row.incarnationId === "string" && typeof row.worktreeId === "string" && typeof row.launcherPid === "number" && typeof row.launcherSignature === "string" && row.launcherSignature.length > 0 && typeof row.launchId === "string" && row.launchId.length > 0;
|
|
317
351
|
}
|
|
318
352
|
|
|
319
353
|
/**
|
|
@@ -409,13 +443,15 @@ export async function recordTerminalLaunch(peer: TerminalPeer, projectRoot: stri
|
|
|
409
443
|
const handle = process.env.ORCA_TERMINAL_HANDLE;
|
|
410
444
|
const worktreeIdFromEnv = process.env.ORCA_WORKTREE_ID;
|
|
411
445
|
if (!handle || !worktreeIdFromEnv) return undefined;
|
|
446
|
+
if (!definitionOf(peer) && !config.executionCwd) throw new Error(`${peer}: recording an extra terminal requires its authenticated startup execution root`);
|
|
447
|
+
const expectedRoot = executionRoot(peer, projectRoot, config.executionCwd || undefined);
|
|
412
448
|
const value = await run(config.runner, ["terminal", "show", "--terminal", handle, "--json"]);
|
|
413
449
|
const terminal = terminalObject(value);
|
|
414
450
|
const actualRoot = nestedString(terminal, ["worktreePath", "projectRoot"]);
|
|
415
451
|
const actualHandle = nestedString(terminal, ["handle"]);
|
|
416
452
|
const actualWorktree = nestedString(terminal, ["worktreeId"]);
|
|
417
453
|
const incarnationId = nestedString(terminal, ["incarnationId"]);
|
|
418
|
-
if (actualHandle !== handle || !actualRoot || !rootMatches(actualRoot, projectRoot) || actualWorktree !== worktreeIdFromEnv || !incarnationId) {
|
|
454
|
+
if (actualHandle !== handle || !actualRoot || !(rootMatches(actualRoot, projectRoot) || !definitionOf(peer) && rootMatches(actualRoot, expectedRoot)) || actualWorktree !== worktreeIdFromEnv || !incarnationId) {
|
|
419
455
|
throw new OrcaCommandError(blocker("project-root-mismatch", "Orca launch terminal failed canonical root/worktree readback", peer, handle));
|
|
420
456
|
}
|
|
421
457
|
const launcherSignature = await config.processIdentity(process.pid);
|
|
@@ -423,7 +459,7 @@ export async function recordTerminalLaunch(peer: TerminalPeer, projectRoot: stri
|
|
|
423
459
|
const launchId = randomUUID();
|
|
424
460
|
process.env.AGENTHUB_LAUNCH_ID = launchId;
|
|
425
461
|
process.env.AGENTHUB_INSTANCE_ID = instanceId;
|
|
426
|
-
const row: RecordedTerminalLaunch = { peer, projectRoot, stateDir, instanceId, launcherPid: process.pid, launcherSignature, launchId, handle, incarnationId, worktreeId: actualWorktree, env: allowedEnv({ env: process.env }) };
|
|
462
|
+
const row: RecordedTerminalLaunch = { peer, projectRoot, ...(!definitionOf(peer) ? { executionCwd: expectedRoot, terminalRoot: actualRoot } : {}), stateDir, instanceId, launcherPid: process.pid, launcherSignature, launchId, handle, incarnationId, worktreeId: actualWorktree, env: allowedEnv({ env: process.env }) };
|
|
427
463
|
const existing = launchRecords(stateDir);
|
|
428
464
|
// #215: rewriting an unreadable file would replace every other launcher's record with this one, and recovery would
|
|
429
465
|
// then read those launchers as gone.
|
|
@@ -469,12 +505,12 @@ function sessionFrom(terminal: Record<string, unknown>): string | undefined {
|
|
|
469
505
|
return nestedString(terminal, ["sessionId", "agentSessionId", "resumeSessionId"]) ?? nestedString(wait, ["sessionId", "agentSessionId", "resumeSessionId", "session", "agentSession"]) ?? nestedString(session, ["id", "sessionId"]);
|
|
470
506
|
}
|
|
471
507
|
|
|
472
|
-
function identityFrom(terminal: Record<string, unknown
|
|
508
|
+
function identityFrom(terminal: Record<string, unknown>, instances?: KindMap): TerminalPeer | undefined {
|
|
473
509
|
const wait = object(terminal.agentWait);
|
|
474
510
|
const session = object(terminal.session);
|
|
475
511
|
const candidates = [terminal.agentIdentity, terminal.agent, wait.agentIdentity, wait.agent, session.agentIdentity, session.agent];
|
|
476
512
|
for (const candidate of candidates) {
|
|
477
|
-
if (
|
|
513
|
+
if (typeof candidate === "string" && definitionOf(candidate, instances)?.session.launchRecord) return candidate as TerminalPeer;
|
|
478
514
|
}
|
|
479
515
|
return undefined;
|
|
480
516
|
}
|
|
@@ -504,13 +540,19 @@ function requireAbsoluteEntrypoint(value: string): string {
|
|
|
504
540
|
|
|
505
541
|
function launchFor(peer: TerminalPeer, projectRoot: string, sessionId: string, options: Required<TerminalRecoveryOptions>, source: Record<string, unknown>, recorded?: RecordedTerminalLaunch, ref?: SessionRef): LaunchMetadata {
|
|
506
542
|
const packageEntrypoint = requireAbsoluteEntrypoint(options.packageEntrypoint);
|
|
507
|
-
const argv = peer === "codex"
|
|
508
|
-
? ["bun", packageEntrypoint, "--project", projectRoot, "codex", "resume", sessionId]
|
|
509
|
-
: peer === "claude"
|
|
510
|
-
? ["bun", packageEntrypoint, "--project", projectRoot, "claude", "--resume", sessionId]
|
|
511
|
-
: ["bun", packageEntrypoint, "--project", projectRoot, "pi", "--mode", "tui", ...(ref?.backend ? ["--backend", ref.backend] : []), ...(ref?.model ? ["--model", ref.model] : []), ...(ref?.sessionFile ? ["--session-file", ref.sessionFile] : ["--session-id", sessionId])];
|
|
512
543
|
const env = { ...allowedEnv(source), ...(recorded?.env ?? {}) };
|
|
513
544
|
const prefix = ALLOWED_ENV.filter((name) => env[name] !== undefined).map((name) => `${name}=${shellQuote(env[name]!)}`);
|
|
545
|
+
const launch: LaunchMetadata = { packageEntrypoint, argv: ["bun", packageEntrypoint, "--project", projectRoot], env, command: "" };
|
|
546
|
+
launch.command = [...prefix, ...launch.argv.map(shellQuote)].join(" ");
|
|
547
|
+
// Identity and environment were independently verified before this pure two-phase launch construction.
|
|
548
|
+
const binding: TerminalBinding = { peer, projectRoot, sessionId, handle: nestedString(source, ["handle"])!,
|
|
549
|
+
incarnationId: nestedString(source, ["incarnationId"])!, worktreeId: nestedString(source, ["worktreeId"])!,
|
|
550
|
+
...(ref?.sessionFile ? { sessionFile: ref.sessionFile } : {}), ...(ref?.backend ? { backend: ref.backend } : {}), ...(ref?.model ? { model: ref.model } : {}), launch, launchMetadata: launch };
|
|
551
|
+
const adapter = definitionOf(peer) ?? definitionOf(peer, readInstanceConfig(projectRoot).instances);
|
|
552
|
+
const nativeTail = adapter?.session.resumeArgv?.(binding, false);
|
|
553
|
+
const tail = nativeTail && adapter ? [nativeTail[0]!, ...(peer !== adapter.kind ? ["--as", peer] : []), ...nativeTail.slice(1)] : undefined;
|
|
554
|
+
if (!tail) throw new Error(`${peer}: no recorded terminal resume adapter`);
|
|
555
|
+
const argv = [...launch.argv, ...tail];
|
|
514
556
|
const command = [...prefix, ...argv.map(shellQuote)].join(" ");
|
|
515
557
|
return { packageEntrypoint, command, argv, env };
|
|
516
558
|
}
|
|
@@ -533,7 +575,10 @@ function verifyBindingData(actual: Record<string, unknown>, binding: TerminalBin
|
|
|
533
575
|
const root = nestedString(actual, ["worktreePath", "projectRoot"]);
|
|
534
576
|
const worktree = nestedString(actual, ["worktreeId"]);
|
|
535
577
|
if (handle !== binding.handle || incarnation !== binding.incarnationId || worktree !== binding.worktreeId) return blocker("stale-terminal", "captured terminal identity changed; refusing to mutate it", binding.peer, binding.handle);
|
|
536
|
-
|
|
578
|
+
let expectedRoot: string;
|
|
579
|
+
try { expectedRoot = savedTerminalRoot(binding); }
|
|
580
|
+
catch { return blocker("project-root-mismatch", "declared terminal execution root changed or is unavailable; refusing to mutate it", binding.peer, binding.handle); }
|
|
581
|
+
if (!rootMatches(root, expectedRoot)) return blocker("project-root-mismatch", "terminal belongs to a different project root; refusing to mutate it", binding.peer, binding.handle);
|
|
537
582
|
return undefined;
|
|
538
583
|
}
|
|
539
584
|
|
|
@@ -555,6 +600,7 @@ export async function inspectSavedTerminal(binding: TerminalBinding, options?: C
|
|
|
555
600
|
const completeIdentity = (value: Record<string, unknown>) =>
|
|
556
601
|
[nestedString(value, ["handle"]), nestedString(value, ["incarnationId"]), nestedString(value, ["worktreeId"]), nestedString(value, ["worktreePath", "projectRoot"])].every(Boolean);
|
|
557
602
|
try {
|
|
603
|
+
savedTerminalRoot(binding);
|
|
558
604
|
const shown = terminalObject(await run(config.runner, ["terminal", "show", "--terminal", binding.handle, "--json"]));
|
|
559
605
|
if (!completeIdentity(shown)) return unknown();
|
|
560
606
|
const issue = verifyBindingData(shown, binding);
|
|
@@ -598,6 +644,7 @@ export async function inspectTerminals(projectRoot: string, sessions: Partial<Re
|
|
|
598
644
|
const byPeer: Partial<Record<TerminalPeer, TerminalBinding>> = {};
|
|
599
645
|
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);
|
|
600
646
|
if (!requested.length) return { bindings, byPeer, blockers, manualRequired: false };
|
|
647
|
+
const instances = requested.some(([peer]) => definitionOf(peer) === undefined) ? readInstanceConfig(projectRoot).instances : undefined;
|
|
601
648
|
// #215: without the records a terminal could be bound from Orca metadata alone, losing the launch's captured
|
|
602
649
|
// CODEX_HOME or CLAUDE_CONFIG_DIR: an unreadable file blocks the binding, never reads as "no records".
|
|
603
650
|
const rows = launchRecords(config.stateDir);
|
|
@@ -614,6 +661,15 @@ export async function inspectTerminals(projectRoot: string, sessions: Partial<Re
|
|
|
614
661
|
|
|
615
662
|
for (const [peer, ref] of requested) {
|
|
616
663
|
const sessionId = ref.sessionId;
|
|
664
|
+
const adapter = definitionOf(peer, instances), extra = definitionOf(peer) === undefined;
|
|
665
|
+
if (!adapter?.session.launchRecord || extra && adapter.transport !== "codex-appserver") { blockers.push(blocker("ownership-unknown", `${peer}: no declared managed-terminal recovery adapter`, peer)); continue; }
|
|
666
|
+
const ownRecord = records.find(item => item.peer === peer);
|
|
667
|
+
let expectedRoot: string, expectedTerminalRoot: string;
|
|
668
|
+
try {
|
|
669
|
+
expectedRoot = executionRoot(peer, projectRoot, ownRecord?.executionCwd ?? projectRoot);
|
|
670
|
+
expectedTerminalRoot = savedTerminalRoot({ peer, projectRoot, executionCwd: ownRecord?.executionCwd, terminalRoot: ownRecord?.terminalRoot });
|
|
671
|
+
}
|
|
672
|
+
catch { blockers.push(blocker("ownership-unknown", `${peer}: declared execution worktree changed or is unverified`, peer)); continue; }
|
|
617
673
|
// #215: a launcher whose identity cannot be read may be this peer's: binding from Orca metadata would lose its launch.
|
|
618
674
|
const unread = unknown.find((item) => item.peer === peer);
|
|
619
675
|
if (unread) {
|
|
@@ -624,7 +680,7 @@ export async function inspectTerminals(projectRoot: string, sessions: Partial<Re
|
|
|
624
680
|
blockers.push(blocker("ownership-unknown", `${recordPath(config.stateDir)} holds a launch record that cannot be evaluated and may be this ${peer} launcher's, so no terminal can be bound to its launch; next action: inspect that row and fix it, or end that launcher and remove its row, then plan or resume again`, peer));
|
|
625
681
|
continue;
|
|
626
682
|
}
|
|
627
|
-
const candidates = listed.filter((terminal) => rootMatches(terminal.worktreePath ?? terminal.projectRoot,
|
|
683
|
+
const candidates = listed.filter((terminal) => rootMatches(terminal.worktreePath ?? terminal.projectRoot, expectedTerminalRoot));
|
|
628
684
|
const peerCandidates: Record<string, unknown>[] = [];
|
|
629
685
|
for (const listedTerminal of candidates) {
|
|
630
686
|
const handle = nestedString(listedTerminal, ["handle"]);
|
|
@@ -637,16 +693,16 @@ export async function inspectTerminals(projectRoot: string, sessions: Partial<Re
|
|
|
637
693
|
continue;
|
|
638
694
|
}
|
|
639
695
|
// A show readback is required; list can be stale between recovery steps.
|
|
640
|
-
if (!rootMatches(terminal.worktreePath ?? terminal.projectRoot,
|
|
641
|
-
const identity = identityFrom(terminal);
|
|
696
|
+
if (!rootMatches(terminal.worktreePath ?? terminal.projectRoot, expectedTerminalRoot)) continue;
|
|
697
|
+
const identity = identityFrom(terminal, instances);
|
|
642
698
|
const terminalSession = sessionFrom(terminal);
|
|
643
699
|
const recorded = records.find((item) => item.peer === peer && item.handle === handle && item.worktreeId === terminal.worktreeId && item.incarnationId === terminal.incarnationId);
|
|
644
700
|
// A recorded launcher proves ownership; the daemon's supplied session id is the
|
|
645
701
|
// authoritative session id when Orca omits it from terminal metadata.
|
|
646
|
-
if ((identity === peer && terminalSession === sessionId) || (recorded !== undefined && (identity === undefined || identity === peer) && (terminalSession === undefined || terminalSession === sessionId))) peerCandidates.push(terminal);
|
|
702
|
+
if ((!extra && identity === peer && terminalSession === sessionId) || (recorded !== undefined && (identity === undefined || identity === peer || extra && identity === adapter.kind) && (terminalSession === undefined || terminalSession === sessionId))) peerCandidates.push(terminal);
|
|
647
703
|
}
|
|
648
704
|
if (!peerCandidates.length) {
|
|
649
|
-
const ownershipUnknown = candidates.some((terminal) => identityFrom(terminal) === undefined && !records.some((item) => item.handle === terminal.handle));
|
|
705
|
+
const ownershipUnknown = candidates.some((terminal) => identityFrom(terminal, instances) === undefined && !records.some((item) => item.handle === terminal.handle));
|
|
650
706
|
blockers.push(ownershipUnknown
|
|
651
707
|
? blocker("ownership-unknown", `cannot prove ${peer} terminal ownership from Orca metadata`, peer)
|
|
652
708
|
: blocker("missing-terminal", `no ${peer} terminal with session ${sessionId} in ${projectRoot}`, peer));
|
|
@@ -669,9 +725,11 @@ export async function inspectTerminals(projectRoot: string, sessions: Partial<Re
|
|
|
669
725
|
continue;
|
|
670
726
|
}
|
|
671
727
|
const recorded = records.find((item) => item.peer === peer && item.handle === handle && item.worktreeId === terminal.worktreeId && item.incarnationId === terminal.incarnationId);
|
|
672
|
-
|
|
728
|
+
// Orca observation supplies file identity; launch arguments are not observed native session-file evidence.
|
|
729
|
+
const { args: _launchArgs, ...observedTerminal } = terminal;
|
|
730
|
+
const sessionFile = definitionOf(peer, instances)?.session.crashIdentity?.(observedTerminal).sessionFile;
|
|
673
731
|
const launch = launchFor(peer, projectRoot, sessionId, config, terminal, recorded, { ...ref, ...(sessionFile ? { sessionFile } : {}) });
|
|
674
|
-
const binding: TerminalBinding = { peer, handle, incarnationId, worktreeId, projectRoot, sessionId, ...(sessionFile ? { sessionFile } : {}), ...(ref.backend ? { backend: ref.backend } : {}), ...(ref.model ? { model: ref.model } : {}), launch, launchMetadata: launch };
|
|
732
|
+
const binding: TerminalBinding = { peer, handle, incarnationId, worktreeId, projectRoot, ...(extra ? { executionCwd: expectedRoot, terminalRoot: expectedTerminalRoot } : {}), sessionId, ...(sessionFile ? { sessionFile } : {}), ...(ref.backend ? { backend: ref.backend } : {}), ...(ref.model ? { model: ref.model } : {}), launch, launchMetadata: launch };
|
|
675
733
|
bindings.push(binding);
|
|
676
734
|
byPeer[peer] = binding;
|
|
677
735
|
}
|
|
@@ -743,6 +801,7 @@ export async function createTerminal(binding: TerminalBinding, options?: Command
|
|
|
743
801
|
const config = normalizeOptions(options);
|
|
744
802
|
let made: string | undefined; // a later failure must still report the terminal it created
|
|
745
803
|
try {
|
|
804
|
+
savedTerminalRoot(binding);
|
|
746
805
|
const worktreeSelector = binding.worktreeId.startsWith("id:") ? binding.worktreeId : `id:${binding.worktreeId}`;
|
|
747
806
|
// The coordinator journals a pending creation before this call and reconciles
|
|
748
807
|
// it on resume. Other agents in the worktree do not imply a prior create.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { definitionOf } from "../adapters/registry.ts";
|
|
1
2
|
import { renderPlan, renderOperation, renderBlocked, renderSummary, renderStep, renderWait, renderMenu, choiceInstruction, type RecoveryPresentationOptions, type MenuPresentationItem } from "./upgrade-presentation.ts";
|
|
2
3
|
import { quoted, wrap } from "./console-state.ts";
|
|
3
4
|
import { operationLines, stepLabel } from "./upgrade-labels.ts";
|
|
@@ -83,7 +84,8 @@ const left = (op: { id: string; sourceRoot?: string }, io: ScreenIO): string =>
|
|
|
83
84
|
export function peerKind(planned: PlannedProject, peer: RecoveryPeer): "tui" | "headless" | "unmanaged" | "offline" {
|
|
84
85
|
if (peer.state === "offline") return "offline";
|
|
85
86
|
if ((planned.terminals as { peer: string }[]).some((t) => t.peer === peer.id)) return "tui";
|
|
86
|
-
|
|
87
|
+
const unrecorded = definitionOf(peer.id)?.session.unrecordedTerminal;
|
|
88
|
+
if (unrecorded === "always" || unrecorded === "tui" && peer.args?.mode === "tui") return "unmanaged";
|
|
87
89
|
return "headless";
|
|
88
90
|
}
|
|
89
91
|
|
|
@@ -93,7 +95,7 @@ function peerAction(planned: PlannedProject, peer: RecoveryPeer): string {
|
|
|
93
95
|
if (planned.freshStart?.includes(peer.id)) return "restarts as a new session (no turn to lose)";
|
|
94
96
|
const terminal = (planned.terminals as { peer: string; handle: string }[]).find((t) => t.peer === peer.id);
|
|
95
97
|
if (terminal) return `resumes its session in a new terminal (replaces ${terminal.handle})`;
|
|
96
|
-
return peer.id
|
|
98
|
+
return definitionOf(peer.id)?.session.restoreOnStart ? "restarts headless on its recorded session" : "restarts headless as a new session";
|
|
97
99
|
}
|
|
98
100
|
|
|
99
101
|
const KINDS = { tui: "TUI", headless: "headless", unmanaged: "own", offline: "-" } as const;
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { definitionOf } from "../adapters/registry.ts";
|
|
2
|
+
import { readInstanceConfig } from "../hub/instance-config.ts";
|
|
1
3
|
import type { TerminalBinding } from "./terminal-recovery.ts";
|
|
2
4
|
|
|
3
5
|
export type RestoreReceipt =
|
|
@@ -22,10 +24,16 @@ export type RestoreReceiptStatus = "unstarted" | "pending" | "waiting:pending" |
|
|
|
22
24
|
function malformed(): never { throw new Error("invalid restore receipt"); }
|
|
23
25
|
const object = (value: unknown): value is Record<string, unknown> => !!value && typeof value === "object" && !Array.isArray(value);
|
|
24
26
|
const text = (value: unknown): value is string => typeof value === "string" && value.length > 0;
|
|
25
|
-
function terminalBinding(value: unknown): TerminalBinding {
|
|
26
|
-
if (!object(value) || typeof value.peer !== "string"
|
|
27
|
+
function terminalBinding(value: unknown, historical = false): TerminalBinding {
|
|
28
|
+
if (!object(value) || typeof value.peer !== "string") malformed();
|
|
27
29
|
for (const key of ["handle", "incarnationId", "worktreeId", "projectRoot", "sessionId"]) if (!text(value[key])) malformed();
|
|
28
|
-
|
|
30
|
+
if (historical) {
|
|
31
|
+
if (!/^[a-z][a-z0-9-]{0,31}$/.test(value.peer)) malformed();
|
|
32
|
+
} else try {
|
|
33
|
+
const adapter = definitionOf(value.peer) ?? definitionOf(value.peer, readInstanceConfig(value.projectRoot as string).instances);
|
|
34
|
+
if (!adapter?.session.launchRecord) malformed();
|
|
35
|
+
} catch { malformed(); }
|
|
36
|
+
for (const key of ["sessionFile", "backend", "model", "executionCwd", "terminalRoot"]) if (value[key] !== undefined && typeof value[key] !== "string") malformed();
|
|
29
37
|
const launch = value.launch;
|
|
30
38
|
const validLaunch = (v: unknown) => object(v) && text(v.packageEntrypoint) && text(v.command)
|
|
31
39
|
&& Array.isArray(v.argv) && v.argv.every(arg => typeof arg === "string")
|
|
@@ -35,7 +43,9 @@ function terminalBinding(value: unknown): TerminalBinding {
|
|
|
35
43
|
}
|
|
36
44
|
|
|
37
45
|
/** Decode legacy representations only here. Absence is unstarted, never pending or failed. */
|
|
38
|
-
export function decodeRestoreReceipt(value: unknown): RestoreReceipt | undefined {
|
|
46
|
+
export function decodeRestoreReceipt(value: unknown, options: { authority?: "current" | "historical" } = {}): RestoreReceipt | undefined {
|
|
47
|
+
// Historical decoding proves receipt structure only, never current permission to launch or replace a peer.
|
|
48
|
+
const historical = options.authority === "historical";
|
|
39
49
|
if (value === undefined) return undefined;
|
|
40
50
|
if (value === "pending" || value === "failed") return { state: value };
|
|
41
51
|
if (!object(value)) malformed();
|
|
@@ -46,14 +56,14 @@ export function decodeRestoreReceipt(value: unknown): RestoreReceipt | undefined
|
|
|
46
56
|
case "pending": case "failed": return { state: value.state };
|
|
47
57
|
case "waiting": {
|
|
48
58
|
if (value.origin !== "pending" && value.origin !== "failed") malformed();
|
|
49
|
-
return { state: "waiting", origin: value.origin, binding: terminalBinding(value.binding) };
|
|
59
|
+
return { state: "waiting", origin: value.origin, binding: terminalBinding(value.binding, historical) };
|
|
50
60
|
}
|
|
51
|
-
case "done": return { state: "done", binding: terminalBinding(value.binding) };
|
|
61
|
+
case "done": return { state: "done", binding: terminalBinding(value.binding, historical) };
|
|
52
62
|
default: return malformed();
|
|
53
63
|
}
|
|
54
64
|
}
|
|
55
65
|
if (Object.hasOwn(value, "attachmentReceipt")) malformed();
|
|
56
|
-
return { state: "done", binding: terminalBinding(value) };
|
|
66
|
+
return { state: "done", binding: terminalBinding(value, historical) };
|
|
57
67
|
}
|
|
58
68
|
|
|
59
69
|
export function restoreReceiptStatus(value: unknown): RestoreReceiptStatus {
|