@staix/agent-hub 0.12.42 → 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 +30 -0
- package/README.md +5 -4
- package/docs/adding-an-ai-tool.md +140 -0
- package/docs/agent-notes/README.md +5 -0
- package/docs/agent-notes/adapters.md +11 -4
- package/docs/agent-notes/budget.md +2 -2
- package/docs/agent-notes/bus.md +2 -0
- package/docs/agent-notes/daemon.md +15 -6
- package/docs/agent-notes/tasks.md +7 -2
- package/docs/agent-notes/tests.md +6 -2
- package/docs/operations.md +159 -26
- package/docs/quickstart.md +11 -3
- package/docs/sdlc.md +1 -1
- package/docs/security.md +7 -5
- package/docs/specs/2026-09-19-agent-hub-design.md +209 -13
- package/docs/verification/2026-10-21-0.12.43.md +50 -0
- package/docs/verification/2026-10-21-0.12.44.md +54 -0
- package/docs/verification/2026-10-21-agy-1.4.3-t0.md +65 -0
- package/docs/verification/2026-10-21-grok-config-acp-t0.md +30 -0
- package/docs/verification/2026-10-21-pii-screen.md +14 -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 +808 -255
- 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/agy/hooks.ts +7 -2
- 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 +50 -16
- 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/completion.ts +173 -0
- package/src/cli/configured-acp-entry.ts +58 -0
- package/src/cli/console-state.ts +116 -24
- package/src/cli/console.ts +9 -6
- package/src/cli/facts-hook.ts +2 -1
- package/src/cli/help.ts +8 -1
- package/src/cli/identity.ts +8 -7
- package/src/cli/launch.ts +5 -4
- package/src/cli/main.js +5 -1
- package/src/cli/main.ts +84 -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 +33 -21
- 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 +471 -338
- package/src/hub/hub-tools.ts +37 -1
- 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/main-checkout.ts +2 -2
- package/src/hub/manager.ts +10 -2
- 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 +51 -17
- package/src/hub/snapshots.ts +276 -17
- package/src/hub/start-mode.ts +10 -8
- package/src/hub/task-sweep.ts +8 -2
- package/src/hub/tasks.ts +101 -19
- 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/launch.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { claudePeer as defaultChannelPeer } from "../hub/claude-state.ts";
|
|
1
2
|
// Launchers inject only the flags the hub owns and refuse user-supplied duplicates.
|
|
2
3
|
import { claudeStateFile } from "../hub/claude-state.ts";
|
|
3
4
|
import { mkdirSync, writeFileSync, renameSync, unlinkSync, lstatSync, readFileSync, openSync, closeSync, readSync, accessSync, constants, linkSync } from "node:fs";
|
|
@@ -159,7 +160,7 @@ async function withClaudeLaunchRecord(stateDir: string, action: () => void): Pro
|
|
|
159
160
|
}
|
|
160
161
|
|
|
161
162
|
function writeClaudeLaunchRecord(stateDir: string, record: Record<string, unknown>): void {
|
|
162
|
-
const file = join(stateDir, claudeStateFile("claude-launch", typeof record.peer === "string" ? record.peer :
|
|
163
|
+
const file = join(stateDir, claudeStateFile("claude-launch", typeof record.peer === "string" ? record.peer : defaultChannelPeer(undefined))), temp = `${file}.${randomUUID()}.tmp`;
|
|
163
164
|
try { writeFileSync(temp, JSON.stringify(record), { mode: 0o600 }); renameSync(temp, file); }
|
|
164
165
|
finally { try { unlinkSync(temp); } catch { /* the rename normally consumed it */ } }
|
|
165
166
|
}
|
|
@@ -202,7 +203,7 @@ export async function recordClaudeNative(stateDir: string, launch: ClaudeLaunchR
|
|
|
202
203
|
const publication = await withClaudeLaunchRecord(stateDir, () => {
|
|
203
204
|
let current: ClaudeLaunchRecord;
|
|
204
205
|
try {
|
|
205
|
-
const file = join(stateDir, claudeStateFile("claude-launch", launch.peer ??
|
|
206
|
+
const file = join(stateDir, claudeStateFile("claude-launch", launch.peer ?? defaultChannelPeer(undefined))), stat = lstatSync(file);
|
|
206
207
|
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || (stat.mode & 0o777) !== 0o600 || !process.getuid || stat.uid !== process.getuid()) return;
|
|
207
208
|
current = JSON.parse(readFileSync(file, "utf8"));
|
|
208
209
|
} catch { return; }
|
|
@@ -219,7 +220,7 @@ export async function recordClaudeNative(stateDir: string, launch: ClaudeLaunchR
|
|
|
219
220
|
writeFileSync(temp, JSON.stringify({ ...launch, nativePid: pid, nativeSignature: signature, nativeIdentity: "direct-child" }), { mode: 0o600, flag: "wx" });
|
|
220
221
|
renameSync(temp, file);
|
|
221
222
|
} finally { try { unlinkSync(temp); } catch { /* atomic rename consumed our private temporary file */ } }
|
|
222
|
-
try { recorded = sameClaudeLaunch(JSON.parse(readFileSync(join(stateDir, claudeStateFile("claude-launch", launch.peer ??
|
|
223
|
+
try { recorded = sameClaudeLaunch(JSON.parse(readFileSync(join(stateDir, claudeStateFile("claude-launch", launch.peer ?? defaultChannelPeer(undefined))), "utf8")), launch); } catch { /* replaced or unreadable managed record */ }
|
|
223
224
|
});
|
|
224
225
|
if (publication.warning) report?.(publication.warning);
|
|
225
226
|
return publication.recorded && recorded;
|
|
@@ -333,7 +334,7 @@ export function buildLaunch(
|
|
|
333
334
|
): Launch {
|
|
334
335
|
const adapter = definitionOf(tool);
|
|
335
336
|
if (!adapter) throw new Error("native launch definition unavailable");
|
|
336
|
-
const { metadata: _metadata, ...launch } = adapter.start.launch({
|
|
337
|
+
const { metadata: _metadata, ...launch } = adapter.start.launch!({
|
|
337
338
|
instance: ctx.instance ?? tool, form: "tui", projectRoot: process.cwd(), stateDir: ctx.facts?.stateDir ?? ctx.statusLine?.stateDir ?? "",
|
|
338
339
|
entrypoint: resolve(import.meta.dir, "main.js"), userArgs, env: {}, ...ctx,
|
|
339
340
|
});
|
package/src/cli/main.js
CHANGED
|
@@ -6,4 +6,8 @@ if (!version || !(Number(version[1]) > 1 || (Number(version[1]) === 1 && (Number
|
|
|
6
6
|
process.exit(1);
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
// Shell completion asks on every Tab: answer from the help entries without loading the CLI (#603).
|
|
10
|
+
if (process.argv[2] === "__complete") {
|
|
11
|
+
const { completeWords } = await import("./completion.ts");
|
|
12
|
+
console.log(completeWords(process.argv.slice(process.argv[3] === "--" ? 4 : 3)).join("\n"));
|
|
13
|
+
} else await import("./main.ts");
|
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";
|
|
@@ -73,12 +75,13 @@ import { addonReport, appendRecords, formatAddons, formatResearch, LABELS, label
|
|
|
73
75
|
import { summarize, summarizeByTask } from "../hub/report.ts";
|
|
74
76
|
import { hasTree, planUndo, repoOf, restore, Turns } from "../hub/snapshots.ts";
|
|
75
77
|
import { pathWarnings } from "../hub/conflicts.ts";
|
|
78
|
+
import { COMPLETION_SHELLS, completionScript } from "./completion.ts";
|
|
76
79
|
import { classifyPeerCommand, cliCommandLabel, detectCliIdentity, LIVE_TEST_PERSON_REFUSAL, liveTestRefusal, peerCommandRefusal } from "./identity.ts";
|
|
77
80
|
import { recordCliAudit, recordDelegatedCliAudit } from "./identity-audit.ts";
|
|
78
81
|
import { runConsole } from "./console.ts";
|
|
79
82
|
import { paint, permissionBoundary, resolveColor, wrap, type Span } from "./console-state.ts";
|
|
80
83
|
import { outputWidth, renderRoster, renderStatus, renderBoard, renderBoardColumns, boardColumnModel, renderBudget, renderDoctor, renderProjects, renderQueue, renderTurns, renderOrphans, renderQueueShow, renderModelsStatus, renderExecutionBudgetStatus, renderReport, type DoctorCheck } from "./output.ts";
|
|
81
|
-
import { renderHelp } from "./help.ts";
|
|
84
|
+
import { renderHelp, configuredAcpHelp } from "./help.ts";
|
|
82
85
|
import { renderTailEvent } from "./tail-render.ts";
|
|
83
86
|
import { archiveProblem, archiveState, damagedState, failureText, MANIFEST, planReset, resetLines, resetRuntime, startState, type ResetPlan } from "./reset.ts";
|
|
84
87
|
|
|
@@ -117,16 +120,32 @@ try {
|
|
|
117
120
|
}
|
|
118
121
|
} else {
|
|
119
122
|
const markedPeer = process.env.AGENTHUB_PEER_ID, markedRoot = process.env.AGENTHUB_PROJECT_DIR;
|
|
120
|
-
const
|
|
121
|
-
|
|
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)) {
|
|
122
126
|
const canonicalRoot = realPath(markedRoot), config = loadConfig(canonicalRoot);
|
|
123
|
-
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);
|
|
124
128
|
if (realPath(projectRoot(process.cwd())) !== realPath(executionCwd)) fail("native instance workspace does not match its declared checkout");
|
|
125
129
|
selected = projectContext(canonicalRoot);
|
|
126
130
|
} else selected = projectContext(process.cwd());
|
|
127
131
|
}
|
|
128
132
|
} catch (error) { fail(startupDiagnostic(error)); }
|
|
129
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;
|
|
130
149
|
const unboundCaller = detectCliIdentity(process.env);
|
|
131
150
|
const markerNeedsDeclaration = unboundCaller.role === "invalid" && unboundCaller.reason === "AGENTHUB_PEER_ID conflicts with native agent marker"
|
|
132
151
|
&& process.env.AGENTHUB_PEER_ID !== undefined && !definitionOf(process.env.AGENTHUB_PEER_ID);
|
|
@@ -134,8 +153,11 @@ const needsInstances = definitionOf(given) && rest.some(arg => arg === "--as" ||
|
|
|
134
153
|
let declaredInstances: ReturnType<typeof readInstanceConfig>["instances"];
|
|
135
154
|
let nativeSelection: ReturnType<typeof instanceLaunchArguments>;
|
|
136
155
|
try {
|
|
137
|
-
declaredInstances = needsInstances ?
|
|
138
|
-
|
|
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);
|
|
139
161
|
} catch (error) { fail(startupDiagnostic(error)); }
|
|
140
162
|
const selectedInstance = nativeSelection.instance;
|
|
141
163
|
// `ahub <command> -h` alone asks for that command's help instead of running it. Elsewhere `-h` may be message text
|
|
@@ -145,10 +167,19 @@ const cmd = asksHelp(given) || (definitionOf(given)?.start.passthrough !== true
|
|
|
145
167
|
const args = cmd === "help" && given !== "help" && !asksHelp(given) ? [given] : nativeSelection.args;
|
|
146
168
|
|
|
147
169
|
/** Resolve a named peer only against this project's current trusted declarations. */
|
|
148
|
-
const peerDefinition = (peer: unknown) =>
|
|
149
|
-
|
|
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
|
+
};
|
|
150
177
|
const requireNamedPeerFeature = (hub: ControlClient, peer: string | undefined) => {
|
|
151
|
-
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
|
+
}
|
|
152
183
|
};
|
|
153
184
|
|
|
154
185
|
/** The project config, saying on stderr which machine-local fields it had to ignore (issue #17). */
|
|
@@ -159,8 +190,8 @@ const projectConfig = () => {
|
|
|
159
190
|
};
|
|
160
191
|
const stateDir = selected.stateDir;
|
|
161
192
|
const caller = declaredInstances ? detectCliIdentity(process.env, declaredInstances) : unboundCaller;
|
|
162
|
-
const commandLabel = cliCommandLabel(cmd, args);
|
|
163
|
-
const commandAccess = classifyPeerCommand(cmd, args);
|
|
193
|
+
const commandLabel = cliCommandLabel(cmd, args, projectCatalog);
|
|
194
|
+
const commandAccess = classifyPeerCommand(cmd, args, projectCatalog);
|
|
164
195
|
// Agent-authored content keeps its peer. Operational starts/holds may borrow console authority for live verification.
|
|
165
196
|
const delegationRefused = liveTest && caller.role === "tools" && commandAccess !== "allowed" && liveTestRefusal(cmd, args);
|
|
166
197
|
const delegatedPeer = liveTest && caller.role === "tools" && commandAccess !== "allowed" && !delegationRefused ? caller.peer : undefined;
|
|
@@ -407,11 +438,11 @@ function fail(message: string): never {
|
|
|
407
438
|
process.exit(1);
|
|
408
439
|
}
|
|
409
440
|
|
|
410
|
-
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> {
|
|
411
442
|
const hub = connection ?? await connect();
|
|
412
443
|
try {
|
|
413
444
|
if (op === "hub_task_cancel" && !hub.features.includes("task_cancel")) fail("this daemon does not support task cancellation; upgrade it first");
|
|
414
|
-
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));
|
|
415
446
|
if (!res.ok) { if (throwOnError) throw new Error(res.error); audit("refused"); fail(res.error); }
|
|
416
447
|
return res.text;
|
|
417
448
|
} finally { if (!connection) hub.close(); }
|
|
@@ -761,10 +792,16 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
761
792
|
help: () => {
|
|
762
793
|
const color = resolveColor(undefined, { isTTY: !!process.stdout.isTTY, TERM: process.env.TERM, NO_COLOR: process.env.NO_COLOR }) === true;
|
|
763
794
|
const topic = asksHelp(args[0]) ? undefined : args[0];
|
|
764
|
-
const text =
|
|
795
|
+
const text = topic && !definitionOf(topic) && projectCatalog.definitionOf(topic)?.permission.genericAcp !== undefined
|
|
796
|
+
? configuredAcpHelp(topic) : renderHelp(process.stdout.columns ?? 80, color, topic);
|
|
765
797
|
if (!text) fail(`unknown command "${topic}"; run ahub help`);
|
|
766
798
|
console.log(text);
|
|
767
799
|
},
|
|
800
|
+
completion: () => {
|
|
801
|
+
const script = completionScript(args[0]);
|
|
802
|
+
if (args.length !== 1 || !script) fail(`usage: ahub completion ${COMPLETION_SHELLS.join("|")}`);
|
|
803
|
+
process.stdout.write(script!);
|
|
804
|
+
},
|
|
768
805
|
"--version": () => console.log(VERSION),
|
|
769
806
|
version: () => console.log(VERSION),
|
|
770
807
|
|
|
@@ -1332,7 +1369,8 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1332
1369
|
const [peer, mode] = positional;
|
|
1333
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]");
|
|
1334
1371
|
if (mode === "never-ask" && !args.includes("--yes")) fail("never-ask requires --yes; nothing was changed");
|
|
1335
|
-
|
|
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})` : ""}`);
|
|
1336
1374
|
const hub = await connect();
|
|
1337
1375
|
try {
|
|
1338
1376
|
const reply = await hub.request({ t: "permission", ...(peer ? { peer } : {}), ...(mode ? { mode, confirmed: args.includes("--yes") } : {}) }, 35_000);
|
|
@@ -1381,14 +1419,15 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1381
1419
|
const allowed: Record<string, string[]> = { list: ["--json"], get: ["--json"], set: ["--yes", "--preview", "--now"], undo: [], reload: [] };
|
|
1382
1420
|
if (!Object.hasOwn(allowed, sub) || flags.some((flag) => !allowed[sub]!.includes(flag)) || extra.length) fail(usage);
|
|
1383
1421
|
if ((sub === "get" && (!key || text !== undefined)) || (sub === "set" && (!key || text === undefined)) || ((sub === "list" || sub === "undo" || sub === "reload") && key !== undefined)) fail(usage);
|
|
1384
|
-
const
|
|
1385
|
-
|
|
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}`);
|
|
1386
1425
|
if (flags.includes("--now") && (typeof def !== "object" || def.group !== "Roster")) fail(usage);
|
|
1387
1426
|
const value = sub === "set" ? parseSettingText(def!, text!) : undefined;
|
|
1388
1427
|
const peer = def?.group === "Permissions" || def?.group === "Roster" ? def.path.at(-1)! : undefined;
|
|
1389
1428
|
if (sub === "set" && value === "never-ask" && !flags.includes("--preview")) {
|
|
1390
1429
|
if (!flags.includes("--yes")) fail("never-ask requires --yes; nothing was changed");
|
|
1391
|
-
console.error(`never-ask: ${permissionBoundary(peer
|
|
1430
|
+
console.error(`never-ask: ${permissionBoundary(peer!, settingScope.instances, settingScope.catalog)}; approval prompts are disabled`);
|
|
1392
1431
|
}
|
|
1393
1432
|
const hub = await connect();
|
|
1394
1433
|
try {
|
|
@@ -1438,7 +1477,9 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1438
1477
|
return console.log(paint([{ text: result.text }], false));
|
|
1439
1478
|
}
|
|
1440
1479
|
if (sub === "undo") {
|
|
1441
|
-
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}`);
|
|
1442
1483
|
const rosterPeer = typeof undoDef !== "string" && undoDef.group === "Roster" ? undoDef.path[1] : undefined;
|
|
1443
1484
|
let confirm: string | undefined;
|
|
1444
1485
|
if (rosterPeer) {
|
|
@@ -1656,7 +1697,7 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1656
1697
|
facts: async () => {
|
|
1657
1698
|
if (!args.includes("--hook")) return fail("usage: ahub facts --hook (a Claude Code PreToolUse, PostToolUse and Stop hook)");
|
|
1658
1699
|
try {
|
|
1659
|
-
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));
|
|
1660
1701
|
if (out) console.log(out);
|
|
1661
1702
|
} catch {
|
|
1662
1703
|
// a hook that fails must not get in the way of the tool call
|
|
@@ -1665,7 +1706,7 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1665
1706
|
"check-path": async () => {
|
|
1666
1707
|
const hook = args.includes("--hook");
|
|
1667
1708
|
const { one, rest } = takeFlags(args.filter((a) => a !== "--hook"), ["--peer"], []);
|
|
1668
|
-
const peer = one["--peer"] ??
|
|
1709
|
+
const peer = one["--peer"] ?? defaultChannelPeer(undefined);
|
|
1669
1710
|
let target = rest[0];
|
|
1670
1711
|
try {
|
|
1671
1712
|
if (hook) {
|
|
@@ -1722,10 +1763,10 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1722
1763
|
const undoWorkspace = (): string => {
|
|
1723
1764
|
// Old turns, including external peers and ids declared only later, belong to the canonical checkout.
|
|
1724
1765
|
if (recordedWorkspace === undefined) return cwd;
|
|
1725
|
-
const declarations = readInstanceConfig(cwd)
|
|
1766
|
+
const { instances: declarations, catalog } = readInstanceConfig(cwd);
|
|
1726
1767
|
if (!declarations || !Object.hasOwn(declarations, turn.peer)) fail(`turn ${id}'s recorded instance is no longer declared; nothing was restored`);
|
|
1727
1768
|
const config = projectConfig();
|
|
1728
|
-
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);
|
|
1729
1770
|
if (realPath(current) !== realPath(recordedWorkspace)) fail(`turn ${id}'s recorded execution workspace changed; nothing was restored`);
|
|
1730
1771
|
return current;
|
|
1731
1772
|
};
|
|
@@ -2055,7 +2096,18 @@ async function runConductorCommand(): Promise<void> {
|
|
|
2055
2096
|
input = { peer: args[0] };
|
|
2056
2097
|
} else {
|
|
2057
2098
|
// Native TUIs return a human launch command through the daemon; an agent shell never executes it.
|
|
2058
|
-
|
|
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;
|
|
2059
2111
|
const parsed = parseStart ? parseStart(args) : args.length ? new Error(`conductor starts accept no launch overrides; use ahub ${cmd}`) : {};
|
|
2060
2112
|
if (parsed instanceof Error) fail(parsed.message);
|
|
2061
2113
|
op = "hub_peer_start";
|
|
@@ -2065,7 +2117,12 @@ async function runConductorCommand(): Promise<void> {
|
|
|
2065
2117
|
if (selectedInstance) { const hub = await connect(); try { requirePeerInstances(hub.features); } finally { hub.close(); } }
|
|
2066
2118
|
console.log(await taskOp(op, input));
|
|
2067
2119
|
}
|
|
2068
|
-
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`));
|
|
2069
2126
|
try {
|
|
2070
2127
|
assertDelegatedAuditConsumer(); // Local command paths must not begin after the authenticated consumer was replaced either.
|
|
2071
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";
|
|
@@ -179,6 +179,8 @@ const MAX_WAIT_MS = 10 * 60 * 1000;
|
|
|
179
179
|
export const MAX_COMMAND_MS = 30_000;
|
|
180
180
|
/** #215: a replacement's readiness is awaited in slices this long, checking between them whether its launcher still runs. */
|
|
181
181
|
const EXIT_SLICE_MS = 5_000;
|
|
182
|
+
/** #599: after the replacement reads idle, its session mapping is re-read this often until the deadline. */
|
|
183
|
+
const MAP_POLL_MS = 1_000;
|
|
182
184
|
const ALLOWED_ENV = ["CODEX_HOME", "CLAUDE_CONFIG_DIR"] as const;
|
|
183
185
|
type AllowedEnvName = (typeof ALLOWED_ENV)[number];
|
|
184
186
|
|
|
@@ -330,8 +332,8 @@ export function recordPath(stateDir: string): string {
|
|
|
330
332
|
function executionRoot(peer: string, projectRoot: string, captured?: string): string {
|
|
331
333
|
const builtin = definitionOf(peer);
|
|
332
334
|
if (builtin?.session.launchRecord) return projectRoot;
|
|
333
|
-
const { instances: declarations, roles } = readInstanceRoleConfig(projectRoot);
|
|
334
|
-
const definition = definitionOf(peer, declarations);
|
|
335
|
+
const { instances: declarations, roles, catalog } = readInstanceRoleConfig(projectRoot);
|
|
336
|
+
const definition = catalog.definitionOf(peer, declarations);
|
|
335
337
|
if (!definition?.session.launchRecord) throw new Error(`${peer}: no managed terminal recovery adapter`);
|
|
336
338
|
if (!declarations?.[peer] || definition.transport !== "codex-appserver" && definition.transport !== "claude-channel") throw new Error(`${peer}: extra instance has no managed terminal support`);
|
|
337
339
|
// #450 I3: a Claude instance reviews or plans in the canonical checkout; it never owns an execution worktree.
|
|
@@ -339,7 +341,7 @@ function executionRoot(peer: string, projectRoot: string, captured?: string): st
|
|
|
339
341
|
if (captured !== undefined && captured !== projectRoot) throw new Error(`${peer}: recorded execution root changed; manual recovery required`);
|
|
340
342
|
return projectRoot;
|
|
341
343
|
}
|
|
342
|
-
const expected = resolveInstanceExecutionCwd(projectRoot, peer, declarations, effectiveInstanceRoles(peer, declarations, roles));
|
|
344
|
+
const expected = resolveInstanceExecutionCwd(projectRoot, peer, declarations, effectiveInstanceRoles(peer, declarations, roles, catalog), catalog);
|
|
343
345
|
if (captured !== undefined && captured !== expected) throw new Error(`${peer}: declared execution worktree changed; manual recovery required`);
|
|
344
346
|
return expected;
|
|
345
347
|
}
|
|
@@ -554,12 +556,12 @@ function sessionFrom(terminal: Record<string, unknown>): string | undefined {
|
|
|
554
556
|
return nestedString(terminal, ["sessionId", "agentSessionId", "resumeSessionId"]) ?? nestedString(wait, ["sessionId", "agentSessionId", "resumeSessionId", "session", "agentSession"]) ?? nestedString(session, ["id", "sessionId"]);
|
|
555
557
|
}
|
|
556
558
|
|
|
557
|
-
function identityFrom(terminal: Record<string, unknown>, instances
|
|
559
|
+
function identityFrom(terminal: Record<string, unknown>, instances: KindMap | undefined, catalog: ProjectAdapterCatalog): TerminalPeer | undefined {
|
|
558
560
|
const wait = object(terminal.agentWait);
|
|
559
561
|
const session = object(terminal.session);
|
|
560
562
|
const candidates = [terminal.agentIdentity, terminal.agent, wait.agentIdentity, wait.agent, session.agentIdentity, session.agent];
|
|
561
563
|
for (const candidate of candidates) {
|
|
562
|
-
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;
|
|
563
565
|
}
|
|
564
566
|
return undefined;
|
|
565
567
|
}
|
|
@@ -597,7 +599,8 @@ function launchFor(peer: TerminalPeer, projectRoot: string, sessionId: string, o
|
|
|
597
599
|
const binding: TerminalBinding = { peer, projectRoot, sessionId, handle: nestedString(source, ["handle"])!,
|
|
598
600
|
incarnationId: nestedString(source, ["incarnationId"])!, worktreeId: nestedString(source, ["worktreeId"])!,
|
|
599
601
|
...(ref?.sessionFile ? { sessionFile: ref.sessionFile } : {}), ...(ref?.backend ? { backend: ref.backend } : {}), ...(ref?.model ? { model: ref.model } : {}), launch, launchMetadata: launch };
|
|
600
|
-
const
|
|
602
|
+
const scope = definitionOf(peer) ? undefined : readInstanceConfig(projectRoot);
|
|
603
|
+
const adapter = definitionOf(peer) ?? scope!.catalog.definitionOf(peer, scope!.instances);
|
|
601
604
|
const nativeTail = adapter?.session.resumeArgv?.(binding, false);
|
|
602
605
|
const tail = nativeTail && adapter ? [nativeTail[0]!, ...(peer !== adapter.kind ? ["--as", peer] : []), ...nativeTail.slice(1)] : undefined;
|
|
603
606
|
if (!tail) throw new Error(`${peer}: no recorded terminal resume adapter`);
|
|
@@ -695,7 +698,8 @@ export async function inspectTerminals(projectRoot: string, sessions: Partial<Re
|
|
|
695
698
|
const byPeer: Partial<Record<TerminalPeer, TerminalBinding>> = {};
|
|
696
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);
|
|
697
700
|
if (!requested.length) return { bindings, byPeer, blockers, manualRequired: false };
|
|
698
|
-
const
|
|
701
|
+
const scope = requested.some(([peer]) => definitionOf(peer) === undefined) ? readInstanceConfig(projectRoot) : { instances: undefined, catalog: createProjectAdapterCatalog() };
|
|
702
|
+
const { instances, catalog } = scope;
|
|
699
703
|
// #215: without the records a terminal could be bound from Orca metadata alone, losing the launch's captured
|
|
700
704
|
// CODEX_HOME or CLAUDE_CONFIG_DIR: an unreadable file blocks the binding, never reads as "no records".
|
|
701
705
|
const rows = launchRecords(config.stateDir);
|
|
@@ -712,7 +716,7 @@ export async function inspectTerminals(projectRoot: string, sessions: Partial<Re
|
|
|
712
716
|
|
|
713
717
|
for (const [peer, ref] of requested) {
|
|
714
718
|
const sessionId = ref.sessionId;
|
|
715
|
-
const adapter = definitionOf(peer, instances), extra = definitionOf(peer) === undefined;
|
|
719
|
+
const adapter = catalog.definitionOf(peer, instances), extra = definitionOf(peer) === undefined;
|
|
716
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; }
|
|
717
721
|
const ownRecord = records.find(item => item.peer === peer);
|
|
718
722
|
let expectedRoot: string, expectedTerminalRoot: string;
|
|
@@ -745,7 +749,7 @@ export async function inspectTerminals(projectRoot: string, sessions: Partial<Re
|
|
|
745
749
|
}
|
|
746
750
|
// A show readback is required; list can be stale between recovery steps.
|
|
747
751
|
if (!rootMatches(terminal.worktreePath ?? terminal.projectRoot, expectedTerminalRoot)) continue;
|
|
748
|
-
const identity = identityFrom(terminal, instances);
|
|
752
|
+
const identity = identityFrom(terminal, instances, catalog);
|
|
749
753
|
const terminalSession = sessionFrom(terminal);
|
|
750
754
|
const recorded = records.find((item) => item.peer === peer && item.handle === handle && item.worktreeId === terminal.worktreeId && item.incarnationId === terminal.incarnationId);
|
|
751
755
|
// A recorded launcher proves ownership; the daemon's supplied session id is the
|
|
@@ -753,7 +757,7 @@ export async function inspectTerminals(projectRoot: string, sessions: Partial<Re
|
|
|
753
757
|
if ((!extra && identity === peer && terminalSession === sessionId) || (recorded !== undefined && (identity === undefined || identity === peer || extra && identity === adapter.kind) && (terminalSession === undefined || terminalSession === sessionId))) peerCandidates.push(terminal);
|
|
754
758
|
}
|
|
755
759
|
if (!peerCandidates.length) {
|
|
756
|
-
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));
|
|
757
761
|
blockers.push(ownershipUnknown
|
|
758
762
|
? blocker("ownership-unknown", `cannot prove ${peer} terminal ownership from Orca metadata`, peer)
|
|
759
763
|
: blocker("missing-terminal", `no ${peer} terminal with session ${sessionId} in ${projectRoot}`, peer));
|
|
@@ -782,7 +786,7 @@ export async function inspectTerminals(projectRoot: string, sessions: Partial<Re
|
|
|
782
786
|
const recorded = records.find((item) => item.peer === peer && item.handle === handle && item.worktreeId === terminal.worktreeId && item.incarnationId === terminal.incarnationId);
|
|
783
787
|
// Orca observation supplies file identity; launch arguments are not observed native session-file evidence.
|
|
784
788
|
const { args: _launchArgs, ...observedTerminal } = terminal;
|
|
785
|
-
const sessionFile = definitionOf(peer, instances)?.session.crashIdentity?.(observedTerminal).sessionFile;
|
|
789
|
+
const sessionFile = catalog.definitionOf(peer, instances)?.session.crashIdentity?.(observedTerminal).sessionFile;
|
|
786
790
|
const launch = launchFor(peer, projectRoot, sessionId, config, terminal, recorded, { ...ref, ...(sessionFile ? { sessionFile } : {}) });
|
|
787
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 };
|
|
788
792
|
bindings.push(binding);
|
|
@@ -909,16 +913,24 @@ export async function createTerminal(binding: TerminalBinding, options?: Command
|
|
|
909
913
|
idle = await waitForIdle(replacement, slice(), config);
|
|
910
914
|
}
|
|
911
915
|
if (!idle.satisfied) return { created: true, ready: false, manualRequired: true, blockers: idle.blockers };
|
|
912
|
-
// An idle terminal may be the login shell the launcher returned to.
|
|
913
|
-
const launch = await launches();
|
|
914
|
-
if (launch.gone) return exited("exited");
|
|
915
|
-
const shown = await showBinding(replacement, config);
|
|
916
|
-
const identity = identityFrom(shown);
|
|
917
|
-
const session = sessionFrom(shown);
|
|
918
|
-
const recorded = launch.live;
|
|
919
916
|
const same = (value: string | undefined) => anySession || value === binding.sessionId;
|
|
920
|
-
|
|
921
|
-
|
|
917
|
+
let session: string | undefined;
|
|
918
|
+
// An idle terminal may be the login shell the launcher returned to, or a TUI that has not yet recorded its
|
|
919
|
+
// launch or been identified by Orca (#599): poll until it maps, contradicts, or the deadline passes.
|
|
920
|
+
for (;;) {
|
|
921
|
+
const launch = await launches();
|
|
922
|
+
if (launch.gone) return exited("exited");
|
|
923
|
+
const shown = await showBinding(replacement, config);
|
|
924
|
+
const scope = definitionOf(binding.peer) ? { instances: undefined, catalog: createProjectAdapterCatalog() } : readInstanceConfig(binding.projectRoot);
|
|
925
|
+
const identity = identityFrom(shown, scope.instances, scope.catalog);
|
|
926
|
+
session = sessionFrom(shown);
|
|
927
|
+
const recorded = launch.live;
|
|
928
|
+
if ((identity === binding.peer && same(session)) || (recorded && (identity === undefined || identity === binding.peer) && (session === undefined || same(session)))) break;
|
|
929
|
+
const contradicts = (identity !== undefined && identity !== binding.peer) || (session !== undefined && !same(session));
|
|
930
|
+
if (contradicts || Date.now() >= deadline) {
|
|
931
|
+
return { created: true, ready: true, manualRequired: true, blockers: [blocker("ambiguous-create", `created terminal ${createdHandle} cannot be mapped to the original ${binding.peer} session`, binding.peer, createdHandle)] };
|
|
932
|
+
}
|
|
933
|
+
await Bun.sleep(Math.max(1, Math.min(MAP_POLL_MS, deadline - Date.now())));
|
|
922
934
|
}
|
|
923
935
|
if (anySession && session) replacement = { ...replacement, sessionId: session };
|
|
924
936
|
return { created: true, ready: true, manualRequired: false, binding: replacement, newBinding: replacement, blockers: [] };
|
|
@@ -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) => {
|