@staix/agent-hub 0.12.43 → 0.12.44
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 +16 -3
- package/README.md +3 -3
- package/docs/adding-an-ai-tool.md +140 -0
- package/docs/agent-notes/README.md +5 -0
- package/docs/agent-notes/adapters.md +8 -3
- package/docs/agent-notes/budget.md +2 -2
- package/docs/agent-notes/daemon.md +9 -5
- package/docs/agent-notes/tasks.md +6 -2
- package/docs/agent-notes/tests.md +3 -1
- package/docs/operations.md +64 -5
- package/docs/quickstart.md +6 -2
- package/docs/security.md +5 -3
- package/docs/specs/2026-09-19-agent-hub-design.md +160 -4
- package/docs/verification/2026-10-21-0.12.44.md +54 -0
- package/docs/verification/2026-10-21-grok-config-acp-t0.md +30 -0
- package/docs/verified.json +34 -19
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +743 -229
- package/src/adapters/acp-owned-group.ts +124 -0
- package/src/adapters/acp.ts +92 -25
- package/src/adapters/agy/definition.ts +1 -3
- package/src/adapters/agy/entry.ts +4 -1
- package/src/adapters/channel-peer.ts +143 -0
- package/src/adapters/claude-channel.ts +7 -7
- package/src/adapters/codex-appserver.ts +16 -2
- package/src/adapters/configured-acp.ts +124 -0
- package/src/adapters/definitions/builtin-metadata.ts +37 -33
- package/src/adapters/definitions/native-channel-state.ts +41 -0
- package/src/adapters/definitions/native-cli-entry-kimi.ts +3 -1
- package/src/adapters/definitions/native-cli-preview-qwen.ts +1 -1
- package/src/adapters/definitions/native-identity-defaults.ts +14 -0
- package/src/adapters/definitions/qwen.ts +3 -8
- package/src/adapters/definitions/reviewer-variants.ts +3 -7
- package/src/adapters/jcode.ts +4 -0
- package/src/adapters/local-worker.ts +4 -0
- package/src/adapters/pi.ts +4 -0
- package/src/adapters/registry-types.ts +18 -16
- package/src/adapters/registry.ts +68 -6
- package/src/adapters/reviewer-print.ts +4 -0
- package/src/cli/agy-mcp.ts +3 -2
- package/src/cli/configured-acp-entry.ts +58 -0
- package/src/cli/console-state.ts +9 -4
- package/src/cli/facts-hook.ts +2 -1
- package/src/cli/help.ts +6 -0
- package/src/cli/identity.ts +7 -6
- package/src/cli/launch.ts +5 -4
- package/src/cli/main.ts +78 -27
- package/src/cli/memory.ts +13 -10
- package/src/cli/output.ts +1 -0
- package/src/cli/preview.ts +9 -0
- package/src/cli/recovery-guidance.ts +1 -1
- package/src/cli/terminal-recovery.ts +16 -13
- package/src/cli/upgrade-restore-state.ts +3 -2
- package/src/cli/upgrade-runtime.ts +21 -6
- package/src/cli/upgrade.ts +3 -2
- package/src/hub/board.ts +9 -6
- package/src/hub/budget.ts +9 -2
- package/src/hub/bus.ts +6 -2
- package/src/hub/claude-state.ts +2 -37
- package/src/hub/conductor.ts +8 -6
- package/src/hub/config-apply.ts +26 -14
- package/src/hub/config-reload.ts +39 -11
- package/src/hub/config-trust.ts +1 -1
- package/src/hub/context-window.ts +6 -0
- package/src/hub/control-client.ts +1 -1
- package/src/hub/crash.ts +8 -6
- package/src/hub/daemon.ts +341 -313
- package/src/hub/hub-tools.ts +36 -0
- package/src/hub/instance-config.ts +20 -11
- package/src/hub/instance-execution.ts +4 -4
- package/src/hub/instances.ts +45 -21
- package/src/hub/lifecycle-schema.ts +5 -3
- package/src/hub/memory-profile.ts +16 -3
- package/src/hub/no-writes.ts +50 -0
- package/src/hub/permission-mode.ts +9 -7
- package/src/hub/roster.ts +7 -4
- package/src/hub/routing.ts +21 -20
- package/src/hub/settings.ts +50 -16
- package/src/hub/snapshots.ts +26 -2
- package/src/hub/start-mode.ts +10 -8
- package/src/hub/task-sweep.ts +8 -2
- package/src/hub/tasks.ts +85 -17
- package/src/memory/capture.ts +2 -1
- package/src/ui/index.html +5 -0
- package/templates/AGENTS.block.md +1 -1
- package/templates/profiles/general.json +1 -1
- package/templates/profiles/research.json +1 -1
- package/templates/profiles/writing.json +1 -1
package/src/cli/main.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
+
import { claudePeer as defaultChannelPeer } from "../hub/claude-state.ts";
|
|
2
3
|
import { effectiveInstanceRoles } from "../hub/instances.ts";
|
|
3
4
|
import { resolveInstanceExecutionCwd } from "../hub/instance-execution.ts";
|
|
4
|
-
import { readInstanceConfig } from "../hub/instance-config.ts";
|
|
5
|
+
import { readInstanceRoleConfig, readInstanceConfig, type InstanceRoleConfig } from "../hub/instance-config.ts";
|
|
6
|
+
import { parseConfiguredAcpArguments, runConfiguredAcpEntry, requireActiveConfiguredAcp } from "./configured-acp-entry.ts";
|
|
5
7
|
import type { NativeCliEntryContext } from "../adapters/definitions/native-cli-entry-types.ts";
|
|
6
|
-
import { BUILTIN_CLI_ENTRIES, definitionOf, DOCTOR_TOOL_PROBES, DOCTOR_MEMORY_CAPTURE_PEERS, DOCTOR_PLUGIN_PROBE, nativeDoctorCheck, nativeDoctorRecoveryIdentity, nativeDoctorOptionalProbes } from "../adapters/registry.ts";
|
|
8
|
+
import { BUILTIN_CLI_ENTRIES, createProjectAdapterCatalog, definitionOf, DOCTOR_TOOL_PROBES, DOCTOR_MEMORY_CAPTURE_PEERS, DOCTOR_PLUGIN_PROBE, nativeDoctorCheck, nativeDoctorRecoveryIdentity, nativeDoctorOptionalProbes } from "../adapters/registry.ts";
|
|
7
9
|
import { parsePiFlags } from "../adapters/definitions/native-cli-pi.ts";
|
|
8
10
|
import { supportsBoardReleaseFilter, supportsBoardColumnMetadata } from "./board-read.ts";
|
|
9
11
|
import { matchesPolicy } from "../hub/path-policy.ts";
|
|
@@ -79,7 +81,7 @@ import { recordCliAudit, recordDelegatedCliAudit } from "./identity-audit.ts";
|
|
|
79
81
|
import { runConsole } from "./console.ts";
|
|
80
82
|
import { paint, permissionBoundary, resolveColor, wrap, type Span } from "./console-state.ts";
|
|
81
83
|
import { outputWidth, renderRoster, renderStatus, renderBoard, renderBoardColumns, boardColumnModel, renderBudget, renderDoctor, renderProjects, renderQueue, renderTurns, renderOrphans, renderQueueShow, renderModelsStatus, renderExecutionBudgetStatus, renderReport, type DoctorCheck } from "./output.ts";
|
|
82
|
-
import { renderHelp } from "./help.ts";
|
|
84
|
+
import { renderHelp, configuredAcpHelp } from "./help.ts";
|
|
83
85
|
import { renderTailEvent } from "./tail-render.ts";
|
|
84
86
|
import { archiveProblem, archiveState, damagedState, failureText, MANIFEST, planReset, resetLines, resetRuntime, startState, type ResetPlan } from "./reset.ts";
|
|
85
87
|
|
|
@@ -118,16 +120,32 @@ try {
|
|
|
118
120
|
}
|
|
119
121
|
} else {
|
|
120
122
|
const markedPeer = process.env.AGENTHUB_PEER_ID, markedRoot = process.env.AGENTHUB_PROJECT_DIR;
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
+
const markedConfig = markedPeer && markedRoot && !definitionOf(markedPeer) ? readInstanceConfig(realPath(markedRoot)) : undefined;
|
|
124
|
+
const declarations = markedConfig?.instances;
|
|
125
|
+
if (markedPeer && markedRoot && markedConfig && declarations && Object.hasOwn(declarations, markedPeer)) {
|
|
123
126
|
const canonicalRoot = realPath(markedRoot), config = loadConfig(canonicalRoot);
|
|
124
|
-
const executionCwd = resolveInstanceExecutionCwd(canonicalRoot, markedPeer, declarations, effectiveInstanceRoles(markedPeer, declarations, config.roles));
|
|
127
|
+
const executionCwd = resolveInstanceExecutionCwd(canonicalRoot, markedPeer, declarations, effectiveInstanceRoles(markedPeer, declarations, config.roles, markedConfig.catalog), markedConfig.catalog);
|
|
125
128
|
if (realPath(projectRoot(process.cwd())) !== realPath(executionCwd)) fail("native instance workspace does not match its declared checkout");
|
|
126
129
|
selected = projectContext(canonicalRoot);
|
|
127
130
|
} else selected = projectContext(process.cwd());
|
|
128
131
|
}
|
|
129
132
|
} catch (error) { fail(startupDiagnostic(error)); }
|
|
130
133
|
const cwd = selected.root;
|
|
134
|
+
let peerConfig: ReturnType<typeof readInstanceConfig>;
|
|
135
|
+
const needsProjectDeclarations = cliCommandLabel(given, rest) === "unknown"
|
|
136
|
+
|| rest.some(arg => arg === "--as" || arg.startsWith("--as="))
|
|
137
|
+
|| !!process.env.AGENTHUB_PEER_ID && !definitionOf(process.env.AGENTHUB_PEER_ID)
|
|
138
|
+
|| given === "help" && !!rest[0] && cliCommandLabel(rest[0], []) === "unknown";
|
|
139
|
+
try { peerConfig = needsProjectDeclarations ? readInstanceConfig(cwd) : { catalog: createProjectAdapterCatalog(), ignored: [] }; } catch (error) { fail(startupDiagnostic(error)); }
|
|
140
|
+
/** Local declarations for a command the running daemon answers. A config file that is mid-edit or invalid must not
|
|
141
|
+
* block it (lowering a permission, listing or reloading settings): such a read falls back to builtins and keeps its
|
|
142
|
+
* diagnostic for a key that only a declaration could define. */
|
|
143
|
+
function localDeclarations(): { scope: Pick<InstanceRoleConfig, "instances" | "catalog">; error?: string } {
|
|
144
|
+
try { return { scope: readInstanceRoleConfig(cwd) }; }
|
|
145
|
+
catch (error) { return { scope: { catalog: createProjectAdapterCatalog() }, error: startupDiagnostic(error) }; }
|
|
146
|
+
}
|
|
147
|
+
const projectCatalog = peerConfig.catalog;
|
|
148
|
+
const configuredCommand = !definitionOf(given) && projectCatalog.definitionOf(given)?.permission.genericAcp !== undefined;
|
|
131
149
|
const unboundCaller = detectCliIdentity(process.env);
|
|
132
150
|
const markerNeedsDeclaration = unboundCaller.role === "invalid" && unboundCaller.reason === "AGENTHUB_PEER_ID conflicts with native agent marker"
|
|
133
151
|
&& process.env.AGENTHUB_PEER_ID !== undefined && !definitionOf(process.env.AGENTHUB_PEER_ID);
|
|
@@ -135,8 +153,11 @@ const needsInstances = definitionOf(given) && rest.some(arg => arg === "--as" ||
|
|
|
135
153
|
let declaredInstances: ReturnType<typeof readInstanceConfig>["instances"];
|
|
136
154
|
let nativeSelection: ReturnType<typeof instanceLaunchArguments>;
|
|
137
155
|
try {
|
|
138
|
-
declaredInstances = needsInstances ?
|
|
139
|
-
|
|
156
|
+
declaredInstances = needsInstances || configuredCommand ? peerConfig.instances : undefined;
|
|
157
|
+
if (configuredCommand && !(rest.length === 1 && ["--help", "-h"].includes(rest[0]!))) {
|
|
158
|
+
const selection = parseConfiguredAcpArguments(given, rest, projectCatalog, declaredInstances);
|
|
159
|
+
nativeSelection = { ...(selection.peer !== given ? { instance: selection.peer } : {}), args: [...rest] };
|
|
160
|
+
} else nativeSelection = instanceLaunchArguments(given, rest, declaredInstances);
|
|
140
161
|
} catch (error) { fail(startupDiagnostic(error)); }
|
|
141
162
|
const selectedInstance = nativeSelection.instance;
|
|
142
163
|
// `ahub <command> -h` alone asks for that command's help instead of running it. Elsewhere `-h` may be message text
|
|
@@ -146,10 +167,19 @@ const cmd = asksHelp(given) || (definitionOf(given)?.start.passthrough !== true
|
|
|
146
167
|
const args = cmd === "help" && given !== "help" && !asksHelp(given) ? [given] : nativeSelection.args;
|
|
147
168
|
|
|
148
169
|
/** Resolve a named peer only against this project's current trusted declarations. */
|
|
149
|
-
const peerDefinition = (peer: unknown) =>
|
|
150
|
-
|
|
170
|
+
const peerDefinition = (peer: unknown) => {
|
|
171
|
+
if (typeof peer !== "string") return undefined;
|
|
172
|
+
const bound = projectCatalog.definitionOf(peer, peerConfig.instances);
|
|
173
|
+
if (bound) return bound;
|
|
174
|
+
const current = readInstanceConfig(cwd);
|
|
175
|
+
return current.catalog.definitionOf(peer, current.instances);
|
|
176
|
+
};
|
|
151
177
|
const requireNamedPeerFeature = (hub: ControlClient, peer: string | undefined) => {
|
|
152
|
-
if (peer !== undefined && !definitionOf(peer))
|
|
178
|
+
if (peer !== undefined && !definitionOf(peer)) {
|
|
179
|
+
const adapter = peerDefinition(peer);
|
|
180
|
+
if (adapter?.permission.genericAcp !== undefined && !hub.features.includes("configured_acp")) throw new Error("running hub lacks configured_acp; upgrade and restart it first");
|
|
181
|
+
if (adapter?.kind !== peer) requirePeerInstances(hub.features);
|
|
182
|
+
}
|
|
153
183
|
};
|
|
154
184
|
|
|
155
185
|
/** The project config, saying on stderr which machine-local fields it had to ignore (issue #17). */
|
|
@@ -160,8 +190,8 @@ const projectConfig = () => {
|
|
|
160
190
|
};
|
|
161
191
|
const stateDir = selected.stateDir;
|
|
162
192
|
const caller = declaredInstances ? detectCliIdentity(process.env, declaredInstances) : unboundCaller;
|
|
163
|
-
const commandLabel = cliCommandLabel(cmd, args);
|
|
164
|
-
const commandAccess = classifyPeerCommand(cmd, args);
|
|
193
|
+
const commandLabel = cliCommandLabel(cmd, args, projectCatalog);
|
|
194
|
+
const commandAccess = classifyPeerCommand(cmd, args, projectCatalog);
|
|
165
195
|
// Agent-authored content keeps its peer. Operational starts/holds may borrow console authority for live verification.
|
|
166
196
|
const delegationRefused = liveTest && caller.role === "tools" && commandAccess !== "allowed" && liveTestRefusal(cmd, args);
|
|
167
197
|
const delegatedPeer = liveTest && caller.role === "tools" && commandAccess !== "allowed" && !delegationRefused ? caller.peer : undefined;
|
|
@@ -408,11 +438,11 @@ function fail(message: string): never {
|
|
|
408
438
|
process.exit(1);
|
|
409
439
|
}
|
|
410
440
|
|
|
411
|
-
async function taskOp(op: string, a: Record<string, unknown>, throwOnError = false, connection?: ControlClient): Promise<string> {
|
|
441
|
+
async function taskOp(op: string, a: Record<string, unknown>, throwOnError = false, connection?: ControlClient, timeoutMs?: number): Promise<string> {
|
|
412
442
|
const hub = connection ?? await connect();
|
|
413
443
|
try {
|
|
414
444
|
if (op === "hub_task_cancel" && !hub.features.includes("task_cancel")) fail("this daemon does not support task cancellation; upgrade it first");
|
|
415
|
-
const res = await hub.request({ t: "task", op, args: a }, op === "github_import" ? 60_000 : 30_000);
|
|
445
|
+
const res = await hub.request({ t: "task", op, args: a }, timeoutMs ?? (op === "github_import" ? 60_000 : 30_000));
|
|
416
446
|
if (!res.ok) { if (throwOnError) throw new Error(res.error); audit("refused"); fail(res.error); }
|
|
417
447
|
return res.text;
|
|
418
448
|
} finally { if (!connection) hub.close(); }
|
|
@@ -762,7 +792,8 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
762
792
|
help: () => {
|
|
763
793
|
const color = resolveColor(undefined, { isTTY: !!process.stdout.isTTY, TERM: process.env.TERM, NO_COLOR: process.env.NO_COLOR }) === true;
|
|
764
794
|
const topic = asksHelp(args[0]) ? undefined : args[0];
|
|
765
|
-
const text =
|
|
795
|
+
const text = topic && !definitionOf(topic) && projectCatalog.definitionOf(topic)?.permission.genericAcp !== undefined
|
|
796
|
+
? configuredAcpHelp(topic) : renderHelp(process.stdout.columns ?? 80, color, topic);
|
|
766
797
|
if (!text) fail(`unknown command "${topic}"; run ahub help`);
|
|
767
798
|
console.log(text);
|
|
768
799
|
},
|
|
@@ -1338,7 +1369,8 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1338
1369
|
const [peer, mode] = positional;
|
|
1339
1370
|
if (positional.length > 2 || positional.some(arg => arg.startsWith("--")) || (mode !== undefined && !isPermissionMode(mode))) fail("usage: ahub permission [<peer> [ask|ask-when-needed|never-ask]] [--yes]");
|
|
1340
1371
|
if (mode === "never-ask" && !args.includes("--yes")) fail("never-ask requires --yes; nothing was changed");
|
|
1341
|
-
|
|
1372
|
+
const { scope: permissionScope, error: permissionScopeError } = localDeclarations();
|
|
1373
|
+
if (mode === "never-ask" && peer) console.error(`never-ask: ${permissionBoundary(peer, permissionScope.instances, permissionScope.catalog)}; approval prompts are disabled${permissionScopeError ? ` (project config unreadable: ${permissionScopeError})` : ""}`);
|
|
1342
1374
|
const hub = await connect();
|
|
1343
1375
|
try {
|
|
1344
1376
|
const reply = await hub.request({ t: "permission", ...(peer ? { peer } : {}), ...(mode ? { mode, confirmed: args.includes("--yes") } : {}) }, 35_000);
|
|
@@ -1387,14 +1419,15 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1387
1419
|
const allowed: Record<string, string[]> = { list: ["--json"], get: ["--json"], set: ["--yes", "--preview", "--now"], undo: [], reload: [] };
|
|
1388
1420
|
if (!Object.hasOwn(allowed, sub) || flags.some((flag) => !allowed[sub]!.includes(flag)) || extra.length) fail(usage);
|
|
1389
1421
|
if ((sub === "get" && (!key || text !== undefined)) || (sub === "set" && (!key || text === undefined)) || ((sub === "list" || sub === "undo" || sub === "reload") && key !== undefined)) fail(usage);
|
|
1390
|
-
const
|
|
1391
|
-
|
|
1422
|
+
const { scope: settingScope, error: settingScopeError } = localDeclarations();
|
|
1423
|
+
const def = key === undefined ? undefined : settingDef(key, settingScope.instances, settingScope.catalog);
|
|
1424
|
+
if (typeof def === "string") fail(settingScopeError && def.startsWith("unknown setting") ? `${key}: ${def}; the project config could not be read: ${settingScopeError}` : `${key}: ${def}`);
|
|
1392
1425
|
if (flags.includes("--now") && (typeof def !== "object" || def.group !== "Roster")) fail(usage);
|
|
1393
1426
|
const value = sub === "set" ? parseSettingText(def!, text!) : undefined;
|
|
1394
1427
|
const peer = def?.group === "Permissions" || def?.group === "Roster" ? def.path.at(-1)! : undefined;
|
|
1395
1428
|
if (sub === "set" && value === "never-ask" && !flags.includes("--preview")) {
|
|
1396
1429
|
if (!flags.includes("--yes")) fail("never-ask requires --yes; nothing was changed");
|
|
1397
|
-
console.error(`never-ask: ${permissionBoundary(peer
|
|
1430
|
+
console.error(`never-ask: ${permissionBoundary(peer!, settingScope.instances, settingScope.catalog)}; approval prompts are disabled`);
|
|
1398
1431
|
}
|
|
1399
1432
|
const hub = await connect();
|
|
1400
1433
|
try {
|
|
@@ -1444,7 +1477,9 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1444
1477
|
return console.log(paint([{ text: result.text }], false));
|
|
1445
1478
|
}
|
|
1446
1479
|
if (sub === "undo") {
|
|
1447
|
-
const view = await request({ t: "settings_get" }), undoDef = settingDef(view.undo);
|
|
1480
|
+
const view = await request({ t: "settings_get" }), undoDef = settingDef(view.undo, settingScope.instances, settingScope.catalog);
|
|
1481
|
+
// A roster undo of a declared peer needs its confirmation prompt; without readable declarations, refuse rather than skip it.
|
|
1482
|
+
if (typeof undoDef === "string" && settingScopeError) fail(`pending undo ${String(view.undo)}: ${undoDef}; the project config could not be read: ${settingScopeError}`);
|
|
1448
1483
|
const rosterPeer = typeof undoDef !== "string" && undoDef.group === "Roster" ? undoDef.path[1] : undefined;
|
|
1449
1484
|
let confirm: string | undefined;
|
|
1450
1485
|
if (rosterPeer) {
|
|
@@ -1662,7 +1697,7 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1662
1697
|
facts: async () => {
|
|
1663
1698
|
if (!args.includes("--hook")) return fail("usage: ahub facts --hook (a Claude Code PreToolUse, PostToolUse and Stop hook)");
|
|
1664
1699
|
try {
|
|
1665
|
-
const out = await factsHook(await Bun.stdin.text(), stateDir, process.env.AGENTHUB_PEER_ID ??
|
|
1700
|
+
const out = await factsHook(await Bun.stdin.text(), stateDir, process.env.AGENTHUB_PEER_ID ?? defaultChannelPeer(undefined));
|
|
1666
1701
|
if (out) console.log(out);
|
|
1667
1702
|
} catch {
|
|
1668
1703
|
// a hook that fails must not get in the way of the tool call
|
|
@@ -1671,7 +1706,7 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1671
1706
|
"check-path": async () => {
|
|
1672
1707
|
const hook = args.includes("--hook");
|
|
1673
1708
|
const { one, rest } = takeFlags(args.filter((a) => a !== "--hook"), ["--peer"], []);
|
|
1674
|
-
const peer = one["--peer"] ??
|
|
1709
|
+
const peer = one["--peer"] ?? defaultChannelPeer(undefined);
|
|
1675
1710
|
let target = rest[0];
|
|
1676
1711
|
try {
|
|
1677
1712
|
if (hook) {
|
|
@@ -1728,10 +1763,10 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1728
1763
|
const undoWorkspace = (): string => {
|
|
1729
1764
|
// Old turns, including external peers and ids declared only later, belong to the canonical checkout.
|
|
1730
1765
|
if (recordedWorkspace === undefined) return cwd;
|
|
1731
|
-
const declarations = readInstanceConfig(cwd)
|
|
1766
|
+
const { instances: declarations, catalog } = readInstanceConfig(cwd);
|
|
1732
1767
|
if (!declarations || !Object.hasOwn(declarations, turn.peer)) fail(`turn ${id}'s recorded instance is no longer declared; nothing was restored`);
|
|
1733
1768
|
const config = projectConfig();
|
|
1734
|
-
const current = resolveInstanceExecutionCwd(cwd, turn.peer, declarations, effectiveInstanceRoles(turn.peer, declarations, config.roles));
|
|
1769
|
+
const current = resolveInstanceExecutionCwd(cwd, turn.peer, declarations, effectiveInstanceRoles(turn.peer, declarations, config.roles, catalog), catalog);
|
|
1735
1770
|
if (realPath(current) !== realPath(recordedWorkspace)) fail(`turn ${id}'s recorded execution workspace changed; nothing was restored`);
|
|
1736
1771
|
return current;
|
|
1737
1772
|
};
|
|
@@ -2061,7 +2096,18 @@ async function runConductorCommand(): Promise<void> {
|
|
|
2061
2096
|
input = { peer: args[0] };
|
|
2062
2097
|
} else {
|
|
2063
2098
|
// Native TUIs return a human launch command through the daemon; an agent shell never executes it.
|
|
2064
|
-
|
|
2099
|
+
if (configuredCommand) {
|
|
2100
|
+
const selection = parseConfiguredAcpArguments(cmd, args, projectCatalog, declaredInstances);
|
|
2101
|
+
if (selection.preview) return runConfiguredAcpEntry({ kind: cmd, raw: args, catalog: projectCatalog, instances: declaredInstances, connect, print: text => console.log(text) });
|
|
2102
|
+
if (selection.sessionId) fail("conductor starts accept no session override; a person starts a configured ACP session");
|
|
2103
|
+
const hub = await connect();
|
|
2104
|
+
try {
|
|
2105
|
+
await requireActiveConfiguredAcp(hub, cmd, selection.peer, text => console.error(text));
|
|
2106
|
+
console.log(await taskOp("hub_peer_start", { peer: selection.peer, mode: "headless" }, false, hub, 70_000));
|
|
2107
|
+
} finally { hub.close(); }
|
|
2108
|
+
return;
|
|
2109
|
+
}
|
|
2110
|
+
const parseStart = projectCatalog.definitionOf(cmd)?.start.conductorStartArgs;
|
|
2065
2111
|
const parsed = parseStart ? parseStart(args) : args.length ? new Error(`conductor starts accept no launch overrides; use ahub ${cmd}`) : {};
|
|
2066
2112
|
if (parsed instanceof Error) fail(parsed.message);
|
|
2067
2113
|
op = "hub_peer_start";
|
|
@@ -2071,7 +2117,12 @@ async function runConductorCommand(): Promise<void> {
|
|
|
2071
2117
|
if (selectedInstance) { const hub = await connect(); try { requirePeerInstances(hub.features); } finally { hub.close(); } }
|
|
2072
2118
|
console.log(await taskOp(op, input));
|
|
2073
2119
|
}
|
|
2074
|
-
const
|
|
2120
|
+
const runConfigured = configuredCommand ? async () => {
|
|
2121
|
+
const selection = parseConfiguredAcpArguments(cmd, args, projectCatalog, declaredInstances);
|
|
2122
|
+
if (!selection.preview) assertLifecycleAvailable();
|
|
2123
|
+
await runConfiguredAcpEntry({ kind: cmd, raw: args, catalog: projectCatalog, instances: declaredInstances, connect, print: text => console.log(text), operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
|
|
2124
|
+
} : undefined;
|
|
2125
|
+
const run = identity.role === "tools" && commandAccess === "conductor" ? runConductorCommand : (Object.hasOwn(commands, cmd) ? commands[cmd] : undefined) ?? runConfigured ?? (() => fail(`unknown command "${cmd}"; run ahub help`));
|
|
2075
2126
|
try {
|
|
2076
2127
|
assertDelegatedAuditConsumer(); // Local command paths must not begin after the authenticated consumer was replaced either.
|
|
2077
2128
|
await run();
|
package/src/cli/memory.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { detectCliIdentity } from "./identity.ts";
|
|
2
2
|
import { createStalenessProbe } from "../hub/memory-staleness.ts";
|
|
3
|
-
import { looksSecret, type MemoryStore } from "@staix/agent-mem";
|
|
3
|
+
import { looksSecret, memoryProjectKey, type MemoryStore } from "@staix/agent-mem";
|
|
4
4
|
import type { SyncOptions } from "@staix/agent-mem";
|
|
5
5
|
import { MEMORY_USAGE as CORE_MEMORY_USAGE, rawMemoryCommand, rawGoalCommand } from "@staix/agent-mem";
|
|
6
6
|
import { mentionsPrivatePath, promotionScreen, withoutPii } from "../hub/memory-policy.ts";
|
|
7
7
|
import { currentRouting, detectSignals } from "../hub/routing.ts";
|
|
8
8
|
import { loadConfig } from "../hub/daemon.ts";
|
|
9
|
-
import { bindProfileMemory, memoryProjectFor, openProfileMemory, MEMORY_PROFILE_UNAVAILABLE, MEMORY_SPLIT_INCOMPLETE, type MemoryProfileReceipt, type ProfileMemoryOptions } from "../hub/memory-profile.ts";
|
|
9
|
+
import { bindProfileMemory, memoryProjectFor, memoryProjectForStore, openProfileMemory, MEMORY_PROFILE_UNAVAILABLE, MEMORY_SPLIT_INCOMPLETE, type MemoryProfileReceipt, type ProfileMemoryOptions } from "../hub/memory-profile.ts";
|
|
10
10
|
|
|
11
11
|
export { GOAL_USAGE } from "@staix/agent-mem";
|
|
12
12
|
export const MEMORY_USAGE = `${CORE_MEMORY_USAGE} | decay [--days N] [--json] | profile-check [--json]`;
|
|
@@ -16,9 +16,12 @@ const fail = (message: string): never => { throw new Error(message); };
|
|
|
16
16
|
const loadConfigSafe = (cwd: string) => { try { return loadConfig(cwd); } catch { return undefined; } };
|
|
17
17
|
|
|
18
18
|
|
|
19
|
+
/** The project key: an injected store resolves the alias in its own home, the default open in the ambient one (#240). */
|
|
20
|
+
const projectOf = (cwd: string, provided: MemoryStore | undefined): string => provided ? memoryProjectForStore(provided, cwd) : memoryProjectFor(cwd);
|
|
21
|
+
|
|
19
22
|
/** Bind each command at entry; the CLI closes default opens and injected stores remain caller-owned. */
|
|
20
|
-
function commandStores(provided: MemoryStore | undefined, options: ProfileMemoryOptions) {
|
|
21
|
-
try { return { ...(provided ? bindProfileMemory(provided) : openProfileMemory(options)), owned: !provided }; }
|
|
23
|
+
function commandStores(provided: MemoryStore | undefined, options: ProfileMemoryOptions, cwd: string) {
|
|
24
|
+
try { return { ...(provided ? bindProfileMemory(provided, undefined, memoryProjectKey(cwd)) : openProfileMemory(options)), owned: !provided }; }
|
|
22
25
|
catch (error) { throw new Error(error instanceof Error && error.message === MEMORY_SPLIT_INCOMPLETE ? error.message : MEMORY_PROFILE_UNAVAILABLE); }
|
|
23
26
|
}
|
|
24
27
|
function profileOutput(text: string, json: boolean, profile: MemoryProfileReceipt | undefined, person: boolean): string {
|
|
@@ -43,9 +46,9 @@ export async function memoryCommand(args: string[], cwd: string, opts: { store?:
|
|
|
43
46
|
const cutoffMs = (opts.now ?? Date.now)() - days * 24 * 3600_000;
|
|
44
47
|
if (!Number.isSafeInteger(days) || !Number.isFinite(cutoffMs) || Math.abs(cutoffMs) > 8_640_000_000_000_000) fail("--days is outside the supported date range");
|
|
45
48
|
const cutoff = new Date(cutoffMs).toISOString();
|
|
46
|
-
const project =
|
|
49
|
+
const project = projectOf(cwd, opts.store), routing = currentRouting(cwd), paths = privatePaths();
|
|
47
50
|
// Review must not promote a held source while reading it: only explicit person actions may append rows.
|
|
48
|
-
const context = commandStores(opts.store, { project, deferPromotions: true, now: opts.now });
|
|
51
|
+
const context = commandStores(opts.store, { project, deferPromotions: true, now: opts.now }, cwd);
|
|
49
52
|
const store = context.store;
|
|
50
53
|
try {
|
|
51
54
|
context.binding.assertCurrent();
|
|
@@ -67,9 +70,9 @@ export async function memoryCommand(args: string[], cwd: string, opts: { store?:
|
|
|
67
70
|
} finally { if (context.owned) store.close(); }
|
|
68
71
|
}
|
|
69
72
|
let showStale = false;
|
|
70
|
-
const project =
|
|
73
|
+
const project = projectOf(cwd, opts.store);
|
|
71
74
|
const context = commandStores(opts.store, { project, ttlMs: (loadConfigSafe(cwd)?.memory.short_ttl_days ?? 14) * 24 * 3600_000,
|
|
72
|
-
screen: promotionScreen(project, cwd, () => currentRouting(cwd), privatePaths) });
|
|
75
|
+
screen: promotionScreen(project, cwd, () => currentRouting(cwd), privatePaths) }, cwd);
|
|
73
76
|
try {
|
|
74
77
|
context.binding.assertCurrent();
|
|
75
78
|
const text = await rawMemoryCommand(args, cwd, { ...opts, project, store: context.store, open: () => context.store,
|
|
@@ -92,8 +95,8 @@ export async function memoryCommand(args: string[], cwd: string, opts: { store?:
|
|
|
92
95
|
export async function goalCommand(args: string[], cwd: string, opts: { store?: MemoryStore; link?: (goal: string, task: string) => Promise<string>; person?: boolean } = {}): Promise<string> {
|
|
93
96
|
const person = opts.person ?? detectCliIdentity(process.env).role === "console";
|
|
94
97
|
const policy = (): string[] => { try { return loadConfig(cwd).pii?.paths ?? []; } catch (e) { return fail(`the project's config does not load (${(e as Error).message}); fix it before using long-term tasks`); } };
|
|
95
|
-
const project =
|
|
96
|
-
const context = commandStores(opts.store, { project });
|
|
98
|
+
const project = projectOf(cwd, opts.store);
|
|
99
|
+
const context = commandStores(opts.store, { project }, cwd);
|
|
97
100
|
try {
|
|
98
101
|
context.binding.assertCurrent();
|
|
99
102
|
const text = await rawGoalCommand(args, cwd, { ...opts, project, store: context.store, open: () => context.store,
|
package/src/cli/output.ts
CHANGED
|
@@ -246,6 +246,7 @@ export function renderStatus(data: StatusOutput, columns?: number, now = Date.no
|
|
|
246
246
|
if (p.toolsOnly) out.push(...detail(definitionOf(id)?.attach?.pullOnlyForms?.includes("tui") && p.pullOnly ? "pull-only" : "tools-only", flat(p.toolsOnly).replace(/^(?:tools-only|pull-only):\s*/, ""), columns, "attention"));
|
|
247
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"));
|
|
248
248
|
if (p.permissionNote) out.push(...detail("permission", p.permissionNote, columns, "muted"));
|
|
249
|
+
if (typeof p.statusNotice?.message === "string" && p.statusNotice.message) out.push(...detail("status notice", p.statusNotice.message, columns, "attention"));
|
|
249
250
|
if (p.requestedModel && p.servedBy && p.requestedModel !== p.servedBy) out.push(...detail("requested model", p.requestedModel, columns));
|
|
250
251
|
if (typeof p.importantWaitingSince === "number" && p.importantWaitingNote) out.push(...detail("important waiting", `${p.importantWaitingNote.replace(/^\d+ important messages? waiting/, "important messages waiting")}`, columns, "attention"));
|
|
251
252
|
if (typeof p.oldestQueuedAt === "number") out.push(...detail(`${id} oldest queued`, relative(p.oldestQueuedAt, now), columns, "muted"));
|
package/src/cli/preview.ts
CHANGED
|
@@ -32,3 +32,12 @@ export function launcherPreview(tool: "claude" | "codex" | "kimi" | "pi" | "qwen
|
|
|
32
32
|
unresolved: [...result.unresolved, { field: "executionCwd", reason: "verified by the daemon at instance start; preview does not start or authorize a worktree" }],
|
|
33
33
|
};
|
|
34
34
|
}
|
|
35
|
+
|
|
36
|
+
/** Configured commands are arbitrary machine-local argv; preview never displays their values or starts a child. */
|
|
37
|
+
export function configuredAcpPreview(kind: string, peer: string, resumed: boolean) {
|
|
38
|
+
return { tool: kind, peer, mode: "headless", cmd: "[redacted]", args: ["[redacted]"],
|
|
39
|
+
...(resumed ? { sessionId: "[redacted]" } : {}),
|
|
40
|
+
envNames: [], redaction: "Configured executable, argv and session values are withheld.",
|
|
41
|
+
unresolved: [{ field: "runtime", reason: "Native identity, owned process group, accounts and daemon catalog are checked at start; preview runs nothing." }],
|
|
42
|
+
needs: ["A trusted config.local.json declaration and a running hub with configured_acp support."] };
|
|
43
|
+
}
|
|
@@ -27,7 +27,7 @@ export interface RecoveryGuidanceOptions {
|
|
|
27
27
|
|
|
28
28
|
const text = (value: unknown): value is string => typeof value === "string" && value.length > 0;
|
|
29
29
|
const argv = (...words: string[]): string => words.map(shellQuote).join(" ");
|
|
30
|
-
const projectDefinition = (peer: string, root: string) => definitionOf(peer)
|
|
30
|
+
const projectDefinition = (peer: string, root: string) => { const builtin = definitionOf(peer); if (builtin) return builtin; const bound = readInstanceConfig(root); return bound.catalog.definitionOf(peer, bound.instances); };
|
|
31
31
|
const peers = (instances: KindMap | undefined): RecoveryNativePeer[] => [...ADAPTERS.map(adapter => adapter.kind), ...Object.keys(instances ?? {})]
|
|
32
32
|
.filter(peer => definitionOf(peer, instances)?.session.launchRecord)
|
|
33
33
|
.sort((a, b) => Number(definitionOf(a, instances)?.transport === "claude-channel") - Number(definitionOf(b, instances)?.transport === "claude-channel"));
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { shellQuote } from "./shell-quote.ts";
|
|
2
2
|
export { shellQuote } from "./shell-quote.ts";
|
|
3
|
-
import { definitionOf, type KindMap } from "../adapters/registry.ts";
|
|
3
|
+
import { createProjectAdapterCatalog, definitionOf, type ProjectAdapterCatalog, type KindMap } from "../adapters/registry.ts";
|
|
4
4
|
import { readInstanceConfig, readInstanceRoleConfig } from "../hub/instance-config.ts";
|
|
5
5
|
import { effectiveInstanceRoles } from "../hub/instances.ts";
|
|
6
6
|
import { resolveInstanceExecutionCwd } from "../hub/instance-execution.ts";
|
|
@@ -332,8 +332,8 @@ export function recordPath(stateDir: string): string {
|
|
|
332
332
|
function executionRoot(peer: string, projectRoot: string, captured?: string): string {
|
|
333
333
|
const builtin = definitionOf(peer);
|
|
334
334
|
if (builtin?.session.launchRecord) return projectRoot;
|
|
335
|
-
const { instances: declarations, roles } = readInstanceRoleConfig(projectRoot);
|
|
336
|
-
const definition = definitionOf(peer, declarations);
|
|
335
|
+
const { instances: declarations, roles, catalog } = readInstanceRoleConfig(projectRoot);
|
|
336
|
+
const definition = catalog.definitionOf(peer, declarations);
|
|
337
337
|
if (!definition?.session.launchRecord) throw new Error(`${peer}: no managed terminal recovery adapter`);
|
|
338
338
|
if (!declarations?.[peer] || definition.transport !== "codex-appserver" && definition.transport !== "claude-channel") throw new Error(`${peer}: extra instance has no managed terminal support`);
|
|
339
339
|
// #450 I3: a Claude instance reviews or plans in the canonical checkout; it never owns an execution worktree.
|
|
@@ -341,7 +341,7 @@ function executionRoot(peer: string, projectRoot: string, captured?: string): st
|
|
|
341
341
|
if (captured !== undefined && captured !== projectRoot) throw new Error(`${peer}: recorded execution root changed; manual recovery required`);
|
|
342
342
|
return projectRoot;
|
|
343
343
|
}
|
|
344
|
-
const expected = resolveInstanceExecutionCwd(projectRoot, peer, declarations, effectiveInstanceRoles(peer, declarations, roles));
|
|
344
|
+
const expected = resolveInstanceExecutionCwd(projectRoot, peer, declarations, effectiveInstanceRoles(peer, declarations, roles, catalog), catalog);
|
|
345
345
|
if (captured !== undefined && captured !== expected) throw new Error(`${peer}: declared execution worktree changed; manual recovery required`);
|
|
346
346
|
return expected;
|
|
347
347
|
}
|
|
@@ -556,12 +556,12 @@ function sessionFrom(terminal: Record<string, unknown>): string | undefined {
|
|
|
556
556
|
return nestedString(terminal, ["sessionId", "agentSessionId", "resumeSessionId"]) ?? nestedString(wait, ["sessionId", "agentSessionId", "resumeSessionId", "session", "agentSession"]) ?? nestedString(session, ["id", "sessionId"]);
|
|
557
557
|
}
|
|
558
558
|
|
|
559
|
-
function identityFrom(terminal: Record<string, unknown>, instances
|
|
559
|
+
function identityFrom(terminal: Record<string, unknown>, instances: KindMap | undefined, catalog: ProjectAdapterCatalog): TerminalPeer | undefined {
|
|
560
560
|
const wait = object(terminal.agentWait);
|
|
561
561
|
const session = object(terminal.session);
|
|
562
562
|
const candidates = [terminal.agentIdentity, terminal.agent, wait.agentIdentity, wait.agent, session.agentIdentity, session.agent];
|
|
563
563
|
for (const candidate of candidates) {
|
|
564
|
-
if (typeof candidate === "string" && definitionOf(candidate, instances)?.session.launchRecord) return candidate as TerminalPeer;
|
|
564
|
+
if (typeof candidate === "string" && catalog.definitionOf(candidate, instances)?.session.launchRecord) return candidate as TerminalPeer;
|
|
565
565
|
}
|
|
566
566
|
return undefined;
|
|
567
567
|
}
|
|
@@ -599,7 +599,8 @@ function launchFor(peer: TerminalPeer, projectRoot: string, sessionId: string, o
|
|
|
599
599
|
const binding: TerminalBinding = { peer, projectRoot, sessionId, handle: nestedString(source, ["handle"])!,
|
|
600
600
|
incarnationId: nestedString(source, ["incarnationId"])!, worktreeId: nestedString(source, ["worktreeId"])!,
|
|
601
601
|
...(ref?.sessionFile ? { sessionFile: ref.sessionFile } : {}), ...(ref?.backend ? { backend: ref.backend } : {}), ...(ref?.model ? { model: ref.model } : {}), launch, launchMetadata: launch };
|
|
602
|
-
const
|
|
602
|
+
const scope = definitionOf(peer) ? undefined : readInstanceConfig(projectRoot);
|
|
603
|
+
const adapter = definitionOf(peer) ?? scope!.catalog.definitionOf(peer, scope!.instances);
|
|
603
604
|
const nativeTail = adapter?.session.resumeArgv?.(binding, false);
|
|
604
605
|
const tail = nativeTail && adapter ? [nativeTail[0]!, ...(peer !== adapter.kind ? ["--as", peer] : []), ...nativeTail.slice(1)] : undefined;
|
|
605
606
|
if (!tail) throw new Error(`${peer}: no recorded terminal resume adapter`);
|
|
@@ -697,7 +698,8 @@ export async function inspectTerminals(projectRoot: string, sessions: Partial<Re
|
|
|
697
698
|
const byPeer: Partial<Record<TerminalPeer, TerminalBinding>> = {};
|
|
698
699
|
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);
|
|
699
700
|
if (!requested.length) return { bindings, byPeer, blockers, manualRequired: false };
|
|
700
|
-
const
|
|
701
|
+
const scope = requested.some(([peer]) => definitionOf(peer) === undefined) ? readInstanceConfig(projectRoot) : { instances: undefined, catalog: createProjectAdapterCatalog() };
|
|
702
|
+
const { instances, catalog } = scope;
|
|
701
703
|
// #215: without the records a terminal could be bound from Orca metadata alone, losing the launch's captured
|
|
702
704
|
// CODEX_HOME or CLAUDE_CONFIG_DIR: an unreadable file blocks the binding, never reads as "no records".
|
|
703
705
|
const rows = launchRecords(config.stateDir);
|
|
@@ -714,7 +716,7 @@ export async function inspectTerminals(projectRoot: string, sessions: Partial<Re
|
|
|
714
716
|
|
|
715
717
|
for (const [peer, ref] of requested) {
|
|
716
718
|
const sessionId = ref.sessionId;
|
|
717
|
-
const adapter = definitionOf(peer, instances), extra = definitionOf(peer) === undefined;
|
|
719
|
+
const adapter = catalog.definitionOf(peer, instances), extra = definitionOf(peer) === undefined;
|
|
718
720
|
if (!adapter?.session.launchRecord || extra && adapter.transport !== "codex-appserver" && adapter.transport !== "claude-channel") { blockers.push(blocker("ownership-unknown", `${peer}: no declared managed-terminal recovery adapter`, peer)); continue; }
|
|
719
721
|
const ownRecord = records.find(item => item.peer === peer);
|
|
720
722
|
let expectedRoot: string, expectedTerminalRoot: string;
|
|
@@ -747,7 +749,7 @@ export async function inspectTerminals(projectRoot: string, sessions: Partial<Re
|
|
|
747
749
|
}
|
|
748
750
|
// A show readback is required; list can be stale between recovery steps.
|
|
749
751
|
if (!rootMatches(terminal.worktreePath ?? terminal.projectRoot, expectedTerminalRoot)) continue;
|
|
750
|
-
const identity = identityFrom(terminal, instances);
|
|
752
|
+
const identity = identityFrom(terminal, instances, catalog);
|
|
751
753
|
const terminalSession = sessionFrom(terminal);
|
|
752
754
|
const recorded = records.find((item) => item.peer === peer && item.handle === handle && item.worktreeId === terminal.worktreeId && item.incarnationId === terminal.incarnationId);
|
|
753
755
|
// A recorded launcher proves ownership; the daemon's supplied session id is the
|
|
@@ -755,7 +757,7 @@ export async function inspectTerminals(projectRoot: string, sessions: Partial<Re
|
|
|
755
757
|
if ((!extra && identity === peer && terminalSession === sessionId) || (recorded !== undefined && (identity === undefined || identity === peer || extra && identity === adapter.kind) && (terminalSession === undefined || terminalSession === sessionId))) peerCandidates.push(terminal);
|
|
756
758
|
}
|
|
757
759
|
if (!peerCandidates.length) {
|
|
758
|
-
const ownershipUnknown = candidates.some((terminal) => identityFrom(terminal, instances) === undefined && !records.some((item) => item.handle === terminal.handle));
|
|
760
|
+
const ownershipUnknown = candidates.some((terminal) => identityFrom(terminal, instances, catalog) === undefined && !records.some((item) => item.handle === terminal.handle));
|
|
759
761
|
blockers.push(ownershipUnknown
|
|
760
762
|
? blocker("ownership-unknown", `cannot prove ${peer} terminal ownership from Orca metadata`, peer)
|
|
761
763
|
: blocker("missing-terminal", `no ${peer} terminal with session ${sessionId} in ${projectRoot}`, peer));
|
|
@@ -784,7 +786,7 @@ export async function inspectTerminals(projectRoot: string, sessions: Partial<Re
|
|
|
784
786
|
const recorded = records.find((item) => item.peer === peer && item.handle === handle && item.worktreeId === terminal.worktreeId && item.incarnationId === terminal.incarnationId);
|
|
785
787
|
// Orca observation supplies file identity; launch arguments are not observed native session-file evidence.
|
|
786
788
|
const { args: _launchArgs, ...observedTerminal } = terminal;
|
|
787
|
-
const sessionFile = definitionOf(peer, instances)?.session.crashIdentity?.(observedTerminal).sessionFile;
|
|
789
|
+
const sessionFile = catalog.definitionOf(peer, instances)?.session.crashIdentity?.(observedTerminal).sessionFile;
|
|
788
790
|
const launch = launchFor(peer, projectRoot, sessionId, config, terminal, recorded, { ...ref, ...(sessionFile ? { sessionFile } : {}) });
|
|
789
791
|
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 };
|
|
790
792
|
bindings.push(binding);
|
|
@@ -919,7 +921,8 @@ export async function createTerminal(binding: TerminalBinding, options?: Command
|
|
|
919
921
|
const launch = await launches();
|
|
920
922
|
if (launch.gone) return exited("exited");
|
|
921
923
|
const shown = await showBinding(replacement, config);
|
|
922
|
-
const
|
|
924
|
+
const scope = definitionOf(binding.peer) ? { instances: undefined, catalog: createProjectAdapterCatalog() } : readInstanceConfig(binding.projectRoot);
|
|
925
|
+
const identity = identityFrom(shown, scope.instances, scope.catalog);
|
|
923
926
|
session = sessionFrom(shown);
|
|
924
927
|
const recorded = launch.live;
|
|
925
928
|
if ((identity === binding.peer && same(session)) || (recorded && (identity === undefined || identity === binding.peer) && (session === undefined || same(session)))) break;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { definitionOf } from "../adapters/registry.ts";
|
|
1
|
+
import { ADAPTERS, definitionOf } from "../adapters/registry.ts";
|
|
2
2
|
import { readInstanceConfig } from "../hub/instance-config.ts";
|
|
3
3
|
import type { TerminalBinding } from "./terminal-recovery.ts";
|
|
4
4
|
|
|
@@ -102,7 +102,8 @@ export function restoreTransition(receipt: RestoreReceipt, evidence: RestoreEvid
|
|
|
102
102
|
/** Checkable compact documentation, enumerated through the same pure decision function. */
|
|
103
103
|
export function renderRestoreTransitionTable(): string {
|
|
104
104
|
const launch = { packageEntrypoint: "table", command: "table", argv: [], env: {} };
|
|
105
|
-
const
|
|
105
|
+
const examplePeer = ADAPTERS.find(definition => definition.transport === "codex-appserver")!.kind;
|
|
106
|
+
const binding: TerminalBinding = { peer: examplePeer, handle: "selected", incarnationId: "selected", worktreeId: "selected", projectRoot: "table", sessionId: "planned", launch, launchMetadata: launch };
|
|
106
107
|
const receipts: RestoreReceipt[] = [{ state: "pending" }, { state: "waiting", origin: "pending", binding }, { state: "waiting", origin: "failed", binding }, { state: "done", binding }, { state: "failed" }];
|
|
107
108
|
const launchers: RestoreEvidence["launcher"][] = ["live", "gone", "unknown", "no-record-yet", "replaced", "older-instance"];
|
|
108
109
|
const result = (receipt: RestoreReceipt, evidence: RestoreEvidence, unchanged = false) => {
|
|
@@ -35,7 +35,7 @@ export const PACKAGE_ROOT = resolve(import.meta.dir, "../..");
|
|
|
35
35
|
const orcaExecutable = () => process.env.ORCA_CLI_COMMAND || (process.env.ORCA_DEV_REPO_ROOT ? "orca-dev" : process.platform === "linux" && !process.env.ORCA_TERMINAL_HANDLE ? "orca-ide" : "orca");
|
|
36
36
|
|
|
37
37
|
/** Definitions are resolved against the owning project's current trusted declarations, never a global instance map. */
|
|
38
|
-
const projectDefinition = (peer: string, projectRoot: string) => definitionOf(peer)
|
|
38
|
+
const projectDefinition = (peer: string, projectRoot: string) => { const builtin = definitionOf(peer); if (builtin) return builtin; const bound = readInstanceConfig(projectRoot); return bound.catalog.definitionOf(peer, bound.instances); };
|
|
39
39
|
|
|
40
40
|
const sessionState = (binding: TerminalBinding): "found" | "missing" | "unknown" => {
|
|
41
41
|
const state = projectDefinition(binding.peer, binding.projectRoot)?.session.transcriptExists?.(binding);
|
|
@@ -144,7 +144,14 @@ export async function endPlannedPeer(planned: PlannedProject, peer: RecoveryPeer
|
|
|
144
144
|
|
|
145
145
|
/** Declared identities cannot be reinterpreted against an older running source's unbound peer rows. */
|
|
146
146
|
export function instanceRecoverySourceRefusal(projectRoot: string, features: unknown, observedPeers?: unknown): string | undefined {
|
|
147
|
-
const
|
|
147
|
+
const bound = readInstanceConfig(projectRoot);
|
|
148
|
+
const configured = Object.keys(bound.adapters ?? {});
|
|
149
|
+
const configuredIds = [...configured, ...Object.entries(bound.instances ?? {}).filter(([, entry]) => configured.includes(entry.kind)).map(([id]) => id)];
|
|
150
|
+
if (configuredIds.length && !(Array.isArray(features) && features.every(feature => typeof feature === "string") && features.includes("configured_acp"))) {
|
|
151
|
+
const rows = observedPeers && typeof observedPeers === "object" && !Array.isArray(observedPeers) ? Object.entries(observedPeers) : undefined;
|
|
152
|
+
if (!rows || !rows.every(([, peer]) => !!peer && typeof peer === "object" && typeof (peer as { state?: unknown }).state === "string") || rows.some(([id, peer]) => configuredIds.includes(id) && (peer as { state: string }).state !== "offline")) return "source lacks configured_acp; configured native identities require a supporting source before recovery";
|
|
153
|
+
}
|
|
154
|
+
const declared = Object.keys(bound.instances ?? {});
|
|
148
155
|
if (!declared.length) return undefined;
|
|
149
156
|
if (Array.isArray(features) && features.every(feature => typeof feature === "string") && features.includes("peer_instances")) return undefined;
|
|
150
157
|
if (observedPeers && typeof observedPeers === "object" && !Array.isArray(observedPeers)) {
|
|
@@ -178,10 +185,16 @@ export async function inspectRecovery(project: Project, targetFeatures?: readonl
|
|
|
178
185
|
}
|
|
179
186
|
// A dead pid or a missing project directory is final whatever protocol its manifest names: probing it would read
|
|
180
187
|
// "unavailable" forever and wedge abort, resume and stop-and-archive (#215).
|
|
181
|
-
if (base.state === "stopped" || base.state === "missing" || (base.state !== "running" && !legacySupported))
|
|
182
|
-
|
|
188
|
+
if (base.state === "stopped" || base.state === "missing" || (base.state !== "running" && !legacySupported)) {
|
|
189
|
+
let blockers: string[];
|
|
190
|
+
if (base.state === "stopped") blockers = snapshotError ? [snapshotError.message] : [];
|
|
191
|
+
else if (base.state === "incompatible") blockers = ["manual-bootstrap-required: source lacks the recovery contract; use its matching CLI"];
|
|
192
|
+
else blockers = [base.error ?? base.state];
|
|
193
|
+
return {
|
|
194
|
+
state: base.state, peers: [], blockers,
|
|
183
195
|
...(control?.instanceId ? { instanceId: control.instanceId } : {}), ...(control?.protocol ? { protocol: control.protocol } : {}), ...(snapshot ? { snapshot } : {}), ...(snapshotError ? { snapshotError } : {}),
|
|
184
|
-
|
|
196
|
+
};
|
|
197
|
+
}
|
|
185
198
|
let status = base.status;
|
|
186
199
|
if (!status && control && sourceProtocol === PROTOCOL) return {
|
|
187
200
|
state: "unavailable", peers: [], blockers: ["runtime changed during recovery inspection"],
|
|
@@ -613,6 +626,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
613
626
|
return { ...target, ...(op.targetFeatures === undefined ? {} : { targetFeatures: [...op.targetFeatures] }) };
|
|
614
627
|
}
|
|
615
628
|
if (cached && op.targetFeatures !== undefined) {
|
|
629
|
+
if (!op.targetFeatures.includes("configured_acp") && op.plan.projects.some(planned => typeof planned.project?.root === "string" && Object.keys(readInstanceConfig(planned.project.root).adapters ?? {}).length > 0)) throw new FinalRefusal("target lacks configured_acp; configured adapters require a supporting target before recovery effects");
|
|
616
630
|
if (!op.targetFeatures.includes("peer_instances") && op.plan.projects.some(planned => typeof planned.project?.root === "string" && Object.keys(readInstanceConfig(planned.project.root).instances ?? {}).length > 0)) throw new FinalRefusal("target lacks peer_instances; declared project instances require a supporting target before recovery effects");
|
|
617
631
|
return { ...target, targetFeatures: [...op.targetFeatures] };
|
|
618
632
|
}
|
|
@@ -624,6 +638,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
624
638
|
if (!metadata || typeof metadata !== "object" || !Number.isInteger(metadata.protocol)) throw new Error(`the staged target's control protocol could not be read (probe exit ${probe.code}); staged package retained, runtimes unchanged; resume again once it can run`);
|
|
625
639
|
if (!Array.isArray(metadata.features) || !metadata.features.every((feature: unknown) => typeof feature === "string" && feature.length > 0)) throw new Error("the staged target features could not be read; staged package retained, runtimes unchanged");
|
|
626
640
|
const targetFeatures: readonly string[] = [...metadata.features];
|
|
641
|
+
if (!targetFeatures.includes("configured_acp") && op.plan.projects.some(planned => typeof planned.project?.root === "string" && Object.keys(readInstanceConfig(planned.project.root).adapters ?? {}).length > 0)) throw new FinalRefusal("target lacks configured_acp; configured adapters require a supporting target before recovery effects");
|
|
627
642
|
if (metadata.protocol !== PROTOCOL) throw new FinalRefusal("target protocol requires a newer coordinator; staged package retained, runtimes unchanged");
|
|
628
643
|
if (!targetFeatures.includes("peer_instances") && op.plan.projects.some(planned => typeof planned.project?.root === "string" && Object.keys(readInstanceConfig(planned.project.root).instances ?? {}).length > 0)) throw new FinalRefusal("target lacks peer_instances; declared project instances require a supporting target before recovery effects");
|
|
629
644
|
const verified = { ...target, targetFeatures };
|
|
@@ -674,7 +689,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
674
689
|
// #215: check resume viability again right before the destructive effect (a planned fresh start has none to lose).
|
|
675
690
|
const transcript = projectDefinition(binding.peer, binding.projectRoot)?.session.missingEvidence === "rollout" ? sessionState(binding) : "found";
|
|
676
691
|
if (transcript === "unknown") throw new Error(`${unresumable(binding)}; no terminal was closed; make that store readable first`);
|
|
677
|
-
if (transcript === "missing" && !planned.freshStart?.includes(
|
|
692
|
+
if (transcript === "missing" && !planned.freshStart?.includes(binding.peer)) {
|
|
678
693
|
throw new Error(`${unresumable(binding)}; no terminal was closed. To continue without its conversation, end that Codex session and close its Orca terminal ${binding.handle} first, then resume`);
|
|
679
694
|
}
|
|
680
695
|
const idle = await waitForIdle(binding, 600_000, terminalOptions(run));
|
package/src/cli/upgrade.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { nativeRestoreGroup } from "../adapters/definitions/native-identity-defaults.ts";
|
|
1
2
|
import { definitionOf } from "../adapters/registry.ts";
|
|
2
3
|
import { readInstanceConfig } from "../hub/instance-config.ts";
|
|
3
4
|
import { LIVE_TEST_PERSON_REFUSAL } from "./identity.ts";
|
|
@@ -18,7 +19,7 @@ import { RecoveryPresentationError, type RecoveryPresentationCause, type Recover
|
|
|
18
19
|
const projectDefinition = (peer: string, root?: string) => {
|
|
19
20
|
const builtin = definitionOf(peer);
|
|
20
21
|
if (builtin || !root) return builtin;
|
|
21
|
-
try { return definitionOf(peer,
|
|
22
|
+
try { const bound = readInstanceConfig(root); return bound.catalog.definitionOf(peer, bound.instances); } catch { return undefined; }
|
|
22
23
|
};
|
|
23
24
|
|
|
24
25
|
|
|
@@ -894,7 +895,7 @@ export async function reattachRecovery(id: string, peer: string, driver: Recover
|
|
|
894
895
|
};
|
|
895
896
|
await target();
|
|
896
897
|
attempted = true;
|
|
897
|
-
await driver.restore(planned, progress, op, projectDefinition(native, planned.project.root)?.transport
|
|
898
|
+
await driver.restore(planned, progress, op, nativeRestoreGroup(projectDefinition(native, planned.project.root)?.transport), save, native);
|
|
898
899
|
// A done receipt is written by the runtime only after authenticated attachment and exact settlement.
|
|
899
900
|
// Inspection peers may be unavailable; do not invent an attachment from a test or a stale status projection.
|
|
900
901
|
const restored = decodeRestoreReceipt(progress.terminals[`restored:${native}`]);
|