@staix/agent-hub 0.12.34 → 0.12.36
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 +27 -0
- package/README.md +8 -6
- package/docs/agent-notes/adapters.md +6 -1
- package/docs/agent-notes/budget.md +4 -2
- package/docs/agent-notes/bus.md +5 -1
- package/docs/agent-notes/daemon.md +11 -3
- package/docs/agent-notes/local-worker.md +1 -1
- package/docs/agent-notes/tasks.md +15 -2
- package/docs/agent-notes/tests.md +5 -1
- package/docs/gsd-collaboration-analysis.md +5 -1
- package/docs/operations.md +97 -19
- package/docs/quickstart.md +8 -4
- package/docs/sdlc.md +100 -34
- package/docs/security.md +7 -3
- package/docs/specs/2026-09-19-agent-hub-design.md +237 -7
- package/docs/verification/2026-10-18-0.12.35.md +49 -0
- package/docs/verification/2026-10-18-493-claude-busy-channel.md +91 -0
- package/docs/verification/2026-10-19-0.12.36.md +63 -0
- package/docs/verified.json +26 -17
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +15781 -14850
- package/src/adapters/acp.ts +3 -1
- package/src/adapters/claude-channel.ts +59 -29
- package/src/adapters/claude-print.ts +230 -0
- package/src/adapters/codex-appserver.ts +1 -1
- package/src/adapters/definitions/builtin-metadata.ts +17 -1
- package/src/adapters/definitions/native-cli-entry-claude.ts +17 -3
- package/src/adapters/definitions/native-cli-entry-codex.ts +13 -5
- package/src/adapters/definitions/native-cli-entry-jcode.ts +6 -2
- package/src/adapters/definitions/native-cli-entry-kimi.ts +18 -12
- package/src/adapters/definitions/native-cli-entry-local.ts +3 -2
- package/src/adapters/definitions/native-cli-entry-pi.ts +8 -6
- package/src/adapters/definitions/native-cli-entry-qwen.ts +3 -2
- package/src/adapters/definitions/native-cli-entry-types.ts +3 -0
- package/src/adapters/definitions/native-factories.ts +7 -0
- package/src/adapters/definitions/native-recovery.ts +2 -2
- package/src/adapters/definitions/qwen-native.ts +2 -2
- package/src/adapters/local-worker.ts +3 -2
- package/src/adapters/pi.ts +3 -1
- package/src/adapters/registry-types.ts +11 -2
- package/src/adapters/registry.ts +36 -8
- package/src/cli/help.ts +5 -0
- package/src/cli/identity.ts +5 -4
- package/src/cli/launch.ts +45 -7
- package/src/cli/main.ts +136 -29
- package/src/cli/preview.ts +16 -5
- package/src/cli/recovery-guidance.ts +39 -22
- package/src/cli/reset.ts +4 -2
- package/src/cli/statusline-tee.ts +5 -2
- package/src/cli/terminal-recovery.ts +73 -16
- package/src/cli/upgrade-restore-state.ts +16 -7
- package/src/cli/upgrade-runtime.ts +83 -52
- package/src/cli/upgrade.ts +43 -27
- package/src/hub/board.ts +29 -7
- package/src/hub/budget.ts +55 -3
- package/src/hub/bus.ts +12 -6
- package/src/hub/claude-state.ts +37 -0
- package/src/hub/conductor.ts +16 -10
- package/src/hub/config-apply.ts +13 -2
- package/src/hub/config-reload.ts +11 -1
- package/src/hub/config-trust.ts +1 -1
- package/src/hub/control-client.ts +1 -1
- package/src/hub/cost-governance.ts +76 -0
- package/src/hub/crash.ts +14 -5
- package/src/hub/daemon.ts +473 -194
- package/src/hub/execution-budget.ts +3 -3
- package/src/hub/hub-tools.ts +36 -6
- package/src/hub/instance-budget.ts +73 -0
- package/src/hub/instance-config.ts +47 -0
- package/src/hub/instance-execution.ts +50 -0
- package/src/hub/instances.ts +114 -0
- package/src/hub/lifecycle-schema.ts +7 -3
- package/src/hub/permission-mode.ts +7 -7
- package/src/hub/review-brief.ts +175 -0
- package/src/hub/roster.ts +7 -6
- package/src/hub/routing.ts +97 -18
- package/src/hub/sdlc-queue.ts +2 -2
- package/src/hub/sdlc.ts +286 -39
- package/src/hub/settings.ts +34 -6
- package/src/hub/snapshots.ts +40 -9
- package/src/hub/start-mode.ts +7 -4
- package/src/hub/status-roster.ts +18 -0
- package/src/hub/task-sweep.ts +143 -0
- package/src/hub/tasks.ts +595 -47
- package/src/hub/text-projection.ts +7 -3
- package/src/hub/usage.ts +57 -20
- package/src/hub/workflow.ts +30 -52
- package/src/pi/launch.ts +4 -3
- package/src/ui/index.html +1 -1
- package/templates/AGENTS.block.md +2 -1
package/src/cli/main.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
+
import { effectiveInstanceRoles } from "../hub/instances.ts";
|
|
3
|
+
import { resolveInstanceExecutionCwd } from "../hub/instance-execution.ts";
|
|
4
|
+
import { readInstanceConfig } from "../hub/instance-config.ts";
|
|
5
|
+
import type { NativeCliEntryContext } from "../adapters/definitions/native-cli-entry-types.ts";
|
|
2
6
|
import { BUILTIN_CLI_ENTRIES, definitionOf, DOCTOR_TOOL_PROBES, DOCTOR_MEMORY_CAPTURE_PEERS, DOCTOR_PLUGIN_PROBE, nativeDoctorCheck, nativeDoctorRecoveryIdentity, nativeDoctorOptionalProbes } from "../adapters/registry.ts";
|
|
3
7
|
import { parsePiFlags } from "../adapters/definitions/native-cli-pi.ts";
|
|
4
8
|
import { supportsBoardReleaseFilter, supportsBoardColumnMetadata } from "./board-read.ts";
|
|
@@ -27,7 +31,7 @@ import { basename, dirname, join, relative, resolve } from "node:path";
|
|
|
27
31
|
import { ControlClient, readControl } from "../hub/control-client.ts";
|
|
28
32
|
import { loadConfig } from "../hub/daemon.ts";
|
|
29
33
|
import { factsHook } from "./facts-hook.ts";
|
|
30
|
-
import { hubHome, projectContext, realPath } from "../hub/project.ts";
|
|
34
|
+
import { hubHome, projectContext, projectRoot, realPath } from "../hub/project.ts";
|
|
31
35
|
import { Registry, type Project } from "../hub/registry.ts";
|
|
32
36
|
import { inspectProject, startProject, stopProject, runProjectDaemon } from "../hub/lifecycle.ts";
|
|
33
37
|
import { processLiveness, processSignature } from "../pi/process-signature.ts";
|
|
@@ -36,7 +40,7 @@ import { OmniRoute } from "../omniroute/client.ts";
|
|
|
36
40
|
import { MemoryClient } from "../memory/client.ts";
|
|
37
41
|
import { init, planInit } from "./init.ts";
|
|
38
42
|
import { launcherPreview } from "./preview.ts";
|
|
39
|
-
import { cleanupStaleClaudeSettings, cleanupClaudeSettings, buildLaunch, buildKimiTuiLaunch, claudeObservationHooks, nativeLaunchEnv, recordClaudeLaunch, runClaudeLaunch, type ClaudeLaunchRecord, UNATTENDED_WARNING } from "./launch.ts";
|
|
43
|
+
import { instanceLaunchArguments, requirePeerInstances, requireActiveInstance, cleanupStaleClaudeSettings, cleanupClaudeSettings, buildLaunch, buildKimiTuiLaunch, claudeObservationHooks, nativeLaunchEnv, recordClaudeLaunch, runClaudeLaunch, type ClaudeLaunchRecord, UNATTENDED_WARNING } from "./launch.ts";
|
|
40
44
|
import { configureKimiMcp } from "./kimi-mcp.ts";
|
|
41
45
|
import { nextStep, parseList, pluginState, type InstalledPlugin, type Marketplace } from "./setup.ts";
|
|
42
46
|
import { CLASSES } from "../hub/board.ts";
|
|
@@ -101,11 +105,6 @@ while (argv[0] === "--project" || argv[0] === "--live-test") {
|
|
|
101
105
|
}
|
|
102
106
|
}
|
|
103
107
|
const [given = "help", ...rest] = argv;
|
|
104
|
-
// `ahub <command> -h` alone asks for that command's help instead of running it. Elsewhere `-h` may be message text
|
|
105
|
-
// (`ahub say try ls -h`), and claude and codex pass their arguments on to the agent.
|
|
106
|
-
const asksHelp = (a: string | undefined) => a === "--help" || a === "-h";
|
|
107
|
-
const cmd = asksHelp(given) || (definitionOf(given)?.start.passthrough !== true && rest.length === 1 && asksHelp(rest[0])) ? "help" : given;
|
|
108
|
-
const args = cmd === "help" && given !== "help" && !asksHelp(given) ? [given] : rest;
|
|
109
108
|
let selected: { root: string; stateDir: string };
|
|
110
109
|
try {
|
|
111
110
|
if (selector) {
|
|
@@ -116,9 +115,42 @@ try {
|
|
|
116
115
|
const registered = known ?? projects.find((p) => p.root === context.root);
|
|
117
116
|
selected = registered ?? context;
|
|
118
117
|
}
|
|
119
|
-
} else
|
|
120
|
-
|
|
118
|
+
} else {
|
|
119
|
+
const markedPeer = process.env.AGENTHUB_PEER_ID, markedRoot = process.env.AGENTHUB_PROJECT_DIR;
|
|
120
|
+
const declarations = markedPeer && markedRoot && !definitionOf(markedPeer) ? readInstanceConfig(realPath(markedRoot)).instances : undefined;
|
|
121
|
+
if (markedPeer && markedRoot && declarations && Object.hasOwn(declarations, markedPeer)) {
|
|
122
|
+
const canonicalRoot = realPath(markedRoot), config = loadConfig(canonicalRoot);
|
|
123
|
+
const executionCwd = resolveInstanceExecutionCwd(canonicalRoot, markedPeer, declarations, effectiveInstanceRoles(markedPeer, declarations, config.roles));
|
|
124
|
+
if (realPath(projectRoot(process.cwd())) !== realPath(executionCwd)) fail("native instance workspace does not match its declared checkout");
|
|
125
|
+
selected = projectContext(canonicalRoot);
|
|
126
|
+
} else selected = projectContext(process.cwd());
|
|
127
|
+
}
|
|
128
|
+
} catch (error) { fail(startupDiagnostic(error)); }
|
|
121
129
|
const cwd = selected.root;
|
|
130
|
+
const unboundCaller = detectCliIdentity(process.env);
|
|
131
|
+
const markerNeedsDeclaration = unboundCaller.role === "invalid" && unboundCaller.reason === "AGENTHUB_PEER_ID conflicts with native agent marker"
|
|
132
|
+
&& process.env.AGENTHUB_PEER_ID !== undefined && !definitionOf(process.env.AGENTHUB_PEER_ID);
|
|
133
|
+
const needsInstances = definitionOf(given) && rest.some(arg => arg === "--as" || arg.startsWith("--as=")) || markerNeedsDeclaration;
|
|
134
|
+
let declaredInstances: ReturnType<typeof readInstanceConfig>["instances"];
|
|
135
|
+
let nativeSelection: ReturnType<typeof instanceLaunchArguments>;
|
|
136
|
+
try {
|
|
137
|
+
declaredInstances = needsInstances ? readInstanceConfig(cwd).instances : undefined;
|
|
138
|
+
nativeSelection = instanceLaunchArguments(given, rest, declaredInstances);
|
|
139
|
+
} catch (error) { fail(startupDiagnostic(error)); }
|
|
140
|
+
const selectedInstance = nativeSelection.instance;
|
|
141
|
+
// `ahub <command> -h` alone asks for that command's help instead of running it. Elsewhere `-h` may be message text
|
|
142
|
+
// (`ahub say try ls -h`), and claude and codex pass their arguments on to the agent.
|
|
143
|
+
const asksHelp = (a: string | undefined) => a === "--help" || a === "-h";
|
|
144
|
+
const cmd = asksHelp(given) || (definitionOf(given)?.start.passthrough !== true && nativeSelection.args.length === 1 && asksHelp(nativeSelection.args[0])) ? "help" : given;
|
|
145
|
+
const args = cmd === "help" && given !== "help" && !asksHelp(given) ? [given] : nativeSelection.args;
|
|
146
|
+
|
|
147
|
+
/** Resolve a named peer only against this project's current trusted declarations. */
|
|
148
|
+
const peerDefinition = (peer: unknown) => typeof peer === "string"
|
|
149
|
+
? definitionOf(peer) ?? definitionOf(peer, readInstanceConfig(cwd).instances) : undefined;
|
|
150
|
+
const requireNamedPeerFeature = (hub: ControlClient, peer: string | undefined) => {
|
|
151
|
+
if (peer !== undefined && !definitionOf(peer)) requirePeerInstances(hub.features);
|
|
152
|
+
};
|
|
153
|
+
|
|
122
154
|
/** The project config, saying on stderr which machine-local fields it had to ignore (issue #17). */
|
|
123
155
|
const projectConfig = () => {
|
|
124
156
|
const config = loadConfig(cwd);
|
|
@@ -126,7 +158,7 @@ const projectConfig = () => {
|
|
|
126
158
|
return config;
|
|
127
159
|
};
|
|
128
160
|
const stateDir = selected.stateDir;
|
|
129
|
-
const caller = detectCliIdentity(process.env);
|
|
161
|
+
const caller = declaredInstances ? detectCliIdentity(process.env, declaredInstances) : unboundCaller;
|
|
130
162
|
const commandLabel = cliCommandLabel(cmd, args);
|
|
131
163
|
const commandAccess = classifyPeerCommand(cmd, args);
|
|
132
164
|
// Agent-authored content keeps its peer. Operational starts/holds may borrow console authority for live verification.
|
|
@@ -216,18 +248,18 @@ async function healthy(): Promise<boolean> {
|
|
|
216
248
|
finally { hub?.close(); }
|
|
217
249
|
}
|
|
218
250
|
|
|
219
|
-
function exec(bin: string, argv: string[], tool?: "claude" | "codex" | "kimi"): never {
|
|
220
|
-
const env = tool ? nativeLaunchEnv(tool, childEnv()) : childEnv();
|
|
221
|
-
const res = spawnSync(bin, argv, { cwd, stdio: "inherit", env: { ...env, AGENTHUB_STATE_DIR: stateDir, AGENTHUB_PROJECT_DIR: cwd } });
|
|
251
|
+
function exec(bin: string, argv: string[], tool?: "claude" | "codex" | "kimi", executionCwd = cwd): never {
|
|
252
|
+
const env = tool ? nativeLaunchEnv(selectedInstance ?? tool, childEnv(), declaredInstances) : childEnv();
|
|
253
|
+
const res = spawnSync(bin, argv, { cwd: executionCwd, stdio: "inherit", env: { ...env, AGENTHUB_STATE_DIR: stateDir, AGENTHUB_PROJECT_DIR: cwd } });
|
|
222
254
|
if (res.error) fail(`cannot run ${bin}: ${res.error.message}`);
|
|
223
255
|
process.exit(res.status ?? 1);
|
|
224
256
|
}
|
|
225
257
|
|
|
226
|
-
function execWithEnv(bin: string, argv: string[], extra: NodeJS.ProcessEnv): never {
|
|
227
|
-
const env: NodeJS.ProcessEnv = { ...nativeLaunchEnv("pi", extra), AGENTHUB_STATE_DIR: stateDir, AGENTHUB_PROJECT_DIR: cwd };
|
|
258
|
+
function execWithEnv(bin: string, argv: string[], extra: NodeJS.ProcessEnv, executionCwd = cwd): never {
|
|
259
|
+
const env: NodeJS.ProcessEnv = { ...nativeLaunchEnv(selectedInstance ?? "pi", extra, declaredInstances), AGENTHUB_STATE_DIR: stateDir, AGENTHUB_PROJECT_DIR: cwd };
|
|
228
260
|
delete env.AGENTHUB_RECOVERY_OPERATION;
|
|
229
261
|
delete env.AGENTHUB_UNATTENDED;
|
|
230
|
-
const res = spawnSync(bin, argv, { cwd, stdio: "inherit", env });
|
|
262
|
+
const res = spawnSync(bin, argv, { cwd: executionCwd, stdio: "inherit", env });
|
|
231
263
|
if (res.error) fail(`cannot run ${bin}: ${res.error.message}`);
|
|
232
264
|
process.exit(res.status ?? 1);
|
|
233
265
|
}
|
|
@@ -324,6 +356,12 @@ async function orphanDoctor(kill: boolean, options: ReturnType<typeof outputOpti
|
|
|
324
356
|
if (failed) fail(`${failed} orphaned hub process(es) could not be stopped`);
|
|
325
357
|
}
|
|
326
358
|
|
|
359
|
+
/** Startup refusals remain single-line and bounded even for malformed local declarations. */
|
|
360
|
+
function startupDiagnostic(error: unknown): string {
|
|
361
|
+
const message = error instanceof Error ? error.message : "invalid startup configuration";
|
|
362
|
+
return message.replace(/[\p{Cc}\p{Cf}]/gu, " ").replace(/\s+/g, " ").trim().slice(0, 512) || "invalid startup configuration";
|
|
363
|
+
}
|
|
364
|
+
|
|
327
365
|
function fail(message: string): never {
|
|
328
366
|
console.error(`ahub: ${message}`);
|
|
329
367
|
process.exit(1);
|
|
@@ -552,7 +590,10 @@ async function upgrade(kind: "restart" | "upgrade"): Promise<void> {
|
|
|
552
590
|
|
|
553
591
|
/** Strict person command: recorded identity only, no session or terminal overrides. */
|
|
554
592
|
function parseRecoveryReattach(argv: readonly string[]): { id: string; peer: TerminalPeer } | undefined {
|
|
555
|
-
if (argv.length !== 3 || argv[0] !== "reattach" || !operationIdPattern.test(argv[1]!) ||
|
|
593
|
+
if (argv.length !== 3 || argv[0] !== "reattach" || !operationIdPattern.test(argv[1]!) || !/^[a-z][a-z0-9-]{0,31}$/.test(argv[2]!)) return undefined;
|
|
594
|
+
const builtin = definitionOf(argv[2]!);
|
|
595
|
+
if (builtin && builtin.session.launchRecord !== true) return undefined;
|
|
596
|
+
// Extra peers are resolved against the operation's canonical project by the recovery core, never by suffix.
|
|
556
597
|
return { id: argv[1]!, peer: argv[2] as TerminalPeer };
|
|
557
598
|
}
|
|
558
599
|
|
|
@@ -805,13 +846,25 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
805
846
|
...Object.fromEntries(BUILTIN_CLI_ENTRIES.map(entry => [entry.command, async () => {
|
|
806
847
|
// Preserve the original guard before native effects; previews remain read-only and precede this guard.
|
|
807
848
|
if (entry.lifecycleAdmission && !args.includes("--print-command") && !args.includes("--dry-run")) assertLifecycleAvailable();
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
849
|
+
const nativeConnect = async () => {
|
|
850
|
+
const hub = await connect();
|
|
851
|
+
try { if (selectedInstance) { requirePeerInstances(hub.features); requireActiveInstance((await hub.request({ t: "status" })).status, selectedInstance); } return hub; }
|
|
852
|
+
catch (error) { hub.close(); throw error; }
|
|
853
|
+
};
|
|
854
|
+
if (selectedInstance && !args.includes("--print-command") && !args.includes("--dry-run")) {
|
|
855
|
+
const hub = await nativeConnect(); hub.close();
|
|
856
|
+
}
|
|
857
|
+
const context: NativeCliEntryContext = { args, cwd, stateDir, cliDir: import.meta.dir, unattendedEnv, definition: entry.definition,
|
|
858
|
+
...(selectedInstance ? { instance: selectedInstance } : {}), ...(nativeSelection.hubStarted ? { hubStarted: true as const } : {}),
|
|
859
|
+
projectConfig, connect: nativeConnect, fail,
|
|
860
|
+
exec: (bin, argv, tool) => exec(bin, argv, tool, context.executionCwd),
|
|
861
|
+
execWithEnv: (bin, argv, env) => execWithEnv(bin, argv, env, context.executionCwd), takeFlags,
|
|
862
|
+
helpers: { launcherPreview: (tool, raw, projectRoot, state, unattended) => launcherPreview(tool, raw, projectRoot, state, unattended, selectedInstance), readControl, recordTerminalLaunch, recoveryLock, processSignature,
|
|
811
863
|
buildLaunch, claudeObservationHooks, recordClaudeLaunch, cleanupStaleClaudeSettings, cleanupClaudeSettings,
|
|
812
864
|
nativeLaunchEnv, runClaudeLaunch, childEnv, qwenSessionId, configureJcodeMcp, configureKimiMcp,
|
|
813
865
|
buildKimiTuiLaunch, startModeOf },
|
|
814
|
-
}
|
|
866
|
+
};
|
|
867
|
+
return entry.execute(context);
|
|
815
868
|
}])),
|
|
816
869
|
|
|
817
870
|
models: async () => {
|
|
@@ -897,7 +950,7 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
897
950
|
|
|
898
951
|
watch: async () => {
|
|
899
952
|
const peer = args[0];
|
|
900
|
-
if (typeof peer !== "string" || !
|
|
953
|
+
if (typeof peer !== "string" || !peerDefinition(peer)?.capabilities?.watch) fail("usage: ahub watch kimi|qwen [--no-thoughts] [--color=auto|always|never]");
|
|
901
954
|
if (args.slice(1).some(arg => arg !== "--no-thoughts" && !/^--color=(auto|always|never)$/.test(arg))) fail("unsupported watch option");
|
|
902
955
|
const color = resolveColor(args.find(arg => arg.startsWith("--color="))?.slice(8), { isTTY: !!process.stdout.isTTY, ...process.env });
|
|
903
956
|
if (color instanceof Error) fail(color.message);
|
|
@@ -979,6 +1032,10 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
979
1032
|
backlog: async () => {
|
|
980
1033
|
const [sub, id, revision, value, ...reason] = args;
|
|
981
1034
|
if (sub === "list" && args.length === 1) return console.log(await taskOp("hub_backlog_list", {}));
|
|
1035
|
+
if (sub === "groom" && args.length === 1) return console.log(await taskOp("hub_planner_groom", {}));
|
|
1036
|
+
if (["return", "return-untouched"].includes(sub ?? "") && /^[1-9]\d*$/.test(id ?? "") && /^\d+$/.test(revision ?? "") && args.length >= 4) {
|
|
1037
|
+
return console.log(await taskOp("hub_backlog_return_untouched", { id: Number(id), revision: Number(revision), reason: args.slice(3).join(" ") }));
|
|
1038
|
+
}
|
|
982
1039
|
if (sub === "add" && args.length >= 3) return console.log(await taskOp("hub_task_propose", { class: id, title: args.slice(2).join(" "), backlog: true }));
|
|
983
1040
|
if (!/^[1-9]\d*$/.test(id ?? "")) fail("usage: ahub backlog list|add <class> <title...>|pull|return <id>|plan|pin <id> <revision> <value> <reason...>|undo <id> <revision>");
|
|
984
1041
|
if (["pull", "return"].includes(sub ?? "") && args.length === 2) return console.log(await taskOp(sub === "pull" ? "backlog_pull" : "backlog_return", { id }));
|
|
@@ -1061,6 +1118,31 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1061
1118
|
finally { hub.close(); }
|
|
1062
1119
|
return;
|
|
1063
1120
|
}
|
|
1121
|
+
if (sub === "costs") {
|
|
1122
|
+
if (args.length > 2) fail("usage: ahub sdlc costs [owner/repository]");
|
|
1123
|
+
return console.log(await taskOp("hub_sdlc_costs", { ...(args[1] ? { repo: args[1] } : {}) }));
|
|
1124
|
+
}
|
|
1125
|
+
if (sub === "plan-review") {
|
|
1126
|
+
const revision = Number(args[2]);
|
|
1127
|
+
if (!Number.isSafeInteger(id) || id! <= 0 || !Number.isSafeInteger(revision) || revision < 0 || args.length < 4) fail("usage: ahub sdlc plan-review <task> <revision> <reason...>");
|
|
1128
|
+
const flags = takeFlags(args.slice(3), ["--review", "--reviewers", "--ci"], []);
|
|
1129
|
+
const scope = flags.one["--review"], reviewers = flags.one["--reviewers"];
|
|
1130
|
+
if (!flags.rest.length || (scope === undefined) !== (reviewers === undefined)) fail("plan-review needs a reason and both --review and --reviewers when supplying review scope");
|
|
1131
|
+
return console.log(await taskOp("hub_plan_review", { id, revision, reason: flags.rest.join(" "), ...(scope !== undefined ? { review: { scope, reviewers: Number(reviewers) } } : {}), ...(flags.one["--ci"] !== undefined ? { ci: flags.one["--ci"] } : {}) }));
|
|
1132
|
+
}
|
|
1133
|
+
if (sub === "accept") {
|
|
1134
|
+
// #481 S1: the person accepts the task's spec issue at its current body; agent shells are refused before this runs.
|
|
1135
|
+
const flags = takeFlags(args.slice(2), ["--issue", "--repo"], []);
|
|
1136
|
+
const issue = Number(flags.one["--issue"]), repo = flags.one["--repo"];
|
|
1137
|
+
if (flags.rest.length || !Number.isSafeInteger(id) || id! <= 0 || !Number.isSafeInteger(issue) || issue <= 0) fail("usage: ahub sdlc accept <task> --issue <n> [--repo owner/repository]");
|
|
1138
|
+
const hub = await connect();
|
|
1139
|
+
try {
|
|
1140
|
+
const reply = await hub.request({ t: "sdlc", op: "accept", id, issue, ...(repo ? { repo } : {}) }, 30_000);
|
|
1141
|
+
if (!reply.ok) fail(reply.error);
|
|
1142
|
+
console.log(`task #${id}: spec issue #${issue} accepted at body edit ${reply.acceptance?.lastEditedAt ?? "never"}`);
|
|
1143
|
+
} finally { hub.close(); }
|
|
1144
|
+
return;
|
|
1145
|
+
}
|
|
1064
1146
|
if (sub === "record") {
|
|
1065
1147
|
if (args.length !== 3 || !Number.isSafeInteger(id) || id! <= 0 || Buffer.byteLength(args[2]!) > 32768) fail("usage: ahub sdlc record <task> '<receipt JSON>'");
|
|
1066
1148
|
let receipt: unknown; try { receipt = JSON.parse(args[2]!); } catch { fail("receipt must be valid JSON"); }
|
|
@@ -1128,9 +1210,14 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1128
1210
|
},
|
|
1129
1211
|
|
|
1130
1212
|
review: async () => {
|
|
1213
|
+
// #520: the review brief a reviewer gets, for a person or an outside tool.
|
|
1214
|
+
if (args[0] === "brief") {
|
|
1215
|
+
if (args.length !== 2 || !/^\d+$/.test(args[1]!)) fail("usage: ahub review brief <task id>");
|
|
1216
|
+
return console.log(await taskOp("review_brief", { id: Number(args[1]) }));
|
|
1217
|
+
}
|
|
1131
1218
|
const { one, many, rest } = takeFlags(args, ["--head"], ["--unmet"]);
|
|
1132
1219
|
const [id, verdict, ...note] = rest;
|
|
1133
|
-
if (!id || !verdict) fail("usage: ahub review <id> approved|changes_requested [note...] [--unmet <item>]...");
|
|
1220
|
+
if (!id || !verdict) fail("usage: ahub review <id> approved|changes_requested [note...] [--unmet <item>]... | ahub review brief <id>");
|
|
1134
1221
|
console.log(await taskOp("hub_review", { id: Number(id), verdict, ...(one["--head"] ? { head: one["--head"] } : {}), note: note.join(" "), ...(many["--unmet"]?.length ? { unmet: many["--unmet"] } : {}) }));
|
|
1135
1222
|
},
|
|
1136
1223
|
|
|
@@ -1202,9 +1289,10 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1202
1289
|
roster: async () => {
|
|
1203
1290
|
const options = outputOptions(), flags = args.filter(arg => arg.startsWith("--")), positional = outputArguments().filter(arg => arg !== "--yes" && arg !== "--now");
|
|
1204
1291
|
const [sub = "list", peer, ...extra] = positional;
|
|
1205
|
-
if (!["list", "add", "remove"].includes(sub) || extra.length || (sub === "list" ? peer !== undefined : !
|
|
1292
|
+
if (!["list", "add", "remove"].includes(sub) || extra.length || (sub === "list" ? peer !== undefined : !peerDefinition(peer)) || flags.some(flag => !["--json", "--full", "--yes", "--now"].includes(flag) && !flag.startsWith("--color=")) || (sub !== "list" && options.json) || (sub !== "remove" && flags.some(flag => ["--yes", "--now"].includes(flag)))) fail("usage: ahub roster [--json] | add <peer> | remove <peer> [--yes] [--now]");
|
|
1206
1293
|
const hub = await connect();
|
|
1207
1294
|
try {
|
|
1295
|
+
requireNamedPeerFeature(hub, peer);
|
|
1208
1296
|
const request = async (message: Record<string, unknown>) => { const reply = await hub.request(message, 35_000); if (reply.ok !== true) fail(String(reply.error ?? "roster request refused")); return reply; };
|
|
1209
1297
|
if (sub === "list") {
|
|
1210
1298
|
const view = await request({ t: "settings_get" });
|
|
@@ -1565,7 +1653,22 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1565
1653
|
if (!turn.ended) fail(`turn ${id} is still running`);
|
|
1566
1654
|
const { start_tree: startTree, end_tree: endTree } = turn;
|
|
1567
1655
|
if (!startTree || !endTree) fail(`turn ${id} has no ${startTree ? "end" : "start"} snapshot: the hub stopped during it, or a snapshot failed (see hub.log)`);
|
|
1568
|
-
const
|
|
1656
|
+
const recordedWorkspace = turnRecords(t => t.workspace(id), undefined);
|
|
1657
|
+
const undoWorkspace = (): string => {
|
|
1658
|
+
// Old turns, including external peers and ids declared only later, belong to the canonical checkout.
|
|
1659
|
+
if (recordedWorkspace === undefined) return cwd;
|
|
1660
|
+
const declarations = readInstanceConfig(cwd).instances;
|
|
1661
|
+
if (!declarations || !Object.hasOwn(declarations, turn.peer)) fail(`turn ${id}'s recorded instance is no longer declared; nothing was restored`);
|
|
1662
|
+
const config = projectConfig();
|
|
1663
|
+
const current = resolveInstanceExecutionCwd(cwd, turn.peer, declarations, effectiveInstanceRoles(turn.peer, declarations, config.roles));
|
|
1664
|
+
if (realPath(current) !== realPath(recordedWorkspace)) fail(`turn ${id}'s recorded execution workspace changed; nothing was restored`);
|
|
1665
|
+
return current;
|
|
1666
|
+
};
|
|
1667
|
+
const undoCwd = undoWorkspace();
|
|
1668
|
+
const requireUndoWorkspace = () => {
|
|
1669
|
+
if (realPath(undoWorkspace()) !== realPath(undoCwd)) fail(`turn ${id}'s execution workspace changed; nothing was restored`);
|
|
1670
|
+
};
|
|
1671
|
+
const repo = repoOf(undoCwd) ?? fail(`${undoCwd} is not in a git work tree`);
|
|
1569
1672
|
if (!hasTree(repo, startTree) || !hasTree(repo, endTree)) fail(`turn ${id}'s snapshots are gone from the git object store (git gc prunes them after two weeks)`);
|
|
1570
1673
|
// Another peer's turn that overlapped this one in time has its changes in this turn's diff as well.
|
|
1571
1674
|
const undoConfig = projectConfig();
|
|
@@ -1587,11 +1690,12 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1587
1690
|
if (first.undone) return console.log(`turn ${id} is already undone: its files are as they were before it started`);
|
|
1588
1691
|
console.log(first.restore.length ? `turn ${id} changed:\n ${first.restore.join("\n ")}` : `turn ${id} changed no files`);
|
|
1589
1692
|
console.log("(every change made in the project during the turn counts as its own, including any by Claude or by you)");
|
|
1590
|
-
const projections = new TextProjections({ cwd, stateDir, config: undoConfig.text_projection ?? {}, deny: undoConfig.local.deny, readAllow: undoConfig.local.read_allow, log: line => console.error(line) });
|
|
1591
|
-
const preview = projectedDiff({ repo, from: endTree, to: startTree, paths: first.restore, projections, deny: undoConfig.local.deny, privatePath: path => matchesPolicy(
|
|
1693
|
+
const projections = new TextProjections({ cwd: undoCwd, stateDir, config: undoConfig.text_projection ?? {}, deny: undoConfig.local.deny, readAllow: undoConfig.local.read_allow, log: line => console.error(line) });
|
|
1694
|
+
const preview = projectedDiff({ repo, from: endTree, to: startTree, paths: first.restore, projections, deny: undoConfig.local.deny, privatePath: path => matchesPolicy(undoCwd, join(repo.top, path), undoConfig.pii?.paths ?? []) });
|
|
1592
1695
|
if (preview) console.log(terminalText("Undo preview (end snapshot to start snapshot):\n" + preview));
|
|
1593
1696
|
|
|
1594
1697
|
if (!args.includes("--yes")) return console.log("nothing was changed; add --yes to restore these files");
|
|
1698
|
+
requireUndoWorkspace();
|
|
1595
1699
|
if (args.includes("--context")) {
|
|
1596
1700
|
console.log(await taskOp("turn_revert", { turn: id }));
|
|
1597
1701
|
reverted = true;
|
|
@@ -1599,6 +1703,7 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1599
1703
|
// A peer may have written meanwhile (the revert alone can take seconds): plan again right before restoring.
|
|
1600
1704
|
const second = plan();
|
|
1601
1705
|
if (second.restore.join("\0") !== first.restore.join("\0")) fail(`the project changed while undoing; nothing was restored, run ahub undo again${reverted ? " without --context (Codex's conversation was already reverted)" : ""}`);
|
|
1706
|
+
requireUndoWorkspace();
|
|
1602
1707
|
restore(repo, startTree, second.restore);
|
|
1603
1708
|
if (second.restore.length) console.log(`restored to their state before ${id}`);
|
|
1604
1709
|
},
|
|
@@ -1706,9 +1811,10 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1706
1811
|
}
|
|
1707
1812
|
if (["assign", "clear"].includes(args[0] ?? "")) {
|
|
1708
1813
|
const [op, peer, id] = args;
|
|
1709
|
-
if (!peer || !
|
|
1814
|
+
if (!peer || !peerDefinition(peer) || !id && op === "assign" || args.length > 3) fail("usage: ahub role assign <peer> <id> | clear <peer> [<id>]");
|
|
1710
1815
|
const hub = await connect();
|
|
1711
1816
|
try {
|
|
1817
|
+
requireNamedPeerFeature(hub, peer);
|
|
1712
1818
|
const view = await hub.request({ t: "settings_get" }); if (!view.ok) fail(view.error);
|
|
1713
1819
|
const row = (view.rows as SettingRow[]).find(row => row.key === `catalog_roles.${peer}`); if (!row) fail("this hub does not support catalog role settings");
|
|
1714
1820
|
const current = Array.isArray(row!.pending) ? row!.pending : Array.isArray(row!.value) ? row!.value : [];
|
|
@@ -1825,8 +1931,9 @@ async function runConductorCommand(): Promise<void> {
|
|
|
1825
1931
|
if (parsed instanceof Error) fail(parsed.message);
|
|
1826
1932
|
op = "hub_peer_start";
|
|
1827
1933
|
// #269: a mode may only repeat the peer's start mode, which a person sets; without one the hub takes that mode.
|
|
1828
|
-
input = { peer: cmd, ...parsed };
|
|
1934
|
+
input = { peer: selectedInstance ?? cmd, ...parsed };
|
|
1829
1935
|
}
|
|
1936
|
+
if (selectedInstance) { const hub = await connect(); try { requirePeerInstances(hub.features); } finally { hub.close(); } }
|
|
1830
1937
|
console.log(await taskOp(op, input));
|
|
1831
1938
|
}
|
|
1832
1939
|
const run = identity.role === "tools" && commandAccess === "conductor" ? runConductorCommand : (Object.hasOwn(commands, cmd) ? commands[cmd] : undefined) ?? (() => fail(`unknown command "${cmd}"; run ahub help`));
|
package/src/cli/preview.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { definitionOf, builtinPreviewOf } from "../adapters/registry.ts";
|
|
|
2
2
|
import { loadConfig } from "../hub/daemon.ts";
|
|
3
3
|
import { childEnv } from "../hub/child-process.ts";
|
|
4
4
|
import { startModeOf } from "../hub/start-mode.ts";
|
|
5
|
-
import { buildLaunch, buildKimiLaunch, buildKimiTuiLaunch, nativeLaunchEnv, claudeObservationHooks } from "./launch.ts";
|
|
5
|
+
import { instanceLaunchArguments, buildLaunch, buildKimiLaunch, buildKimiTuiLaunch, nativeLaunchEnv, claudeObservationHooks } from "./launch.ts";
|
|
6
6
|
import { kimiMcpPlan } from "./kimi-mcp.ts";
|
|
7
7
|
import { jcodeCommand } from "../adapters/jcode.ts";
|
|
8
8
|
import { qwenSessionId } from "../hub/crash.ts";
|
|
@@ -10,14 +10,25 @@ import { relayModelIds } from "../models/relay.ts";
|
|
|
10
10
|
import { buildPiLaunch } from "../pi/launch.ts";
|
|
11
11
|
|
|
12
12
|
/** Read-only builtin preview. Native redaction uses the same launch builders as actual startup. */
|
|
13
|
-
export function launcherPreview(tool: "claude" | "codex" | "kimi" | "pi" | "qwen" | "jcode", raw: string[], cwd: string, stateDir: string, unattended: boolean) {
|
|
14
|
-
const args = raw.filter(arg => !["--print-command", "--dry-run", "--json"].includes(arg));
|
|
13
|
+
export function launcherPreview(tool: "claude" | "codex" | "kimi" | "pi" | "qwen" | "jcode", raw: string[], cwd: string, stateDir: string, unattended: boolean, requestedInstance?: string) {
|
|
15
14
|
const config = loadConfig(cwd);
|
|
15
|
+
const selected = instanceLaunchArguments(tool, requestedInstance ? ["--as", requestedInstance, ...raw] : raw, config.instances);
|
|
16
|
+
const args = selected.args.filter(arg => !["--print-command", "--dry-run", "--json"].includes(arg));
|
|
16
17
|
const definition = definitionOf(tool), preview = builtinPreviewOf(tool);
|
|
17
18
|
if (!definition || !preview) throw new Error("native preview definition unavailable");
|
|
18
|
-
|
|
19
|
+
if (selected.instance && definition.start.explicitHeadlessMode) {
|
|
20
|
+
const mode = args.indexOf("--mode");
|
|
21
|
+
if (mode >= 0 && args[mode + 1] !== "headless") throw new Error("extra instances require headless mode");
|
|
22
|
+
if (mode < 0 && !args.includes("--headless")) args.push("--mode", "headless");
|
|
23
|
+
}
|
|
24
|
+
const result = preview({ tool, args, cwd, stateDir, cliDir: import.meta.dir, unattended, config, definition,
|
|
19
25
|
baseEnvNames: Object.keys(childEnv()).concat(["AGENTHUB_STATE_DIR", "AGENTHUB_PROJECT_DIR"]),
|
|
20
|
-
helpers: { buildLaunch, claudeObservationHooks, buildKimiLaunch, buildKimiTuiLaunch, nativeLaunchEnv, childEnv,
|
|
26
|
+
helpers: { buildLaunch: (native, argv, ctx) => buildLaunch(native, argv, { ...ctx, ...(selected.instance ? { instance: selected.instance } : {}) }), claudeObservationHooks, buildKimiLaunch, buildKimiTuiLaunch, nativeLaunchEnv, childEnv,
|
|
21
27
|
startModeOf, kimiMcpPlan, jcodeCommand, qwenSessionId, relayModelIds, buildPiLaunch },
|
|
22
28
|
});
|
|
29
|
+
if (!selected.instance) return result;
|
|
30
|
+
return { ...result, instance: selected.instance,
|
|
31
|
+
...(result.mcp ? { mcp: { ...result.mcp, action: "session-scoped MCP identity; shared project file unchanged", changed: false } } : {}),
|
|
32
|
+
unresolved: [...result.unresolved, { field: "executionCwd", reason: "verified by the daemon at instance start; preview does not start or authorize a worktree" }],
|
|
33
|
+
};
|
|
23
34
|
}
|
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
import { ADAPTERS, definitionOf } from "../adapters/registry.ts";
|
|
1
|
+
import { ADAPTERS, definitionOf, type AdapterDefinition, type KindMap } from "../adapters/registry.ts";
|
|
2
|
+
import { readInstanceConfig } from "../hub/instance-config.ts";
|
|
2
3
|
import { recordedFileState } from "./recovery-session-file.ts";
|
|
3
4
|
import { decodeRestoreReceipt } from "./upgrade-restore-state.ts";
|
|
4
5
|
import { shellQuote } from "./terminal-recovery.ts";
|
|
5
6
|
import type { TerminalBinding } from "./terminal-recovery.ts";
|
|
6
7
|
import type { Inspection, PlannedProject, ProjectProgress, RecoveryOperation, RecoveryPeer } from "./upgrade.ts";
|
|
7
8
|
|
|
8
|
-
export type RecoveryNativePeer =
|
|
9
|
+
export type RecoveryNativePeer = string;
|
|
9
10
|
export type RecordedSessionOverride = Pick<TerminalBinding, "sessionId" | "sessionFile">;
|
|
10
11
|
export interface RecordedPeerGuidance {
|
|
11
12
|
peer: RecoveryNativePeer;
|
|
@@ -26,30 +27,35 @@ export interface RecoveryGuidanceOptions {
|
|
|
26
27
|
|
|
27
28
|
const text = (value: unknown): value is string => typeof value === "string" && value.length > 0;
|
|
28
29
|
const argv = (...words: string[]): string => words.map(shellQuote).join(" ");
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
.
|
|
30
|
+
const projectDefinition = (peer: string, root: string) => definitionOf(peer) ?? definitionOf(peer, readInstanceConfig(root).instances);
|
|
31
|
+
const peers = (instances: KindMap | undefined): RecoveryNativePeer[] => [...ADAPTERS.map(adapter => adapter.kind), ...Object.keys(instances ?? {})]
|
|
32
|
+
.filter(peer => definitionOf(peer, instances)?.session.launchRecord)
|
|
33
|
+
.sort((a, b) => Number(definitionOf(a, instances)?.transport === "claude-channel") - Number(definitionOf(b, instances)?.transport === "claude-channel"));
|
|
32
34
|
|
|
33
|
-
function sourceSession(
|
|
34
|
-
const sessionId = source?.[
|
|
35
|
-
const sessionFile =
|
|
35
|
+
function sourceSession(adapter: AdapterDefinition, source: RecoveryPeer | undefined): { sessionId?: string; sessionFile?: string } {
|
|
36
|
+
const sessionId = source?.[adapter.session.idField];
|
|
37
|
+
const sessionFile = adapter.session.crashIdentity ? source?.sessionFile ?? source?.args?.sessionFile : undefined;
|
|
36
38
|
return { ...(text(sessionId) ? { sessionId } : {}), ...(text(sessionFile) ? { sessionFile } : {}) };
|
|
37
39
|
}
|
|
38
40
|
|
|
39
41
|
/** Fixed person commands for the existing Pi archive exit; this helper performs no lifecycle action. */
|
|
40
|
-
export function piArchiveExit(planned: PlannedProject, stopAndArchiveCommand: string): string {
|
|
41
|
-
|
|
42
|
+
export function piArchiveExit(planned: PlannedProject, stopAndArchiveCommand: string, peer?: RecoveryNativePeer): string {
|
|
43
|
+
const adapter = peer ? projectDefinition(peer, planned.project.root) : undefined;
|
|
44
|
+
if (peer && (!adapter?.session.launchRecord || adapter.session.personFresh !== false)) throw new Error("recorded archive-exit peer has no current trusted Pi recovery declaration");
|
|
45
|
+
const instance = peer && adapter && peer !== adapter.kind ? ["--as", peer] : [];
|
|
46
|
+
return `Run ${stopAndArchiveCommand}; after stop-and-archive, run ${argv("ahub", "--project", planned.project.root, "pi", ...instance)}. A proven missing archived bookmark is retired by the normal Pi start without a fresh fallback in that same call; repeat the normal start as its notice directs.`;
|
|
42
47
|
}
|
|
43
48
|
|
|
44
49
|
/** Accepted identity outlives an incarnation's repair fence. Malformed evidence never means absence. */
|
|
45
|
-
export function resolveAcceptedBinding(planned: PlannedProject, progress: ProjectProgress, peer: RecoveryNativePeer): TerminalBinding | undefined {
|
|
50
|
+
export function resolveAcceptedBinding(planned: PlannedProject, progress: ProjectProgress, peer: RecoveryNativePeer, options: { authority?: "current" | "historical" } = {}): TerminalBinding | undefined {
|
|
46
51
|
if (progress.id !== planned.project.id) throw new Error("recorded binding project mismatch");
|
|
52
|
+
if (options.authority !== "historical" && !projectDefinition(peer, planned.project.root)?.session.launchRecord) throw new Error("recorded binding peer has no current trusted recovery declaration");
|
|
47
53
|
const binding = (value: unknown): TerminalBinding => {
|
|
48
|
-
const receipt = decodeRestoreReceipt({ state: "done", binding: value });
|
|
54
|
+
const receipt = decodeRestoreReceipt({ state: "done", binding: value }, options);
|
|
49
55
|
if (receipt?.state !== "done" || receipt.binding.peer !== peer || receipt.binding.projectRoot !== planned.project.root) throw new Error("recorded binding does not match its peer and project");
|
|
50
56
|
return receipt.binding;
|
|
51
57
|
};
|
|
52
|
-
const restored = decodeRestoreReceipt(progress.terminals[`restored:${peer}`]);
|
|
58
|
+
const restored = decodeRestoreReceipt(progress.terminals[`restored:${peer}`], options);
|
|
53
59
|
if (restored?.state === "done" || restored?.state === "waiting") return binding(restored.binding);
|
|
54
60
|
if (progress.reattachments !== undefined) {
|
|
55
61
|
if (!progress.reattachments || typeof progress.reattachments !== "object" || Array.isArray(progress.reattachments)) throw new Error("invalid recorded reattachment markers");
|
|
@@ -73,7 +79,18 @@ export function resolveAcceptedBinding(planned: PlannedProject, progress: Projec
|
|
|
73
79
|
* current receipts and the original plan. A malformed current receipt otherwise stays unavailable.
|
|
74
80
|
*/
|
|
75
81
|
export function recordedPeerGuidance(planned: PlannedProject, progress: ProjectProgress, options: RecoveryGuidanceOptions = {}): RecordedPeerGuidance[] {
|
|
76
|
-
|
|
82
|
+
let instances: KindMap | undefined;
|
|
83
|
+
try { instances = readInstanceConfig(planned.project.root).instances; }
|
|
84
|
+
catch {
|
|
85
|
+
// ponytail: failed-config guidance shows at most 64 recorded extra ids, the declaration ceiling;
|
|
86
|
+
// share an exported instance limit if that ceiling grows. These rows confer no launch authority.
|
|
87
|
+
const base = peers(undefined), recorded = [...planned.terminals.map(value => (value as { peer?: unknown })?.peer),
|
|
88
|
+
...Object.keys(progress.reattachments ?? {}), ...Object.keys(progress.terminals).filter(key => key.startsWith("restored:")).map(key => key.slice("restored:".length))];
|
|
89
|
+
const extra = [...new Set(recorded.filter((peer): peer is string => typeof peer === "string" && /^[a-z][a-z0-9-]{0,31}$/.test(peer) && !base.includes(peer)))].slice(0, 64);
|
|
90
|
+
return [...base, ...extra].map(peer => ({ peer, note: `${peer}: recorded session identity unavailable; project instance configuration could not be read; inspect the retained recovery receipt before choosing a conversation` }));
|
|
91
|
+
}
|
|
92
|
+
return peers(instances).map(peer => {
|
|
93
|
+
const adapter = definitionOf(peer, instances)!;
|
|
77
94
|
let session: { sessionId?: string; sessionFile?: string } | undefined;
|
|
78
95
|
let unavailable = false;
|
|
79
96
|
const override = options.recorded && Object.hasOwn(options.recorded, peer) ? options.recorded[peer] : undefined;
|
|
@@ -81,25 +98,25 @@ export function recordedPeerGuidance(planned: PlannedProject, progress: ProjectP
|
|
|
81
98
|
else {
|
|
82
99
|
try {
|
|
83
100
|
session = resolveAcceptedBinding(planned, progress, peer)
|
|
84
|
-
?? sourceSession(
|
|
101
|
+
?? sourceSession(adapter, planned.source.peers.find(row => row.id === peer));
|
|
85
102
|
} catch { unavailable = true; }
|
|
86
103
|
}
|
|
87
104
|
const sessionId = text(session?.sessionId) ? session.sessionId : undefined;
|
|
88
|
-
const sessionFile =
|
|
105
|
+
const sessionFile = adapter.session.crashIdentity && text(session?.sessionFile) ? session.sessionFile : undefined;
|
|
89
106
|
if (unavailable || (!sessionId && !sessionFile)) return { peer, note: `${peer}: recorded session identity unavailable; inspect the retained recovery receipt before choosing a conversation` };
|
|
90
107
|
const prefix = ["ahub", "--project", planned.project.root];
|
|
91
|
-
const adapter = definitionOf(peer)!;
|
|
92
108
|
const tail = adapter.session.resumeArgv?.({ sessionId: sessionId ?? "", ...(sessionFile ? { sessionFile } : {}) }, false);
|
|
93
109
|
const start = adapter.cli.startWords("tui");
|
|
94
110
|
// Person guidance preserves its legacy implied startup form; terminal creation pins the explicit form.
|
|
95
111
|
const personTail = tail && start.every((word, index) => tail[index] === word) ? [tail[0]!, ...tail.slice(start.length)] : tail;
|
|
96
|
-
const
|
|
97
|
-
|
|
112
|
+
const instanceTail = personTail && peer !== adapter.kind ? [personTail[0]!, "--as", peer, ...personTail.slice(1)] : personTail;
|
|
113
|
+
const command = instanceTail ? argv(...prefix, ...instanceTail) : undefined;
|
|
114
|
+
let note = adapter.transport === "claude-channel"
|
|
98
115
|
? "The existing Claude channel may reconnect while its native session remains open. Transcript availability is unknown; the recorded resume command is guidance, not proof that the transcript can be resumed."
|
|
99
|
-
:
|
|
100
|
-
: `Recorded ${
|
|
116
|
+
: adapter.session.idField === "threadId" ? `Recorded ${adapter.displayName} thread; leave the old native client before resuming this conversation.`
|
|
117
|
+
: `Recorded ${adapter.displayName} conversation; preserve this session when starting ${adapter.displayName} again.`;
|
|
101
118
|
if (sessionFile && options.stopAndArchiveCommand && recordedFileState(sessionFile) === "missing") {
|
|
102
|
-
note = `Recorded Pi session file is positively missing. Fresh-session is not supported for Pi. ${piArchiveExit(planned, options.stopAndArchiveCommand)}`;
|
|
119
|
+
note = `Recorded Pi session file is positively missing. Fresh-session is not supported for Pi. ${piArchiveExit(planned, options.stopAndArchiveCommand, peer)}`;
|
|
103
120
|
}
|
|
104
121
|
const reattachCommand = command ? options.reattachCommand?.(peer) : undefined;
|
|
105
122
|
return { peer, ...(sessionId ? { sessionId } : {}), ...(sessionFile ? { sessionFile } : {}), ...(command ? { command } : {}), ...(reattachCommand ? { reattachCommand } : {}), note };
|
package/src/cli/reset.ts
CHANGED
|
@@ -10,6 +10,8 @@ import { HUB_DB_BUSY_TIMEOUT_MS } from "../hub/db-timeout.ts";
|
|
|
10
10
|
|
|
11
11
|
/** Agent session resume pointers. `pi-sessions/` holds Pi transcripts, history rather than pointers, and stays. */
|
|
12
12
|
export const SESSION_POINTERS = ["sessions.json", "claude-session.json", "claude-context.json"];
|
|
13
|
+
/** The fixed pointers plus each Claude instance's own session and context files (#450 I3). */
|
|
14
|
+
const sessionPointers = (stateDir: string): string[] => [...SESSION_POINTERS, ...(existsSync(stateDir) ? readdirSync(stateDir).filter(name => /^claude-(session|context)\.[a-z][a-z0-9-]{0,31}\.json$/.test(name)) : [])];
|
|
13
15
|
/** What a running daemon publishes; a daemon that did not stop cleanly (crash, SIGKILL, forced exit) leaves it. */
|
|
14
16
|
export const MANIFEST = ["status.json", "control-token", "hub.pid"];
|
|
15
17
|
|
|
@@ -51,7 +53,7 @@ const shaped = (ok: boolean, what: string) => { if (!ok) throw new Error(`invali
|
|
|
51
53
|
/** What a reset would act on, read from the state directory without changing it (hub.db opened read-only). */
|
|
52
54
|
export function planReset(stateDir: string, projectId: string): ResetPlan {
|
|
53
55
|
const plan: ResetPlan = { queued: [], needsReview: [], inFlight: [], manualHolds: [], budgetPauses: [], conductorHolds: [],
|
|
54
|
-
sessionPointers:
|
|
56
|
+
sessionPointers: sessionPointers(stateDir).filter((name) => existsSync(join(stateDir, name))), entries: existsSync(stateDir) ? readdirSync(stateDir).length : 0 };
|
|
55
57
|
const file = join(stateDir, "hub.db");
|
|
56
58
|
if (!existsSync(file)) return plan;
|
|
57
59
|
// A read-only open of a WAL database creates hub.db-wal and -shm when the last writer's close removed them (Linux),
|
|
@@ -143,7 +145,7 @@ export function resetRuntime(stateDir: string, project: Project): { settled: str
|
|
|
143
145
|
})();
|
|
144
146
|
} finally { db.close(); }
|
|
145
147
|
}
|
|
146
|
-
for (const name of
|
|
148
|
+
for (const name of sessionPointers(stateDir)) rmSync(join(stateDir, name), { force: true });
|
|
147
149
|
// The caller has checked that no daemon behind these is alive or uncertain: they are a crashed run's leftovers.
|
|
148
150
|
const stale = MANIFEST.filter((name) => existsSync(join(stateDir, name)));
|
|
149
151
|
for (const name of stale) rmSync(join(stateDir, name), { force: true });
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { spawnSync } from "node:child_process";
|
|
7
7
|
import { chmodSync, mkdirSync, renameSync, writeFileSync } from "node:fs";
|
|
8
8
|
import { join } from "node:path";
|
|
9
|
+
import { claudePeer, claudeStateFile } from "../hub/claude-state.ts";
|
|
9
10
|
|
|
10
11
|
const input = await Bun.stdin.text();
|
|
11
12
|
let own = "";
|
|
@@ -15,6 +16,8 @@ try {
|
|
|
15
16
|
const pct = (w: any) => (typeof w?.used_percentage === "number" ? `${Math.round(w.used_percentage)}%` : "-");
|
|
16
17
|
if (limits) own = `agent-hub 5h ${pct(limits.five_hour)} wk ${pct(limits.seven_day)}`;
|
|
17
18
|
const dir = process.env.AGENTHUB_STATE_DIR;
|
|
19
|
+
// Usage is per account and shared; context and session belong to this peer (#450 I3: a claude-<name> instance).
|
|
20
|
+
const peer = claudePeer(process.env.AGENTHUB_PEER_ID);
|
|
18
21
|
if (limits && dir) {
|
|
19
22
|
mkdirSync(dir, { recursive: true });
|
|
20
23
|
const file = join(dir, "claude-usage.json");
|
|
@@ -25,7 +28,7 @@ try {
|
|
|
25
28
|
if (sessionId && dir) {
|
|
26
29
|
// Bind context to the launcher, daemon and native session. Persist no prompts or transcript text.
|
|
27
30
|
mkdirSync(dir, { recursive: true });
|
|
28
|
-
const file = join(dir, "claude-context
|
|
31
|
+
const file = join(dir, claudeStateFile("claude-context", peer));
|
|
29
32
|
const c = parsed.context_window;
|
|
30
33
|
const current = c?.current_usage;
|
|
31
34
|
const numeric = (v: unknown) => typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : null;
|
|
@@ -38,7 +41,7 @@ try {
|
|
|
38
41
|
}
|
|
39
42
|
if (sessionId && dir) {
|
|
40
43
|
mkdirSync(dir, { recursive: true });
|
|
41
|
-
const file = join(dir, "claude-session
|
|
44
|
+
const file = join(dir, claudeStateFile("claude-session", peer));
|
|
42
45
|
// The transcript path lets the hub tell a zero-turn session (file not yet created, so
|
|
43
46
|
// `claude --resume` can never work) from one whose conversation must be preserved (#64).
|
|
44
47
|
const transcriptPath = typeof parsed.transcript_path === "string" && parsed.transcript_path ? parsed.transcript_path : undefined;
|