@staix/agent-hub 0.12.39 → 0.12.40
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 +26 -0
- package/README.md +15 -10
- package/docs/agent-notes/adapters.md +4 -1
- package/docs/agent-notes/budget.md +2 -0
- package/docs/agent-notes/bus.md +2 -2
- package/docs/agent-notes/daemon.md +10 -6
- package/docs/agent-notes/models.md +4 -4
- package/docs/agent-notes/tasks.md +3 -0
- package/docs/agent-notes/tests.md +1 -1
- package/docs/memory.md +1 -1
- package/docs/operations.md +152 -28
- package/docs/quickstart.md +19 -5
- package/docs/security.md +19 -7
- package/docs/smoke.md +2 -2
- package/docs/specs/2026-09-19-agent-hub-design.md +158 -70
- package/docs/specs/2026-10-04-switchyard-source-port-design.md +12 -7
- package/docs/verification/2026-10-20-0.12.40.md +63 -0
- package/docs/verified.json +38 -19
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +413 -147
- package/src/adapters/acp.ts +34 -1
- package/src/adapters/claude-channel.ts +35 -4
- package/src/adapters/codex-appserver.ts +12 -3
- package/src/adapters/definitions/builtin-metadata.ts +2 -0
- package/src/adapters/definitions/native-cli-doctor.ts +8 -5
- package/src/adapters/definitions/native-cli-entry-claude.ts +7 -2
- package/src/adapters/definitions/native-cli-entry-codex.ts +13 -14
- package/src/adapters/definitions/native-cli-entry-jcode.ts +13 -3
- package/src/adapters/definitions/native-cli-entry-pi.ts +9 -7
- package/src/adapters/definitions/native-cli-entry-types.ts +2 -0
- package/src/adapters/definitions/native-cli-jcode-mcp.ts +55 -1
- package/src/adapters/jcode.ts +110 -4
- package/src/adapters/local-worker.ts +21 -37
- package/src/adapters/pi.ts +7 -0
- package/src/cli/console-state.ts +1 -0
- package/src/cli/console.ts +11 -1
- package/src/cli/help.ts +13 -8
- package/src/cli/identity-audit.ts +33 -7
- package/src/cli/jcode-mcp.ts +1 -1
- package/src/cli/main.ts +134 -24
- package/src/cli/output.ts +5 -2
- package/src/cli/status-lines.ts +1 -1
- package/src/cli/terminal-recovery.ts +54 -4
- package/src/cli/upgrade-interactive.ts +5 -0
- package/src/cli/upgrade-runtime.ts +13 -1
- package/src/cli/upgrade.ts +67 -1
- package/src/hub/board.ts +4 -2
- package/src/hub/bus.ts +8 -3
- package/src/hub/checks.ts +15 -5
- package/src/hub/conductor.ts +2 -1
- package/src/hub/config-apply.ts +20 -2
- package/src/hub/config-reload.ts +2 -8
- package/src/hub/control-client.ts +26 -3
- package/src/hub/daemon.ts +155 -68
- package/src/hub/delivery-superseded.ts +19 -2
- package/src/hub/events.ts +4 -1
- package/src/hub/hub-tools.ts +4 -4
- package/src/hub/inference.ts +4 -8
- package/src/hub/lifecycle.ts +3 -3
- package/src/hub/main-checkout.ts +36 -0
- package/src/hub/memory-profile-check.ts +6 -2
- package/src/hub/memory-profile.ts +18 -1
- package/src/hub/peers.ts +2 -0
- package/src/hub/ports.ts +1 -0
- package/src/hub/routing.ts +24 -14
- package/src/hub/settings.ts +1 -1
- package/src/hub/task-repository.ts +136 -0
- package/src/hub/tasks.ts +101 -21
- package/src/hub/terminal-launch.ts +130 -0
- package/src/hub/usage.ts +44 -18
- package/src/models/route/config.ts +1 -1
- package/src/omniroute/client.ts +7 -19
- package/templates/AGENTS.block.md +1 -0
- package/templates/routing.toml +10 -44
- package/src/switchyard/config.ts +0 -45
- package/src/switchyard/sidecar.ts +0 -136
package/src/cli/main.ts
CHANGED
|
@@ -17,12 +17,12 @@ import { profileName, projectProfile, resolveProfile } from "../hub/profiles.ts"
|
|
|
17
17
|
import { catalogAssignments, loadRoleCatalog, selectedRoles } from "../hub/roles.ts";
|
|
18
18
|
import { attachWatch } from "./watch.ts";
|
|
19
19
|
import { verifiedJcodeCommand } from "../adapters/jcode.ts";
|
|
20
|
-
import { configureJcodeMcp } from "./jcode-mcp.ts";
|
|
20
|
+
import { configureJcodeMcp, jcodeExtraMcpServers } from "./jcode-mcp.ts";
|
|
21
21
|
import { qwenSessionId } from "../hub/crash.ts";
|
|
22
22
|
import { isPermissionMode } from "../hub/permission-mode.ts";
|
|
23
23
|
import { parseSettingText, settingDef, settingText, type SettingRow } from "../hub/settings.ts";
|
|
24
24
|
import { startModeOf } from "../hub/start-mode.ts";
|
|
25
|
-
import {
|
|
25
|
+
import { OVERLAY_FILE, parseOverlay, parseRouting, routingText, type Routing, type RoutingOverlay } from "../hub/routing.ts";
|
|
26
26
|
import { spawn, spawnSync } from "node:child_process";
|
|
27
27
|
import { chmodSync, existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
28
28
|
import { randomUUID } from "node:crypto";
|
|
@@ -50,13 +50,13 @@ import { createInterface } from "node:readline/promises";
|
|
|
50
50
|
import { createInterface as lineReader } from "node:readline";
|
|
51
51
|
import { activeOperation, assertLifecycleAvailable, operationIdPattern, readOperation, recoveryLock, recoveryRunner, signedRunner, stopSignedRunner, supportsAction } from "../hub/recovery-store.ts";
|
|
52
52
|
import { childEnv } from "../hub/child-process.ts";
|
|
53
|
-
import { abortRecovery, createOperation, disposeRecovery, repairedFreshSession, reattachRecovery, liveProjects, nextChoices, nextActionsText, publicOperation, recoveryCommand, registeredProjects, runRecovery, type RecoveryOperation, type Inspection } from "./upgrade.ts";
|
|
53
|
+
import { abortRecovery, createOperation, disposeRecovery, recordRecoveryDelegation, repairedFreshSession, reattachRecovery, liveProjects, nextChoices, nextActionsText, publicOperation, recoveryCommand, registeredProjects, runRecovery, type RecoveryOperation, type Inspection } from "./upgrade.ts";
|
|
54
54
|
import { endPlannedPeer, makeRecoveryDriver, makeUpgradePlan, preserveSource } from "./upgrade-runtime.ts";
|
|
55
55
|
import { exactVersion, latestRelease, newerVersion } from "./recovery-package.ts";
|
|
56
56
|
import { operationScreen, planScreen, type ScreenIO, type UpgradeHost } from "./upgrade-interactive.ts";
|
|
57
57
|
import { readRecoveryPresentationFlags } from "./upgrade-presentation-flags.ts";
|
|
58
58
|
import { renderOperation, renderBlocked, renderSummary, type RecoveryPresentationOptions } from "./upgrade-presentation.ts";
|
|
59
|
-
import { recordTerminalLaunch, type TerminalPeer } from "./terminal-recovery.ts";
|
|
59
|
+
import { recordTerminalLaunch, prepareTerminalLaunch, registerTerminalLaunch, type TerminalPeer } from "./terminal-recovery.ts";
|
|
60
60
|
import { stopAndArchiveSummary } from "./recovery-guidance.ts";
|
|
61
61
|
import { ensureMlx, inspectMlx, stopMlx } from "../models/mlx.ts";
|
|
62
62
|
import { setupOllamaModel } from "./models-setup.ts";
|
|
@@ -74,7 +74,7 @@ import { summarize, summarizeByTask } from "../hub/report.ts";
|
|
|
74
74
|
import { hasTree, planUndo, repoOf, restore, Turns } from "../hub/snapshots.ts";
|
|
75
75
|
import { pathWarnings } from "../hub/conflicts.ts";
|
|
76
76
|
import { classifyPeerCommand, cliCommandLabel, detectCliIdentity, LIVE_TEST_PERSON_REFUSAL, liveTestRefusal, peerCommandRefusal } from "./identity.ts";
|
|
77
|
-
import { recordCliAudit } from "./identity-audit.ts";
|
|
77
|
+
import { recordCliAudit, recordDelegatedCliAudit } from "./identity-audit.ts";
|
|
78
78
|
import { runConsole } from "./console.ts";
|
|
79
79
|
import { paint, permissionBoundary, resolveColor, wrap, type Span } from "./console-state.ts";
|
|
80
80
|
import { outputWidth, renderRoster, renderStatus, renderBoard, renderBoardColumns, boardColumnModel, renderBudget, renderDoctor, renderProjects, renderQueue, renderTurns, renderOrphans, renderQueueShow, renderModelsStatus, renderExecutionBudgetStatus, renderReport, type DoctorCheck } from "./output.ts";
|
|
@@ -167,7 +167,7 @@ const delegatedPeer = liveTest && caller.role === "tools" && commandAccess !== "
|
|
|
167
167
|
const identity = delegatedPeer ? { role: "console" as const } : caller;
|
|
168
168
|
function audit(outcome: "run" | "refused" | "invalid"): void {
|
|
169
169
|
if (caller.role === "console") return;
|
|
170
|
-
try { recordCliAudit(stateDir, caller.role === "tools" ? caller.peer : "unknown", commandLabel,
|
|
170
|
+
try { recordCliAudit(stateDir, caller.role === "tools" ? caller.peer : "unknown", commandLabel, outcome); }
|
|
171
171
|
catch {
|
|
172
172
|
if (delegatedPeer) fail("live-test delegation audit unavailable; nothing was executed");
|
|
173
173
|
console.error("ahub: CLI audit could not be recorded (outbox unavailable or full)");
|
|
@@ -176,7 +176,42 @@ function audit(outcome: "run" | "refused" | "invalid"): void {
|
|
|
176
176
|
if (identity.role === "invalid") { audit("invalid"); fail(`${identity.reason}; use ahub console or a terminal with no agent markers`); }
|
|
177
177
|
if (delegationRefused) { audit("refused"); fail(LIVE_TEST_PERSON_REFUSAL); }
|
|
178
178
|
if (identity.role === "tools" && commandAccess === "console") { audit("refused"); fail(peerCommandRefusal(identity.peer, commandLabel)); }
|
|
179
|
-
|
|
179
|
+
// Recovery keeps its authoritative delegation audit in the operation receipt, independent of an old source drain.
|
|
180
|
+
// With no daemon manifest, local bootstrap commands have no consumer to negotiate with; use the compatible outcome.
|
|
181
|
+
const recoveryAudit = ["upgrade", "restart", "recovery", "recovery-run"].includes(cmd);
|
|
182
|
+
const noAuditConsumer = !existsSync(join(stateDir, "status.json")) && !existsSync(join(stateDir, "hub.pid"));
|
|
183
|
+
let delegatedAuditHub: ControlClient | undefined;
|
|
184
|
+
let delegatedConnectionTaken = false;
|
|
185
|
+
let releaseAuditInstance: (() => void) | undefined;
|
|
186
|
+
if (delegatedPeer && !recoveryAudit && !noAuditConsumer) {
|
|
187
|
+
let published = false;
|
|
188
|
+
try {
|
|
189
|
+
const auditId = await recordDelegatedCliAudit(stateDir, delegatedPeer, commandLabel, async () => {
|
|
190
|
+
// The validated invocation already has operational console authority; a tools hello would add a roster gate.
|
|
191
|
+
delegatedAuditHub = await ControlClient.connect(stateDir, { role: "console", projectRoot: cwd });
|
|
192
|
+
return delegatedAuditHub.instanceId ? delegatedAuditHub.features : [];
|
|
193
|
+
});
|
|
194
|
+
published = true;
|
|
195
|
+
releaseAuditInstance = ControlClient.pinInstance(stateDir, delegatedAuditHub!.instanceId!);
|
|
196
|
+
assertDelegatedAuditConsumer();
|
|
197
|
+
const retained = await delegatedAuditHub!.request({ t: "cli_audit_ack", auditId }, 3000);
|
|
198
|
+
if (retained.ok !== true || retained.auditId !== auditId || retained.instanceId !== delegatedAuditHub!.instanceId) {
|
|
199
|
+
throw new Error("live-test audit retention was not confirmed; nothing was executed; inspect the hub or upgrade it before retrying");
|
|
200
|
+
}
|
|
201
|
+
} catch (error) {
|
|
202
|
+
releaseAuditInstance?.(); delegatedAuditHub?.close();
|
|
203
|
+
if (published) {
|
|
204
|
+
try { recordCliAudit(stateDir, delegatedPeer, commandLabel, "refused"); }
|
|
205
|
+
catch { fail("live-test delegation audit unavailable; nothing was executed"); }
|
|
206
|
+
}
|
|
207
|
+
fail((error as Error).message);
|
|
208
|
+
}
|
|
209
|
+
} else if (identity.role === "tools" || delegatedPeer) audit("run");
|
|
210
|
+
function assertDelegatedAuditConsumer(): void {
|
|
211
|
+
if (delegatedAuditHub && ((!delegatedConnectionTaken && !delegatedAuditHub.isOpen) || readControl(stateDir)?.instanceId !== delegatedAuditHub.instanceId)) {
|
|
212
|
+
throw new Error("delegated audit consumer changed; refusing further actions; inspect the result before retrying");
|
|
213
|
+
}
|
|
214
|
+
}
|
|
180
215
|
// Forward only to ahub's own continuation commands, never to native agent argv or an environment flag.
|
|
181
216
|
const liveTestArgs = liveTest ? ["--live-test"] : [];
|
|
182
217
|
const invokedFrom = process.cwd(); // relative paths a person types (a bench suite) are theirs, not the project's
|
|
@@ -199,7 +234,12 @@ function printOutput(lines: Span[][], color: boolean): void {
|
|
|
199
234
|
}
|
|
200
235
|
const unattendedEnv = process.env.AGENTHUB_UNATTENDED === "1";
|
|
201
236
|
const lifecycle = { inspectProject, startProject, stopProject };
|
|
202
|
-
const connect = () =>
|
|
237
|
+
const connect = async () => {
|
|
238
|
+
assertDelegatedAuditConsumer();
|
|
239
|
+
if (delegatedAuditHub && !delegatedConnectionTaken) { delegatedConnectionTaken = true; return delegatedAuditHub; }
|
|
240
|
+
return ControlClient.connect(stateDir, identity.role === "tools" ? { role: "tools", peer: identity.peer, projectRoot: cwd }
|
|
241
|
+
: { role: "console", projectRoot: cwd, ...(delegatedAuditHub ? { instanceId: delegatedAuditHub.instanceId } : {}) });
|
|
242
|
+
};
|
|
203
243
|
|
|
204
244
|
function registeredProject(): Project {
|
|
205
245
|
const registry = new Registry();
|
|
@@ -509,7 +549,7 @@ function upgradeHost(kind: "restart" | "upgrade", version: string): UpgradeHost
|
|
|
509
549
|
if (current.fingerprint !== plan.fingerprint) throw new Error("the plan changed during review; review it again");
|
|
510
550
|
// The check above takes seconds: a Ctrl+C in that time is still a no.
|
|
511
551
|
if (interrupted()) throw new Error("interrupted; nothing was started");
|
|
512
|
-
const operation = createOperation(plan, preserveSource(plan));
|
|
552
|
+
const operation = createOperation(plan, preserveSource(plan), undefined, delegatedPeer ? { peer: delegatedPeer, command: kind } : undefined);
|
|
513
553
|
spawnRecovery(operation, true);
|
|
514
554
|
return operation;
|
|
515
555
|
},
|
|
@@ -583,7 +623,7 @@ async function upgrade(kind: "restart" | "upgrade"): Promise<void> {
|
|
|
583
623
|
const current = await makeUpgradePlan(kind, plan.version, kind === "restart" ? cwd : undefined);
|
|
584
624
|
if (current.fingerprint !== plan.fingerprint) fail("plan changed during review; run the command again");
|
|
585
625
|
const sourceRoot = preserveSource(plan);
|
|
586
|
-
const operation = createOperation(plan, sourceRoot);
|
|
626
|
+
const operation = createOperation(plan, sourceRoot, undefined, delegatedPeer ? { peer: delegatedPeer, command: kind } : undefined);
|
|
587
627
|
if (display.json) { spawnRecovery(operation, true); console.log(JSON.stringify(publicOperation(operation), null, 2)); }
|
|
588
628
|
else spawnRecovery(operation, false, humanRecovery(false) ? display.options : undefined);
|
|
589
629
|
}
|
|
@@ -707,8 +747,11 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
707
747
|
else if (humanRecovery(false)) printRecoveryStatus(operation, `runner ${runner} is still working on this operation`, display.options);
|
|
708
748
|
else console.log(`runner ${runner} is still working on this operation; ${recoveryCommand(operation, "status")}`);
|
|
709
749
|
}
|
|
710
|
-
else
|
|
711
|
-
|
|
750
|
+
else {
|
|
751
|
+
if (delegatedPeer) operation = recordRecoveryDelegation(operation.id, { peer: delegatedPeer, command: "recovery resume" });
|
|
752
|
+
if (display.json) { spawnRecovery(operation, true); console.log(JSON.stringify(publicOperation(operation), null, 2)); }
|
|
753
|
+
else spawnRecovery(operation, false, humanRecovery(false) ? display.options : undefined);
|
|
754
|
+
}
|
|
712
755
|
},
|
|
713
756
|
"recovery-run": async () => {
|
|
714
757
|
if (args.length !== 1 || process.env.AGENTHUB_RECOVERY_OPERATION !== args[0]) fail("recovery-run is an internal command");
|
|
@@ -859,7 +902,7 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
859
902
|
projectConfig, connect: nativeConnect, fail,
|
|
860
903
|
exec: (bin, argv, tool) => exec(bin, argv, tool, context.executionCwd),
|
|
861
904
|
execWithEnv: (bin, argv, env) => execWithEnv(bin, argv, env, selectedInstance ?? entry.command, context.executionCwd), takeFlags,
|
|
862
|
-
helpers: { launcherPreview: (tool, raw, projectRoot, state, unattended) => launcherPreview(tool, raw, projectRoot, state, unattended, selectedInstance), readControl, recordTerminalLaunch, recoveryLock, processSignature,
|
|
905
|
+
helpers: { launcherPreview: (tool, raw, projectRoot, state, unattended) => launcherPreview(tool, raw, projectRoot, state, unattended, selectedInstance), readControl, recordTerminalLaunch, prepareTerminalLaunch, registerTerminalLaunch, recoveryLock, processSignature,
|
|
863
906
|
buildLaunch, claudeObservationHooks, recordClaudeLaunch, cleanupStaleClaudeSettings, cleanupClaudeSettings,
|
|
864
907
|
nativeLaunchEnv, runClaudeLaunch, childEnv, qwenSessionId, configureJcodeMcp, configureKimiMcp,
|
|
865
908
|
buildKimiTuiLaunch, startModeOf },
|
|
@@ -1551,7 +1594,9 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1551
1594
|
// #258: the hub's own memory on this machine; reads are anyone's, changes a person's.
|
|
1552
1595
|
if (args.filter(arg => arg !== "--json")[0] === "profile-check") {
|
|
1553
1596
|
if (args.some(arg => arg !== "profile-check" && arg !== "--json")) fail("usage: ahub memory profile-check [--json]");
|
|
1554
|
-
|
|
1597
|
+
let selected: ReturnType<typeof selectedMemoryProfile>;
|
|
1598
|
+
try { selected = selectedMemoryProfile(); } catch (e) { return fail((e as Error).message); } // an inactive split names the resume remedy
|
|
1599
|
+
const result = await checkMemoryProfiles(selected);
|
|
1555
1600
|
console.log(args.includes("--json") ? JSON.stringify(result, null, 2) : formatMemoryProfileCheck(result));
|
|
1556
1601
|
if (!result.ready) process.exitCode = 1;
|
|
1557
1602
|
return;
|
|
@@ -1887,9 +1932,16 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1887
1932
|
const config = loadConfig(cwd);
|
|
1888
1933
|
row(!config.ignored, "config", config.ignored ? `${config.ignored.join("; ")} (move them to .agenthub/config.local.json)` : "no machine-local field ignored");
|
|
1889
1934
|
for (const line of config.retired ?? []) row(false, "retired setting", line);
|
|
1935
|
+
const { routing, errors: routingErrors } = inspectDoctorRouting(cwd);
|
|
1936
|
+
for (const error of routingErrors) row(false, "routing", error);
|
|
1890
1937
|
for (const probe of nativeDoctorOptionalProbes(config)) {
|
|
1891
1938
|
if (probe.kind === "source-identity") row(verifiedJcodeCommand(probe.command, cwd), probe.name, probe.detail);
|
|
1892
|
-
else {
|
|
1939
|
+
else if (probe.kind === "jcode-mcp") {
|
|
1940
|
+
const { servers, unreadable } = jcodeExtraMcpServers(cwd, probe.home);
|
|
1941
|
+
row(servers.length || unreadable.length ? false : true, probe.name,
|
|
1942
|
+
[servers.length ? `${servers.length} besides the hub's own would load in the hub-owned jcode (${servers.join(", ")}); jcode has no switch to choose them, so remove them from its config or use another JCODE_HOME` : "",
|
|
1943
|
+
unreadable.length ? `unreadable or unsafe, not listed: ${unreadable.join(", ")}` : ""].filter(Boolean).join("; ") || "none besides the hub's own");
|
|
1944
|
+
} else {
|
|
1893
1945
|
const v = version(probe.command[0]!);
|
|
1894
1946
|
row(v === probe.expected, probe.name, v === probe.expected ? probe.confirmed : probe.unconfirmed);
|
|
1895
1947
|
}
|
|
@@ -1898,15 +1950,18 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1898
1950
|
const gateway = await omni.base();
|
|
1899
1951
|
row(gateway ? true : config.omniroute.urls.length || process.env.AGENTHUB_OMNIROUTE_URL ? false : undefined, "omniroute", gateway ? `${new URL(gateway).host} healthy` : config.omniroute.urls.length || process.env.AGENTHUB_OMNIROUTE_URL ? "no candidate reachable (VPN off?); ahub local cannot run" : "not configured: set omniroute.urls in .agenthub/config.local.json (any OpenAI-compatible gateway); ahub local cannot run");
|
|
1900
1952
|
row(!!omni.apiKey(), "omniroute key", omni.apiKey() ? "present" : "missing: set OMNIROUTE_API_KEY or omniroute.api_key_file in .agenthub/config.local.json");
|
|
1901
|
-
if (
|
|
1953
|
+
if (!routing) {
|
|
1954
|
+
row(undefined, "routing models", "routing could not be read");
|
|
1955
|
+
} else if (gateway && omni.apiKey()) {
|
|
1902
1956
|
try {
|
|
1903
|
-
const
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1957
|
+
const aliases = new Set([routing.local.fixed_model, ...Object.values(routing.classes).flatMap(policy => policy.fixed_model ? [policy.fixed_model] : []),
|
|
1958
|
+
...Object.values(routing.hub_routes ?? {}).flatMap(route => [route.efficient ?? "fast", route.capable ?? "coding", ...(route.judge ? [route.judge] : [])])]);
|
|
1959
|
+
const served = new Set(await omni.models());
|
|
1960
|
+
const missing = [...aliases].filter(alias => !served.has(alias)).sort();
|
|
1961
|
+
for (const alias of missing) row(false, "routing model", `${alias} is not served by the gateway`);
|
|
1962
|
+
if (!missing.length) row(true, "routing models", `all ${aliases.size} routing aliases are served`);
|
|
1963
|
+
} catch { row(undefined, "routing models", "could not read the gateway model inventory"); }
|
|
1964
|
+
} else row(undefined, "routing models", "gateway model inventory unavailable");
|
|
1910
1965
|
const mlxConfig = config.mlx;
|
|
1911
1966
|
if (mlxConfig.enabled === false) row(true, "pi mlx", "disabled (mlx.enabled=false; local endpoint not probed)");
|
|
1912
1967
|
else {
|
|
@@ -1931,6 +1986,60 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1931
1986
|
},
|
|
1932
1987
|
};
|
|
1933
1988
|
|
|
1989
|
+
function sanitizeRoutingParseError(file: "routing.toml" | "routing.local.toml", error: unknown): string {
|
|
1990
|
+
const raw = ((error as Error)?.message || "invalid TOML").split("\n")[0]!.trim();
|
|
1991
|
+
const cleaned = raw
|
|
1992
|
+
.replace(/^(TOML Parse error: Strings must be quoted): ".*"$/, "$1")
|
|
1993
|
+
.replace(/^(TOML Parse error: Cannot redefine (?:key|table)) '.*'$/, "$1")
|
|
1994
|
+
.replace(/^Invalid regular expression: \/.*\/[a-z]*: /, "Invalid regular expression: ");
|
|
1995
|
+
return cleaned.startsWith(`${file}:`) ? `.agenthub/${cleaned}`
|
|
1996
|
+
: cleaned.startsWith(`.agenthub/${file}:`) ? cleaned
|
|
1997
|
+
: `.agenthub/${file}: ${cleaned}`;
|
|
1998
|
+
}
|
|
1999
|
+
|
|
2000
|
+
function inspectDoctorRouting(cwd: string): { routing?: Routing; errors: string[] } {
|
|
2001
|
+
const errors: string[] = [];
|
|
2002
|
+
let baseText: string | undefined;
|
|
2003
|
+
try {
|
|
2004
|
+
baseText = readFileSync(join(cwd, ".agenthub", "routing.toml"), "utf8");
|
|
2005
|
+
} catch (error) {
|
|
2006
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") baseText = routingText(cwd);
|
|
2007
|
+
else errors.push(sanitizeRoutingParseError("routing.toml", error));
|
|
2008
|
+
}
|
|
2009
|
+
let baseRouting: Routing | undefined;
|
|
2010
|
+
if (baseText !== undefined) {
|
|
2011
|
+
try {
|
|
2012
|
+
baseRouting = parseRouting(baseText);
|
|
2013
|
+
} catch (error) {
|
|
2014
|
+
errors.push(sanitizeRoutingParseError("routing.toml", error));
|
|
2015
|
+
}
|
|
2016
|
+
}
|
|
2017
|
+
let overlayText: string | undefined;
|
|
2018
|
+
try {
|
|
2019
|
+
overlayText = readFileSync(join(cwd, ".agenthub", OVERLAY_FILE), "utf8");
|
|
2020
|
+
} catch (error) {
|
|
2021
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
2022
|
+
errors.push(sanitizeRoutingParseError("routing.local.toml", error));
|
|
2023
|
+
}
|
|
2024
|
+
}
|
|
2025
|
+
let overlay: RoutingOverlay | undefined;
|
|
2026
|
+
if (overlayText !== undefined) {
|
|
2027
|
+
try {
|
|
2028
|
+
overlay = parseOverlay(overlayText);
|
|
2029
|
+
} catch (error) {
|
|
2030
|
+
errors.push(sanitizeRoutingParseError("routing.local.toml", error));
|
|
2031
|
+
}
|
|
2032
|
+
}
|
|
2033
|
+
if (errors.length || !baseText) return { errors };
|
|
2034
|
+
if (overlay === undefined) return { routing: baseRouting, errors };
|
|
2035
|
+
try {
|
|
2036
|
+
return { routing: parseRouting(baseText, overlay), errors };
|
|
2037
|
+
} catch (error) {
|
|
2038
|
+
errors.push(sanitizeRoutingParseError("routing.local.toml", error));
|
|
2039
|
+
return { errors };
|
|
2040
|
+
}
|
|
2041
|
+
}
|
|
2042
|
+
|
|
1934
2043
|
async function runConductorCommand(): Promise<void> {
|
|
1935
2044
|
let op: string;
|
|
1936
2045
|
let input: Record<string, unknown>;
|
|
@@ -1958,8 +2067,9 @@ async function runConductorCommand(): Promise<void> {
|
|
|
1958
2067
|
}
|
|
1959
2068
|
const run = identity.role === "tools" && commandAccess === "conductor" ? runConductorCommand : (Object.hasOwn(commands, cmd) ? commands[cmd] : undefined) ?? (() => fail(`unknown command "${cmd}"; run ahub help`));
|
|
1960
2069
|
try {
|
|
2070
|
+
assertDelegatedAuditConsumer(); // Local command paths must not begin after the authenticated consumer was replaced either.
|
|
1961
2071
|
await run();
|
|
1962
2072
|
} catch (e) {
|
|
1963
2073
|
audit("refused");
|
|
1964
2074
|
fail((e as Error).message);
|
|
1965
|
-
}
|
|
2075
|
+
} finally { releaseAuditInstance?.(); delegatedAuditHub?.close(); }
|
package/src/cli/output.ts
CHANGED
|
@@ -10,13 +10,15 @@ export interface DoctorCheck { section: "Tools" | "Hub" | "Config" | "Models" |
|
|
|
10
10
|
export interface StatusOutput {
|
|
11
11
|
pendingRestart?: readonly { key: string; reason: string }[];
|
|
12
12
|
retainedConfig?: readonly { key: string; reason: string }[];
|
|
13
|
+
/** #572 AC3: advisory, present while the project root checkout is on a branch other than the default. */
|
|
14
|
+
mainCheckout?: string;
|
|
13
15
|
taskStalls?: readonly { task: number; cause: { kind: string; peer?: string; dependency?: number; code: string } }[];
|
|
14
16
|
pendingTaskOffers?: readonly { task: number; peer: string }[];
|
|
15
17
|
isolatedChecks?: { pending?: number; orphans?: string[]; unknownReceipts?: readonly string[]; maintenanceUnavailable?: boolean };
|
|
16
18
|
memoryProfile?: { profile?: string; generation?: string; state?: string; agentMemVersion?: string; skippedRows?: number; error?: string };
|
|
17
19
|
taskWip?: Record<string, { wip: number; wipLimit: number }>;
|
|
18
20
|
pid?: number; controlPort?: number; cwd?: string; version?: string; peers: Record<string, any>; roleInfo?: Record<string, any>; budget?: Record<string, any>; planner?: { peers?: string[]; source?: string };
|
|
19
|
-
tasks?: Record<string, number>; models?: { backends?: any[] }; permissionDefaults?: any[]; deliveryError?: string; crash?: string[];
|
|
21
|
+
tasks?: Record<string, number>; models?: { backends?: any[] }; permissionDefaults?: any[]; deliveryError?: string; crash?: string[];
|
|
20
22
|
}
|
|
21
23
|
export interface BudgetOutput { budget: Record<string, any>; gate?: number }
|
|
22
24
|
export interface RosterRow { peer: string; on: boolean; startMode: string; state: string; queued: number; tasks: number }
|
|
@@ -192,6 +194,7 @@ export function renderStatus(data: StatusOutput, columns?: number, now = Date.no
|
|
|
192
194
|
const out = lines(`agent-hub${data.version ? ` ${data.version}` : ""} pid ${data.pid ?? "unknown"} control 127.0.0.1:${data.controlPort ?? "unknown"}`, columns, "info");
|
|
193
195
|
if (data.cwd) out.push(...lines(data.cwd, columns));
|
|
194
196
|
for (const pending of data.pendingRestart ?? []) out.push(...detail("pending restart", `${pending.key}: ${pending.reason}`, columns, "attention"));
|
|
197
|
+
if (data.mainCheckout) out.push(...detail("main checkout", data.mainCheckout, columns, "attention"));
|
|
195
198
|
if (data.retainedConfig?.length) {
|
|
196
199
|
out.push(...detail("config retained", `${data.retainedConfig.map(row => row.key).join(", ")}; run ahub settings reload`, columns, "attention"));
|
|
197
200
|
for (const pending of data.retainedConfig) out.push(...detail("retained reason", `${pending.key}: ${pending.reason}`, columns, "attention"));
|
|
@@ -244,6 +247,7 @@ export function renderStatus(data: StatusOutput, columns?: number, now = Date.no
|
|
|
244
247
|
if (p.pullOnly && definitionOf(id)?.attach?.pullOnlyForms?.includes("tui")) out.push(...detail(`${id} last read`, typeof p.lastInboxAt === "number" ? relative(p.lastInboxAt, now) : "never", columns, "muted"));
|
|
245
248
|
if (p.permissionNote) out.push(...detail("permission", p.permissionNote, columns, "muted"));
|
|
246
249
|
if (p.requestedModel && p.servedBy && p.requestedModel !== p.servedBy) out.push(...detail("requested model", p.requestedModel, columns));
|
|
250
|
+
if (typeof p.importantWaitingSince === "number" && p.importantWaitingNote) out.push(...detail("important waiting", `${p.importantWaitingNote.replace(/^\d+ important messages? waiting/, "important messages waiting")}`, columns, "attention"));
|
|
247
251
|
if (typeof p.oldestQueuedAt === "number") out.push(...detail(`${id} oldest queued`, relative(p.oldestQueuedAt, now), columns, "muted"));
|
|
248
252
|
return out;
|
|
249
253
|
});
|
|
@@ -255,7 +259,6 @@ export function renderStatus(data: StatusOutput, columns?: number, now = Date.no
|
|
|
255
259
|
out.push([], ...rows(["MODEL", "STATE", "ACTIVE", "REQUESTED", "ACTUAL", "PROVIDER"], data.models.backends.map(b => [span(backendLabel(b)), span(b.state ?? "unknown", stateTone(b.state ?? "")), span(b.active || "-"), span(b.requestedModel ?? "-"), span(b.actualModel ?? "-"), span(b.provider ?? "-")]), columns));
|
|
256
260
|
for (const b of data.models.backends) for (const [key, label] of [["coolingUntil", "cooling"], ["failingUntil", "dispatch held"]]) if (b[key!]) { const at = Date.parse(b[key!]); out.push(...detail(`${backendLabel(b)} ${label}`, `${Number.isFinite(at) ? relative(at, now) : "unknown"}${key === "coolingUntil" && typeof b.failures === "number" ? ` after ${b.failures} failures` : key === "failingUntil" ? "; no load moves until then" : ""}`, columns, "attention")); }
|
|
257
261
|
}
|
|
258
|
-
if (data.switchyard) out.push(...detail("switchyard", data.switchyard, columns));
|
|
259
262
|
const counts = Object.entries(data.tasks ?? {}).map(([state, count]) => `${count} ${state}`).join(", ");
|
|
260
263
|
if (counts) out.push([], ...lines(`TASKS ${counts} (ahub board)`, columns));
|
|
261
264
|
return out;
|
package/src/cli/status-lines.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ContextView } from "../hub/context-window.ts";
|
|
2
2
|
// What `ahub status` prints for a peer and for a model backend, kept pure so it can be checked.
|
|
3
3
|
|
|
4
|
-
export interface PeerRow { permissionMode?: string; context?: ContextView; state?: string; queued?: number; queuedImportant?: number; needsReview?: number; heldBy?: string; holdNote?: string; liveAccepted?: string[]; oldestQueuedAt?: number; attached?: boolean; toolsOnly?: string; pullOnly?: boolean; lastInboxAt?: number | null; permissionNote?: string; paused?: string; servedBy?: string; requestedModel?: string }
|
|
4
|
+
export interface PeerRow { permissionMode?: string; context?: ContextView; state?: string; queued?: number; queuedImportant?: number; needsReview?: number; heldBy?: string; holdNote?: string; liveAccepted?: string[]; oldestQueuedAt?: number; importantWaitingSince?: number; importantWaitingNote?: string; attached?: boolean; toolsOnly?: string; pullOnly?: boolean; lastInboxAt?: number | null; permissionNote?: string; paused?: string; servedBy?: string; requestedModel?: string }
|
|
5
5
|
export interface BackendRow { kind?: string; alias?: string; state?: string; active?: number; requestedModel?: string; actualModel?: string; provider?: string; coolingUntil?: string; failures?: number; failingUntil?: string }
|
|
6
6
|
|
|
7
7
|
/** Aliases are already namespaced ("dgx/coding"); only an alias that is not gets its kind in front of it. */
|
|
@@ -10,6 +10,7 @@ import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
|
10
10
|
import { randomUUID } from "node:crypto";
|
|
11
11
|
import { processLiveness, processSignature } from "../pi/process-signature.ts";
|
|
12
12
|
import { atomicPrivateJSON } from "../hub/recovery-store.ts";
|
|
13
|
+
import type { ControlClient } from "../hub/control-client.ts";
|
|
13
14
|
|
|
14
15
|
/** The only terminal commands used by this adapter. Keep this list in sync with Orca's public CLI. */
|
|
15
16
|
export type OrcaTerminalCommand = "list" | "show" | "wait" | "close" | "create";
|
|
@@ -73,6 +74,7 @@ export interface RecoveryBlocker {
|
|
|
73
74
|
| "ownership-unknown"
|
|
74
75
|
| "ambiguous-terminal"
|
|
75
76
|
| "terminal-unready"
|
|
77
|
+
| "terminal-orphaned"
|
|
76
78
|
| "command-error"
|
|
77
79
|
| "ambiguous-create"
|
|
78
80
|
| "launcher-exited"
|
|
@@ -443,7 +445,7 @@ export async function launcherOf(peer: TerminalPeer, projectRoot: string, option
|
|
|
443
445
|
* Capture the Orca terminal handed to an ahub launcher. This is the authenticated
|
|
444
446
|
* identity source for legacy Orca payloads that omit agent/session metadata.
|
|
445
447
|
*/
|
|
446
|
-
export async function
|
|
448
|
+
export async function prepareTerminalLaunch(peer: TerminalPeer, projectRoot: string, stateDir: string, instanceId: string, options?: CommandRunner | TerminalRecoveryOptions): Promise<RecordedTerminalLaunch | undefined> {
|
|
447
449
|
const config = normalizeOptions(options);
|
|
448
450
|
const handle = process.env.ORCA_TERMINAL_HANDLE;
|
|
449
451
|
const worktreeIdFromEnv = process.env.ORCA_WORKTREE_ID;
|
|
@@ -465,17 +467,59 @@ export async function recordTerminalLaunch(peer: TerminalPeer, projectRoot: stri
|
|
|
465
467
|
process.env.AGENTHUB_LAUNCH_ID = launchId;
|
|
466
468
|
process.env.AGENTHUB_INSTANCE_ID = instanceId;
|
|
467
469
|
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 }) };
|
|
470
|
+
return row;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** Decode a console candidate against this daemon, never against paths supplied by that candidate. */
|
|
474
|
+
export function terminalLaunchCandidate(value: unknown, projectRoot: string, stateDir: string, instanceId: string): RecordedTerminalLaunch | undefined {
|
|
475
|
+
if (!validRecord(value) || JSON.stringify(value).length > 32 * 1024 || value.projectRoot !== projectRoot || value.stateDir !== stateDir || value.instanceId !== instanceId ||
|
|
476
|
+
!Number.isSafeInteger(value.launcherPid) || value.launcherPid <= 0 || value.launchId.length > 128 || value.launcherSignature.length > 256) return undefined;
|
|
477
|
+
try {
|
|
478
|
+
const expected = executionRoot(value.peer, projectRoot, value.executionCwd);
|
|
479
|
+
if (value.executionCwd !== undefined && value.executionCwd !== expected || value.terminalRoot !== undefined && value.terminalRoot !== projectRoot && value.terminalRoot !== expected) return undefined;
|
|
480
|
+
return { peer: value.peer, projectRoot, stateDir, instanceId, launcherPid: value.launcherPid, launcherSignature: value.launcherSignature,
|
|
481
|
+
launchId: value.launchId, handle: value.handle, incarnationId: value.incarnationId, worktreeId: value.worktreeId,
|
|
482
|
+
...(value.executionCwd ? { executionCwd: value.executionCwd } : {}), ...(value.terminalRoot ? { terminalRoot: value.terminalRoot } : {}), env: allowedEnv({ env: object(value.env) }) };
|
|
483
|
+
} catch { return undefined; }
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** Old daemons may launch the TUI, but cannot turn a pre-attach candidate into recovery authority. */
|
|
487
|
+
export async function registerTerminalLaunch(client: Pick<ControlClient, "features" | "request">, row: RecordedTerminalLaunch, operationId?: string): Promise<void> {
|
|
488
|
+
if (!client.features.includes("terminal_owner")) {
|
|
489
|
+
console.error("terminal launcher is unrecorded; upgrade the running hub for managed terminal recovery");
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
const result = await client.request({ t: "terminal_launch", launch: row, ...(operationId ? { operationId } : {}) });
|
|
493
|
+
if (result.ok !== true) throw new Error("terminal launch registration failed; inspect ahub status before launching again");
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** Capture alone is not ownership: a refused native child must leave the live row untouched. */
|
|
497
|
+
export async function recordTerminalLaunch(peer: TerminalPeer, projectRoot: string, stateDir: string, instanceId: string, options?: CommandRunner | TerminalRecoveryOptions, isCurrentOwner?: (row: RecordedTerminalLaunch) => boolean): Promise<RecordedTerminalLaunch | undefined> {
|
|
498
|
+
const row = await prepareTerminalLaunch(peer, projectRoot, stateDir, instanceId, options);
|
|
499
|
+
if (!row || !isCurrentOwner) return undefined;
|
|
500
|
+
return await publishTerminalLaunch(stateDir, row, () => isCurrentOwner(row), options) ? row : undefined;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/** Only the daemon's current native-owner proof may publish a captured candidate. No await after its final fence. */
|
|
504
|
+
export async function publishTerminalLaunch(stateDir: string, row: RecordedTerminalLaunch, isCurrent: () => boolean, options?: CommandRunner | TerminalRecoveryOptions): Promise<boolean> {
|
|
505
|
+
const config = normalizeOptions(options);
|
|
506
|
+
if (!isCurrent() || await launcherState(row, config.processIdentity, config.legacyIdentity) !== "live") return false;
|
|
507
|
+
const terminal = terminalObject(await run(config.runner, ["terminal", "show", "--terminal", row.handle, "--json"]));
|
|
508
|
+
const expectedRoot = executionRoot(row.peer, row.projectRoot, row.executionCwd);
|
|
509
|
+
if (nestedString(terminal, ["handle"]) !== row.handle || nestedString(terminal, ["incarnationId"]) !== row.incarnationId ||
|
|
510
|
+
nestedString(terminal, ["worktreeId"]) !== row.worktreeId || nestedString(terminal, ["worktreePath", "projectRoot"]) !== (row.terminalRoot ?? expectedRoot)) return false;
|
|
511
|
+
if (await launcherState(row, config.processIdentity, config.legacyIdentity) !== "live" || !terminalLaunchCandidate(row, row.projectRoot, stateDir, row.instanceId) || !isCurrent()) return false;
|
|
468
512
|
const existing = launchRecords(stateDir);
|
|
469
513
|
// #215: rewriting an unreadable file would replace every other launcher's record with this one, and recovery would
|
|
470
514
|
// then read those launchers as gone.
|
|
471
515
|
if (existing === "unreadable") {
|
|
472
|
-
throw new OrcaCommandError(blocker("command-error", `${recordPath(stateDir)} cannot be read, so recording this launch would erase every other launcher's record; next action: inspect that file and move it aside (recovery then treats sessions it recorded as unmanaged), then launch again`, peer, handle));
|
|
516
|
+
throw new OrcaCommandError(blocker("command-error", `${recordPath(stateDir)} cannot be read, so recording this launch would erase every other launcher's record; next action: inspect that file and move it aside (recovery then treats sessions it recorded as unmanaged), then launch again`, row.peer, row.handle));
|
|
473
517
|
}
|
|
474
518
|
// #228: every row that cannot be evaluated goes back unchanged; only this peer's valid row for this instance is replaced.
|
|
475
|
-
const rows = existing.all.filter((item) => !(validRecord(item) && item.peer === peer && item.instanceId === instanceId));
|
|
519
|
+
const rows = existing.all.filter((item) => !(validRecord(item) && item.peer === row.peer && item.projectRoot === row.projectRoot && item.instanceId === row.instanceId));
|
|
476
520
|
rows.push(row);
|
|
477
521
|
writeLaunchRecords(stateDir, rows);
|
|
478
|
-
return
|
|
522
|
+
return true;
|
|
479
523
|
}
|
|
480
524
|
|
|
481
525
|
function rootMatches(actual: unknown, expected: string): boolean {
|
|
@@ -567,6 +611,7 @@ function blocker(code: RecoveryBlocker["code"], message: string, peer?: Terminal
|
|
|
567
611
|
code === "ownership-unknown" ? "confirm the terminal owner in Orca, then resume recovery" :
|
|
568
612
|
code === "ambiguous-terminal" ? "close or identify the duplicate terminal, then resume recovery" :
|
|
569
613
|
code === "ambiguous-create" ? "inspect the existing terminal and attach the original session before resuming" :
|
|
614
|
+
code === "terminal-orphaned" ? `reattach the pane with orca terminal switch --terminal ${shellQuote(handle!)}; then refresh the recovery plan` :
|
|
570
615
|
code === "terminal-unready" ? "finish or cancel the active terminal turn, then resume recovery" :
|
|
571
616
|
code === "launcher-exited" ? "the session was not restored; the coordinator's error names the choices" :
|
|
572
617
|
code === "terminal-gone" ? "the terminal was closed; inspect the project's terminals in Orca before resuming recovery" :
|
|
@@ -610,6 +655,7 @@ export async function inspectSavedTerminal(binding: TerminalBinding, options?: C
|
|
|
610
655
|
if (!completeIdentity(shown)) return unknown();
|
|
611
656
|
const issue = verifyBindingData(shown, binding);
|
|
612
657
|
if (issue) return { state: "changed", blocker: issue };
|
|
658
|
+
if (shown.orphaned === true && shown.connected === true) return { state: "unknown", blocker: blocker("terminal-orphaned", "the saved terminal is orphaned but still connected", binding.peer, binding.handle) };
|
|
613
659
|
if (shown.orphaned === true) return { state: "orphaned", blocker: blocker("terminal-gone", "the saved terminal is orphaned", binding.peer, binding.handle) };
|
|
614
660
|
if (shown.connected === false) return unknown();
|
|
615
661
|
return { state: "present" };
|
|
@@ -725,6 +771,10 @@ export async function inspectTerminals(projectRoot: string, sessions: Partial<Re
|
|
|
725
771
|
blockers.push(blocker("stale-terminal", `terminal metadata lacks a stable handle, incarnation or worktree id`, peer, handle));
|
|
726
772
|
continue;
|
|
727
773
|
}
|
|
774
|
+
if (terminal.orphaned === true && terminal.connected === true) {
|
|
775
|
+
blockers.push(blocker("terminal-orphaned", `terminal ${handle} is orphaned but still connected`, peer, handle));
|
|
776
|
+
continue;
|
|
777
|
+
}
|
|
728
778
|
if (terminal.orphaned === true || terminal.connected === false) {
|
|
729
779
|
blockers.push(blocker("terminal-unready", `terminal ${handle} is orphaned or disconnected`, peer, handle));
|
|
730
780
|
continue;
|
|
@@ -84,6 +84,7 @@ const left = (op: { id: string; sourceRoot?: string }, io: ScreenIO): string =>
|
|
|
84
84
|
export function peerKind(planned: PlannedProject, peer: RecoveryPeer): "tui" | "headless" | "unmanaged" | "offline" {
|
|
85
85
|
if (peer.state === "offline") return "offline";
|
|
86
86
|
if ((planned.terminals as { peer: string }[]).some((t) => t.peer === peer.id)) return "tui";
|
|
87
|
+
if (planned.unrecordedTerminals?.includes(peer.id)) return "unmanaged";
|
|
87
88
|
const unrecorded = definitionOf(peer.id)?.session.unrecordedTerminal;
|
|
88
89
|
if (unrecorded === "always" || unrecorded === "tui" && peer.args?.mode === "tui") return "unmanaged";
|
|
89
90
|
return "headless";
|
|
@@ -91,10 +92,14 @@ export function peerKind(planned: PlannedProject, peer: RecoveryPeer): "tui" | "
|
|
|
91
92
|
|
|
92
93
|
function peerAction(planned: PlannedProject, peer: RecoveryPeer): string {
|
|
93
94
|
if (peer.state === "offline") return "offline, left as it is";
|
|
95
|
+
if (planned.unrecordedTerminals?.includes(peer.id)) return planned.reconnectOnly?.includes(peer.id)
|
|
96
|
+
? "attached, launcher unrecorded; reconnects by itself (its terminal is left alone)"
|
|
97
|
+
: "attached, launcher unrecorded; end it in its terminal or resume the original conversation, then refresh the plan";
|
|
94
98
|
if (planned.reconnectOnly?.includes(peer.id)) return "reconnects by itself (unmanaged session; its terminal is left alone)";
|
|
95
99
|
if (planned.freshStart?.includes(peer.id)) return "restarts as a new session (no turn to lose)";
|
|
96
100
|
const terminal = (planned.terminals as { peer: string; handle: string }[]).find((t) => t.peer === peer.id);
|
|
97
101
|
if (terminal) return `resumes its session in a new terminal (replaces ${terminal.handle})`;
|
|
102
|
+
if (peerKind(planned, peer) === "unmanaged") return "attached, terminal ownership unverified; resolve its evidence or end it in its terminal or resume the original conversation, then refresh the plan";
|
|
98
103
|
return definitionOf(peer.id)?.session.restoreOnStart ? "restarts headless on its recorded session" : "restarts headless as a new session";
|
|
99
104
|
}
|
|
100
105
|
|
|
@@ -252,6 +252,7 @@ export async function makeUpgradePlan(kind: "restart" | "upgrade", version: stri
|
|
|
252
252
|
if (source.recovery?.operationId && source.recovery.phase !== "released") blockers.push(`existing recovery operation ${source.recovery.operationId} must be resolved first`);
|
|
253
253
|
const sessions: Partial<Record<TerminalBinding["peer"], string | SessionRef>> = {};
|
|
254
254
|
const reconnectOnly: string[] = [];
|
|
255
|
+
const unrecordedTerminals: string[] = [];
|
|
255
256
|
for (const peer of source.peers) {
|
|
256
257
|
if (peer.state === "offline") continue;
|
|
257
258
|
const adapter = projectDefinition(peer.id, project.root);
|
|
@@ -271,6 +272,7 @@ export async function makeUpgradePlan(kind: "restart" | "upgrade", version: stri
|
|
|
271
272
|
}
|
|
272
273
|
const launcher = recorded?.state === "live" ? recorded.record! : undefined;
|
|
273
274
|
if (!launcher) {
|
|
275
|
+
unrecordedTerminals.push(peer.id);
|
|
274
276
|
delete peer.sessionId;
|
|
275
277
|
if (source.protocol === PROTOCOL) reconnectOnly.push(peer.id);
|
|
276
278
|
else blockers.push(`${peer.id}: unmanaged session (no live ahub claude launcher recorded) runs a protocol-${source.protocol} plugin and cannot reconnect to a protocol-${PROTOCOL} hub; next action: end that Claude session, or relaunch it with ahub claude in an Orca terminal, then make a new plan`);
|
|
@@ -300,6 +302,16 @@ export async function makeUpgradePlan(kind: "restart" | "upgrade", version: stri
|
|
|
300
302
|
...terminalOptions(run), stateDir: project.stateDir, instanceId: source.instanceId,
|
|
301
303
|
});
|
|
302
304
|
blockers.push(...terminals.blockers.map((b) => `${b.message}${b.terminalReference ? ` (terminal ${b.terminalReference})` : ""}${b.nextAction ? `; next action: ${b.nextAction}` : ""}`));
|
|
305
|
+
for (const peer of source.peers) {
|
|
306
|
+
if (peer.state === "offline" || unrecordedTerminals.includes(peer.id) || terminals.bindings.some(binding => binding.peer === peer.id)) continue;
|
|
307
|
+
const unrecorded = projectDefinition(peer.id, project.root)?.session.unrecordedTerminal;
|
|
308
|
+
if (unrecorded !== "always" && !(unrecorded === "tui" && peer.args?.mode === "tui")) continue;
|
|
309
|
+
const recorded = await launcherOf(peer.id, project.root, { ...terminalOptions(run), stateDir: project.stateDir, instanceId: source.instanceId });
|
|
310
|
+
// Unknown or unreadable evidence remains unknown; exact Orca ownership above is an independent positive proof.
|
|
311
|
+
if (recorded && recorded.state !== "gone") continue;
|
|
312
|
+
unrecordedTerminals.push(peer.id);
|
|
313
|
+
blockers.push(`${peer.id}: attached, launcher unrecorded; no current live launcher or verified terminal binding; manual-required; next action: end it in its terminal, or resume the original conversation through its native resume flow in an Orca terminal, then make a new plan`);
|
|
314
|
+
}
|
|
303
315
|
const freshStart: string[] = [];
|
|
304
316
|
for (const binding of terminals.bindings) if (projectDefinition(binding.peer, binding.projectRoot)?.session.missingEvidence === "rollout") {
|
|
305
317
|
const transcript = sessionState(binding);
|
|
@@ -309,7 +321,7 @@ export async function makeUpgradePlan(kind: "restart" | "upgrade", version: stri
|
|
|
309
321
|
? `${unresumable(binding)}; manual-required; next action: make that store readable, then make a new plan`
|
|
310
322
|
: `${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`);
|
|
311
323
|
}
|
|
312
|
-
body.projects.push({ project, source, terminals: terminals.bindings, blockers, ...(reconnectOnly.length ? { reconnectOnly } : {}), ...(freshStart.length ? { freshStart } : {}) });
|
|
324
|
+
body.projects.push({ project, source, terminals: terminals.bindings, blockers, ...(reconnectOnly.length ? { reconnectOnly } : {}), ...(unrecordedTerminals.length ? { unrecordedTerminals } : {}), ...(freshStart.length ? { freshStart } : {}) });
|
|
313
325
|
}
|
|
314
326
|
if (!body.projects.length) body.blockers.push("no running registered projects in scope");
|
|
315
327
|
return { ...body, fingerprint: planFingerprint(body) };
|