@staix/agent-hub 0.12.33 → 0.12.35
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/README.md +3 -3
- package/docs/agent-notes/adapters.md +9 -3
- package/docs/agent-notes/budget.md +7 -3
- package/docs/agent-notes/bus.md +2 -1
- package/docs/agent-notes/daemon.md +7 -3
- package/docs/agent-notes/local-worker.md +1 -1
- package/docs/agent-notes/tasks.md +6 -2
- package/docs/agent-notes/tests.md +2 -1
- package/docs/gsd-collaboration-analysis.md +5 -1
- package/docs/operations.md +80 -16
- package/docs/quickstart.md +10 -2
- package/docs/sdlc.md +59 -34
- package/docs/security.md +7 -4
- package/docs/smoke.md +1 -1
- package/docs/specs/2026-09-19-agent-hub-design.md +407 -10
- package/docs/verification/2026-10-18-0.12.34.md +48 -0
- package/docs/verification/2026-10-18-0.12.35.md +49 -0
- package/docs/verification/2026-10-18-493-claude-busy-channel.md +91 -0
- package/docs/verified.json +35 -19
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +23567 -16606
- package/src/adapters/acp.ts +3 -1
- package/src/adapters/claude-channel.ts +59 -29
- package/src/adapters/codex-appserver.ts +82 -12
- package/src/adapters/definitions/builtin-metadata.ts +101 -7
- package/src/adapters/definitions/native-cli-claude-launch.ts +90 -0
- package/src/adapters/definitions/native-cli-claude-setup.ts +80 -0
- package/src/adapters/definitions/native-cli-codex-launch.ts +14 -0
- package/src/adapters/definitions/native-cli-doctor.ts +46 -0
- package/src/adapters/definitions/native-cli-entry-claude.ts +52 -0
- package/src/adapters/definitions/native-cli-entry-codex.ts +34 -0
- package/src/adapters/definitions/native-cli-entry-jcode.ts +27 -0
- package/src/adapters/definitions/native-cli-entry-kimi.ts +57 -0
- package/src/adapters/definitions/native-cli-entry-local.ts +15 -0
- package/src/adapters/definitions/native-cli-entry-pi.ts +53 -0
- package/src/adapters/definitions/native-cli-entry-qwen.ts +21 -0
- package/src/adapters/definitions/native-cli-entry-types.ts +41 -0
- package/src/adapters/definitions/native-cli-entry.ts +19 -0
- package/src/adapters/definitions/native-cli-jcode-mcp.ts +58 -0
- package/src/adapters/definitions/native-cli-launch-common.ts +17 -0
- package/src/adapters/definitions/native-cli-pi.ts +33 -0
- package/src/adapters/definitions/native-cli-preview-claude.ts +37 -0
- package/src/adapters/definitions/native-cli-preview-codex.ts +20 -0
- package/src/adapters/definitions/native-cli-preview-common.ts +57 -0
- package/src/adapters/definitions/native-cli-preview-jcode.ts +20 -0
- package/src/adapters/definitions/native-cli-preview-kimi.ts +28 -0
- package/src/adapters/definitions/native-cli-preview-pi.ts +36 -0
- package/src/adapters/definitions/native-cli-preview-qwen.ts +22 -0
- package/src/adapters/definitions/native-cli-preview.ts +16 -0
- package/src/adapters/definitions/native-config-defaults.ts +19 -0
- package/src/adapters/definitions/native-config-policy.ts +35 -0
- package/src/adapters/definitions/native-context.ts +35 -0
- package/src/adapters/definitions/native-daemon-kimi.ts +5 -0
- package/src/adapters/definitions/native-daemon-policy.ts +18 -0
- package/src/adapters/definitions/native-display.ts +18 -0
- package/src/adapters/definitions/native-factories.ts +48 -0
- package/src/adapters/definitions/native-policy-aux.ts +130 -0
- package/src/adapters/definitions/native-quota.ts +29 -0
- package/src/adapters/definitions/native-recovery-continuity.ts +12 -0
- package/src/adapters/definitions/native-recovery.ts +88 -0
- package/src/adapters/definitions/qwen-native.ts +2 -2
- package/src/adapters/definitions/qwen.ts +8 -4
- package/src/adapters/local-worker.ts +3 -2
- package/src/adapters/pi.ts +3 -1
- package/src/adapters/registry-types.ts +100 -10
- package/src/adapters/registry.ts +127 -11
- package/src/cli/console-state.ts +5 -5
- package/src/cli/identity.ts +8 -7
- package/src/cli/jcode-mcp.ts +2 -58
- package/src/cli/launch.ts +41 -102
- package/src/cli/main.ts +126 -290
- package/src/cli/output.ts +3 -2
- package/src/cli/preview.ts +26 -126
- package/src/cli/recovery-guidance.ts +43 -22
- package/src/cli/setup.ts +2 -80
- package/src/cli/shell-quote.ts +3 -0
- package/src/cli/terminal-recovery.ts +81 -22
- package/src/cli/upgrade-interactive.ts +4 -2
- package/src/cli/upgrade-restore-state.ts +17 -7
- package/src/cli/upgrade-runtime.ts +126 -147
- package/src/cli/upgrade.ts +44 -27
- package/src/hub/budget.ts +59 -31
- package/src/hub/bus.ts +1 -1
- package/src/hub/conductor.ts +19 -9
- package/src/hub/config-apply.ts +22 -15
- package/src/hub/config-reload.ts +18 -3
- package/src/hub/config-trust.ts +1 -1
- package/src/hub/context-window.ts +3 -27
- package/src/hub/control-client.ts +1 -1
- package/src/hub/crash.ts +15 -39
- package/src/hub/daemon.ts +506 -341
- package/src/hub/execution-budget.ts +3 -2
- package/src/hub/hub-tools.ts +19 -8
- package/src/hub/instance-budget.ts +73 -0
- package/src/hub/instance-config.ts +47 -0
- package/src/hub/instance-execution.ts +50 -0
- package/src/hub/instances.ts +100 -0
- package/src/hub/lifecycle-schema.ts +7 -3
- package/src/hub/peer-catalog.ts +4 -13
- package/src/hub/permission-mode.ts +14 -17
- package/src/hub/recovery-continuity.ts +6 -5
- package/src/hub/report.ts +9 -8
- package/src/hub/roster.ts +9 -9
- package/src/hub/routing.ts +94 -19
- package/src/hub/sdlc-queue.ts +2 -2
- package/src/hub/sdlc.ts +84 -38
- package/src/hub/settings.ts +39 -10
- package/src/hub/settled-roles.ts +1 -1
- package/src/hub/snapshots.ts +40 -9
- package/src/hub/start-mode.ts +13 -8
- package/src/hub/status-roster.ts +8 -0
- package/src/hub/tasks.ts +60 -27
- package/src/hub/text-projection.ts +7 -3
- package/src/hub/tooling.ts +4 -60
- package/src/hub/ui.ts +3 -1
- package/src/hub/workflow.ts +30 -52
- package/src/memory/recall.ts +4 -5
- package/src/models/route/signals.ts +3 -1
- package/src/pi/launch.ts +5 -3
- package/src/ui/index.html +4 -3
- package/src/ui/office-model.ts +3 -1
- package/src/ui/peer-palette.ts +16 -6
- package/templates/AGENTS.block.md +2 -1
- package/templates/routing.toml +2 -2
package/src/cli/main.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
import {
|
|
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";
|
|
6
|
+
import { BUILTIN_CLI_ENTRIES, definitionOf, DOCTOR_TOOL_PROBES, DOCTOR_MEMORY_CAPTURE_PEERS, DOCTOR_PLUGIN_PROBE, nativeDoctorCheck, nativeDoctorRecoveryIdentity, nativeDoctorOptionalProbes } from "../adapters/registry.ts";
|
|
7
|
+
import { parsePiFlags } from "../adapters/definitions/native-cli-pi.ts";
|
|
3
8
|
import { supportsBoardReleaseFilter, supportsBoardColumnMetadata } from "./board-read.ts";
|
|
4
9
|
import { matchesPolicy } from "../hub/path-policy.ts";
|
|
5
10
|
import { projectedDiff } from "../hub/projected-diff.ts";
|
|
@@ -16,7 +21,6 @@ import { configureJcodeMcp } from "./jcode-mcp.ts";
|
|
|
16
21
|
import { qwenSessionId } from "../hub/crash.ts";
|
|
17
22
|
import { isPermissionMode } from "../hub/permission-mode.ts";
|
|
18
23
|
import { parseSettingText, settingDef, settingText, type SettingRow } from "../hub/settings.ts";
|
|
19
|
-
import { isSupportedPeer } from "../hub/peer-catalog.ts";
|
|
20
24
|
import { startModeOf } from "../hub/start-mode.ts";
|
|
21
25
|
import { currentRouting } from "../hub/routing.ts";
|
|
22
26
|
import { spawn, spawnSync } from "node:child_process";
|
|
@@ -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, 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) || (given !== "claude" && given !== "codex" && 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,49 +248,24 @@ 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
|
}
|
|
234
266
|
|
|
235
267
|
/** `mode` is absent when no flag chose one: the caller takes the configured start mode (#269). `--headless` is `--mode headless`. */
|
|
236
|
-
|
|
237
|
-
const usage = "usage: ahub pi [--mode headless|tui | --headless] [--backend auto|dgx|mlx] [--model <alias>] [--session-id <id> | --session-file <path>] [--replace-session --session-file <path>]";
|
|
238
|
-
if (args.includes("--headless")) {
|
|
239
|
-
if (args.includes("--mode")) fail(usage);
|
|
240
|
-
args.splice(args.indexOf("--headless"), 1, "--mode", "headless");
|
|
241
|
-
}
|
|
242
|
-
for (const flag of ["--mode", "--backend", "--model", "--session-id", "--session-file"]) {
|
|
243
|
-
const index = args.indexOf(flag);
|
|
244
|
-
if (index >= 0 && (!args[index + 1] || args[index + 1]!.startsWith("--"))) fail(`${flag} needs a value`);
|
|
245
|
-
}
|
|
246
|
-
const mode = (args.includes("--mode") ? args[args.indexOf("--mode") + 1] : undefined) as string | undefined;
|
|
247
|
-
const backend = (args.includes("--backend") ? args[args.indexOf("--backend") + 1] : undefined) as string | undefined;
|
|
248
|
-
const model = args.includes("--model") ? args[args.indexOf("--model") + 1] : undefined;
|
|
249
|
-
const sessionId = args.includes("--session-id") ? args[args.indexOf("--session-id") + 1] : undefined;
|
|
250
|
-
const sessionFile = args.includes("--session-file") ? args[args.indexOf("--session-file") + 1] : undefined;
|
|
251
|
-
const replaceSession = args.includes("--replace-session");
|
|
252
|
-
if (replaceSession && (!sessionFile || sessionId)) fail("--replace-session requires --session-file, without --session-id");
|
|
253
|
-
const valueFlags = new Set(["--mode", "--backend", "--model", "--session-id", "--session-file"]);
|
|
254
|
-
for (let i = 0; i < args.length; i++) {
|
|
255
|
-
if (args[i] === "--replace-session") continue;
|
|
256
|
-
if (!valueFlags.has(args[i]!)) fail(`unknown Pi option: ${args[i]}`);
|
|
257
|
-
i++;
|
|
258
|
-
}
|
|
259
|
-
if ((mode !== undefined && !["headless", "tui"].includes(mode)) || (backend !== undefined && !["auto", "dgx", "mlx"].includes(backend)) || (sessionId && sessionFile)) fail(usage);
|
|
260
|
-
return { ...(replaceSession ? { replaceSession: true } : {}), ...(mode ? { mode: mode as "headless" | "tui" } : {}), ...(backend ? { backend: backend as "auto" | "dgx" | "mlx" } : {}), ...(model ? { model } : {}), ...(sessionId ? { sessionId } : {}), ...(sessionFile ? { sessionFile } : {}) };
|
|
261
|
-
}
|
|
268
|
+
const piFlags = () => parsePiFlags(args, fail);
|
|
262
269
|
|
|
263
270
|
async function projectRows() {
|
|
264
271
|
const registry = new Registry();
|
|
@@ -349,6 +356,12 @@ async function orphanDoctor(kill: boolean, options: ReturnType<typeof outputOpti
|
|
|
349
356
|
if (failed) fail(`${failed} orphaned hub process(es) could not be stopped`);
|
|
350
357
|
}
|
|
351
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
|
+
|
|
352
365
|
function fail(message: string): never {
|
|
353
366
|
console.error(`ahub: ${message}`);
|
|
354
367
|
process.exit(1);
|
|
@@ -577,7 +590,10 @@ async function upgrade(kind: "restart" | "upgrade"): Promise<void> {
|
|
|
577
590
|
|
|
578
591
|
/** Strict person command: recorded identity only, no session or terminal overrides. */
|
|
579
592
|
function parseRecoveryReattach(argv: readonly string[]): { id: string; peer: TerminalPeer } | undefined {
|
|
580
|
-
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.
|
|
581
597
|
return { id: argv[1]!, peer: argv[2] as TerminalPeer };
|
|
582
598
|
}
|
|
583
599
|
|
|
@@ -827,220 +843,29 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
827
843
|
if (process.stdin.isTTY && process.stdout.isTTY && !args.includes("--no-console")) await runConsole({ client: await connect(), cwd, stateDir, liveTest });
|
|
828
844
|
},
|
|
829
845
|
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
}
|
|
835
|
-
assertLifecycleAvailable();
|
|
836
|
-
const control = readControl(stateDir);
|
|
837
|
-
if (control?.instanceId) {
|
|
838
|
-
process.env.AGENTHUB_INSTANCE_ID = control.instanceId;
|
|
839
|
-
const terminal = await recordTerminalLaunch("claude", cwd, stateDir, control.instanceId);
|
|
840
|
-
if (!terminal) process.env.AGENTHUB_LAUNCH_ID = randomUUID();
|
|
841
|
-
}
|
|
842
|
-
// `--settings` outranks project and user settings, so the tee has to wrap whichever status line would have won:
|
|
843
|
-
// project local, then project, then user.
|
|
844
|
-
let original: { command?: string; refreshInterval?: number; padding?: number } | undefined;
|
|
845
|
-
for (const file of [join(cwd, ".claude", "settings.local.json"), join(cwd, ".claude", "settings.json"), join(process.env.HOME ?? "", ".claude", "settings.json")]) {
|
|
846
|
-
try {
|
|
847
|
-
original ??= JSON.parse(readFileSync(file, "utf8")).statusLine;
|
|
848
|
-
} catch {
|
|
849
|
-
// no such file, or no status line in it
|
|
850
|
-
}
|
|
851
|
-
}
|
|
852
|
-
// Turn-free facts and opted-in task sweeps share native PreToolUse/PostToolUse/Stop observations.
|
|
853
|
-
const facts = claudeObservationHooks(projectConfig(), { script: join(import.meta.dir, "facts-hook.ts"), stateDir });
|
|
854
|
-
let previousLaunch: unknown;
|
|
855
|
-
try { previousLaunch = JSON.parse(readFileSync(join(stateDir, "claude-launch.json"), "utf8")); } catch { /* no previous managed launch */ }
|
|
856
|
-
const launch = buildLaunch("claude", args, { unattended: unattendedEnv, statusLine: { script: join(import.meta.dir, "statusline-tee.ts"), stateDir, ...(original ? { original } : {}) }, ...(facts ? { facts } : {}) });
|
|
857
|
-
let result: Awaited<ReturnType<typeof runClaudeLaunch>>;
|
|
858
|
-
try {
|
|
859
|
-
let launchRecord: ClaudeLaunchRecord | undefined;
|
|
860
|
-
if (control?.instanceId) {
|
|
861
|
-
// Publish only the final launch's installed hook and effective permission flags.
|
|
862
|
-
launchRecord = { instanceId: control.instanceId, launchId: process.env.AGENTHUB_LAUNCH_ID!, permissionHook: launch.permissionHook === true, hookPurpose: launch.hookPurpose, settingsFile: launch.settingsFile, launcherPid: process.pid, launcherSignature: processSignature(process.pid), unattended: launch.unattended === true };
|
|
863
|
-
recordClaudeLaunch(stateDir, launchRecord);
|
|
864
|
-
cleanupStaleClaudeSettings(stateDir, previousLaunch);
|
|
865
|
-
}
|
|
866
|
-
if (launch.warning) console.error(launch.warning);
|
|
867
|
-
const env = nativeLaunchEnv("claude", childEnv());
|
|
868
|
-
result = await runClaudeLaunch(launch, { cwd, stateDir, env: { ...env, AGENTHUB_STATE_DIR: stateDir, AGENTHUB_PROJECT_DIR: cwd }, ...(launchRecord ? { record: launchRecord } : {}) });
|
|
869
|
-
} finally {
|
|
870
|
-
cleanupClaudeSettings(stateDir, launch.settingsFile);
|
|
871
|
-
}
|
|
872
|
-
if (result.error) fail(`cannot run ${launch.cmd}: ${result.error.message}`);
|
|
873
|
-
process.exit(result.status ?? 1);
|
|
874
|
-
},
|
|
875
|
-
|
|
876
|
-
codex: async () => {
|
|
877
|
-
if (args.includes("--print-command") || args.includes("--dry-run")) {
|
|
878
|
-
try { return console.log(JSON.stringify(launcherPreview("codex", args, cwd, stateDir, unattendedEnv), null, 2)); }
|
|
879
|
-
catch { fail("cannot preview launch: invalid arguments or configuration (details withheld to protect credentials)"); }
|
|
880
|
-
}
|
|
881
|
-
assertLifecycleAvailable();
|
|
882
|
-
const launch0 = buildLaunch("codex", args, { unattended: unattendedEnv, proxyUrl: "pending" }); // refuse bad flags before starting anything
|
|
883
|
-
// #215: a launch by a recovery operation records itself before the hub round trip, so the coordinator never takes
|
|
884
|
-
// a launcher still starting for one that never ran. An ordinary launch records only once the hub accepted it: a
|
|
885
|
-
// refused start must not replace the record of a Codex already running here (one row per peer and instance).
|
|
886
|
-
const recovering = !!process.env.AGENTHUB_RECOVERY_OPERATION && process.env.AGENTHUB_RECOVERY_OPERATION === recoveryLock(); // not an id a restored session inherited
|
|
887
|
-
const before = readControl(stateDir);
|
|
888
|
-
if (recovering && before?.instanceId) await recordTerminalLaunch("codex", cwd, stateDir, before.instanceId);
|
|
889
|
-
const hub = await connect();
|
|
890
|
-
const res = await hub.request({ t: "start", peer: "codex", args: { unattended: launch0.unattended === true }, operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
|
|
891
|
-
hub.close();
|
|
892
|
-
if (!res.ok) fail(res.error);
|
|
893
|
-
const control = readControl(stateDir);
|
|
894
|
-
if (!recovering && control?.instanceId) await recordTerminalLaunch("codex", cwd, stateDir, control.instanceId);
|
|
895
|
-
const launch = buildLaunch("codex", args, { unattended: unattendedEnv, proxyUrl: res.proxyUrl, codexBin: projectConfig().codex_bin });
|
|
896
|
-
if (launch0.warning) console.error(launch0.warning);
|
|
897
|
-
exec(launch.cmd, launch.args, "codex");
|
|
898
|
-
},
|
|
899
|
-
|
|
900
|
-
jcode: async () => {
|
|
901
|
-
if (args.includes("--print-command") || args.includes("--dry-run")) {
|
|
902
|
-
try { return console.log(JSON.stringify(launcherPreview("jcode", args, cwd, stateDir, unattendedEnv), null, 2)); }
|
|
903
|
-
catch { fail("cannot preview Jcode launch: invalid arguments or configuration (details withheld)"); }
|
|
904
|
-
}
|
|
905
|
-
assertLifecycleAvailable();
|
|
906
|
-
const { one, rest } = takeFlags(args, ["--mode", "--model", "--provider-profile", "--session-id"], []);
|
|
907
|
-
if (rest.length || (one["--mode"] !== undefined && one["--mode"] !== "headless")) fail("usage: ahub jcode [--mode headless] [--model <alias>] [--provider-profile <name>] [--session-id <id>]");
|
|
908
|
-
if (one["--session-id"] !== undefined && !qwenSessionId(one["--session-id"])) fail("invalid Jcode session identity");
|
|
909
|
-
const hub = await connect();
|
|
910
|
-
try {
|
|
911
|
-
const snapshot = (await hub.request({ t: "status" })).status;
|
|
912
|
-
if (snapshot?.roster?.find((row: any) => row.peer === "jcode")?.on !== true) fail("jcode is off roster; add it with ahub roster add jcode");
|
|
913
|
-
const written = configureJcodeMcp(cwd, stateDir, resolve(import.meta.dir, "../../plugins/agent-hub/server.js"));
|
|
914
|
-
if (written.tracked) console.error(".jcode/mcp.json is tracked; review this local machine-path change before committing it");
|
|
915
|
-
const reply = await hub.request({ t: "start", peer: "jcode", args: { mode: "headless", model: one["--model"], providerProfile: one["--provider-profile"], sessionId: one["--session-id"] }, operationId: process.env.AGENTHUB_RECOVERY_OPERATION }, 65_000);
|
|
916
|
-
if (!reply.ok) fail(reply.error);
|
|
917
|
-
console.log(reply.already ? "jcode is already attached" : "jcode attached (headless); native policy unmanaged, NO hub sandbox");
|
|
918
|
-
} finally { hub.close(); }
|
|
919
|
-
},
|
|
920
|
-
|
|
921
|
-
qwen: async () => {
|
|
922
|
-
if (args.includes("--print-command") || args.includes("--dry-run")) {
|
|
923
|
-
try { return console.log(JSON.stringify(launcherPreview("qwen", args, cwd, stateDir, unattendedEnv), null, 2)); }
|
|
924
|
-
catch { fail("cannot preview Qwen launch: invalid arguments or configuration (details withheld)"); }
|
|
925
|
-
}
|
|
926
|
-
assertLifecycleAvailable();
|
|
927
|
-
const { one, rest } = takeFlags(args, ["--mode", "--model", "--session-id"], []);
|
|
928
|
-
const adapter = definitionOf("qwen")!;
|
|
929
|
-
if (one["--session-id"] !== undefined && !adapter.session.crashIdentity?.({ sessionId: one["--session-id"] }).sessionId) fail("invalid Qwen session identity");
|
|
930
|
-
if (rest.length || (one["--mode"] !== undefined && !(adapter.start.forms as readonly string[]).includes(one["--mode"]))) fail("Qwen TUI is not offered before verification; usage: ahub qwen [--mode headless] [--model <alias>] [--session-id <id>]");
|
|
931
|
-
const hub = await connect();
|
|
932
|
-
try {
|
|
933
|
-
const reply = await hub.request({ t: "start", peer: "qwen", args: { mode: "headless", ...(one["--model"] ? { model: one["--model"] } : {}), ...(one["--session-id"] ? { sessionId: one["--session-id"] } : {}) }, operationId: process.env.AGENTHUB_RECOVERY_OPERATION }, 35_000);
|
|
934
|
-
if (!reply.ok) fail(reply.error);
|
|
935
|
-
console.log(reply.already ? "qwen is already attached" : "qwen attached (headless); use ahub say @qwen and the console for approvals");
|
|
936
|
-
} finally { hub.close(); }
|
|
937
|
-
},
|
|
938
|
-
kimi: async () => {
|
|
939
|
-
if (args.includes("--print-command") || args.includes("--dry-run")) {
|
|
940
|
-
try { return console.log(JSON.stringify(launcherPreview("kimi", args, cwd, stateDir, unattendedEnv), null, 2)); }
|
|
941
|
-
catch { fail("cannot preview launch: invalid arguments or configuration (details withheld to protect credentials)"); }
|
|
942
|
-
}
|
|
943
|
-
assertLifecycleAvailable();
|
|
944
|
-
const { one, rest } = takeFlags(args, ["--mode", "--model"], []);
|
|
945
|
-
const mode = one["--mode"] ?? startModeOf(projectConfig().peers, "kimi"), model = one["--model"];
|
|
946
|
-
if (rest.length || !["tui", "headless"].includes(mode)) fail("usage: ahub kimi [--mode tui|headless] [--model <alias>]");
|
|
947
|
-
if (mode === "tui" && one["--mode"] === undefined && !(process.stdin.isTTY && process.stdout.isTTY)) {
|
|
948
|
-
if (model) fail("Kimi's TUI needs a terminal for these options; run in a terminal or add --mode headless");
|
|
949
|
-
const hub = await connect();
|
|
950
|
-
try {
|
|
951
|
-
const reply = await hub.request({ t: "peer_start", peer: "kimi" }, 35_000);
|
|
952
|
-
if (!reply.ok) fail(reply.error);
|
|
953
|
-
return console.log(reply.already ? "kimi is already attached" : `kimi: opened a terminal running ${reply.command}; it attaches pull-only when its TUI is ready`);
|
|
954
|
-
} finally { hub.close(); }
|
|
955
|
-
}
|
|
956
|
-
const hub = await connect();
|
|
957
|
-
try {
|
|
958
|
-
if (mode === "tui") {
|
|
959
|
-
const launch = buildKimiTuiLaunch(projectConfig().kimi_cmd, model);
|
|
960
|
-
const snapshot = (await hub.request({ t: "status" })).status;
|
|
961
|
-
if (snapshot?.roster?.find((row: any) => row.peer === "kimi")?.on === false) fail("kimi is off roster; add it with ahub roster add kimi");
|
|
962
|
-
const peer = snapshot?.peers?.kimi;
|
|
963
|
-
if (peer && !["offline"].includes(peer.state) && !peer.pullOnly) fail("Kimi is running headless; run ahub stop kimi before starting its TUI");
|
|
964
|
-
const admission = await hub.request({ t: "start", peer: "kimi", args: { mode: "tui" }, operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
|
|
965
|
-
if (!admission.ok) fail(admission.error);
|
|
966
|
-
let result: ReturnType<typeof spawnSync>;
|
|
967
|
-
try {
|
|
968
|
-
const written = configureKimiMcp(cwd, stateDir, resolve(import.meta.dir, "../../plugins/agent-hub/server.js"), "tui");
|
|
969
|
-
if (written.tracked) console.error(".kimi-code/mcp.json is tracked; the agent-hub entry contains machine paths. Review this local change before committing it.");
|
|
970
|
-
result = spawnSync(launch.cmd, launch.args, { cwd, stdio: "inherit", env: { ...nativeLaunchEnv("kimi", childEnv()), AGENTHUB_STATE_DIR: stateDir, AGENTHUB_PROJECT_DIR: cwd } });
|
|
971
|
-
} finally {
|
|
972
|
-
if (admission.admissionId) await hub.request({ t: "kimi_launch_end", admissionId: admission.admissionId }).catch(() => {});
|
|
973
|
-
}
|
|
974
|
-
if (result.error) fail(`cannot run ${launch.cmd}: ${result.error.message}`);
|
|
975
|
-
process.exit(result.status ?? 1);
|
|
976
|
-
}
|
|
977
|
-
const peer = (await hub.request({ t: "status" })).status?.peers?.kimi;
|
|
978
|
-
if (peer?.pullOnly && peer.state !== "offline") fail("Kimi runs in a terminal; end it there before starting a headless one");
|
|
979
|
-
configureKimiMcp(cwd, stateDir, resolve(import.meta.dir, "../../plugins/agent-hub/server.js"), "headless");
|
|
980
|
-
const res = await hub.request({ t: "start", peer: "kimi", args: { mode: "headless", model }, operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
|
|
981
|
-
if (!res.ok) fail(res.error);
|
|
982
|
-
console.log(res.already ? "kimi is already attached" : 'kimi attached (headless). Talk to it with: ahub say @kimi "..."');
|
|
983
|
-
} finally { hub.close(); }
|
|
984
|
-
},
|
|
985
|
-
|
|
986
|
-
pi: async () => {
|
|
987
|
-
// --headless is --mode headless everywhere this command goes: the preview, the hub and the conductor path.
|
|
988
|
-
if (args.includes("--headless") && !args.includes("--mode")) args.splice(args.indexOf("--headless"), 1, "--mode", "headless");
|
|
989
|
-
if (args.includes("--print-command") || args.includes("--dry-run")) {
|
|
990
|
-
try { return console.log(JSON.stringify(launcherPreview("pi", args, cwd, stateDir, unattendedEnv), null, 2)); }
|
|
991
|
-
catch { fail("cannot preview launch: invalid arguments or configuration (details withheld to protect credentials)"); }
|
|
992
|
-
}
|
|
993
|
-
const flags = piFlags();
|
|
994
|
-
// #269: with no flag, Pi's start mode decides, and that is its TUI unless a person set headless.
|
|
995
|
-
const options = { ...flags, mode: flags.mode ?? startModeOf(projectConfig().peers, "pi") };
|
|
996
|
-
if (flags.mode === undefined && options.mode === "tui" && !(process.stdin.isTTY && process.stdout.isTTY)) {
|
|
997
|
-
// The default asked for the TUI and there is no terminal here (the console's command line, a script): the hub
|
|
998
|
-
// opens one, or says how to. It is never downgraded to headless. The fixed command carries no option, so one
|
|
999
|
-
// that was given needs a terminal of its own. An explicit --mode tui is taken at its word, as before.
|
|
1000
|
-
if (Object.keys(flags).length) fail("Pi's TUI needs a terminal, and these options are not passed to one the hub opens: run this in a terminal, or add --mode headless");
|
|
846
|
+
...Object.fromEntries(BUILTIN_CLI_ENTRIES.map(entry => [entry.command, async () => {
|
|
847
|
+
// Preserve the original guard before native effects; previews remain read-only and precede this guard.
|
|
848
|
+
if (entry.lifecycleAdmission && !args.includes("--print-command") && !args.includes("--dry-run")) assertLifecycleAvailable();
|
|
849
|
+
const nativeConnect = async () => {
|
|
1001
850
|
const hub = await connect();
|
|
1002
|
-
try {
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
if (options.mode === "tui") {
|
|
1021
|
-
const launch = res.launch;
|
|
1022
|
-
if (!launch || typeof launch.cmd !== "string" || !Array.isArray(launch.args) || !launch.args.every((a: unknown) => typeof a === "string") || !launch.env || typeof launch.env !== "object") fail("Pi TUI launch metadata was not verified");
|
|
1023
|
-
const launchEnv = { ...(launch.env as NodeJS.ProcessEnv) };
|
|
1024
|
-
delete launchEnv.AGENTHUB_RECOVERY_OPERATION;
|
|
1025
|
-
delete launchEnv.AGENTHUB_UNATTENDED;
|
|
1026
|
-
const control = readControl(stateDir);
|
|
1027
|
-
if (!recovering && control?.instanceId) await recordTerminalLaunch("pi", cwd, stateDir, control.instanceId);
|
|
1028
|
-
return execWithEnv(launch.cmd, launch.args, launchEnv);
|
|
1029
|
-
}
|
|
1030
|
-
console.log(res.already ? "pi is already attached" : 'pi attached (headless). Talk to it with: ahub say @pi "..."');
|
|
1031
|
-
},
|
|
1032
|
-
|
|
1033
|
-
local: async () => {
|
|
1034
|
-
const opt = (flag: string) => (args.includes(flag) ? args[args.indexOf(flag) + 1] ?? fail(`${flag} needs a value`) : undefined);
|
|
1035
|
-
for (const flag of ["--route", "--model"]) if (args.includes(flag) && (!opt(flag) || opt(flag)!.startsWith("--"))) fail(`${flag} needs a value`);
|
|
1036
|
-
if (args.includes("--route") && args.includes("--model")) fail("use --route or --model, not both");
|
|
1037
|
-
if (args.some((a, i) => i % 2 === 0 && !["--route", "--model"].includes(a))) fail("usage: ahub local [--route <id> | --model <id>]");
|
|
1038
|
-
const hub = await connect();
|
|
1039
|
-
const res = await hub.request({ t: "start", peer: "local", args: { route: opt("--route"), model: opt("--model") }, operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
|
|
1040
|
-
hub.close();
|
|
1041
|
-
if (!res.ok) fail(res.error);
|
|
1042
|
-
console.log(res.already ? "local is already attached" : `local attached on ${res.model}. Give it work with: ahub say @local "..."`);
|
|
1043
|
-
},
|
|
851
|
+
try { if (selectedInstance) requirePeerInstances(hub.features); 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 } : {}),
|
|
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,
|
|
863
|
+
buildLaunch, claudeObservationHooks, recordClaudeLaunch, cleanupStaleClaudeSettings, cleanupClaudeSettings,
|
|
864
|
+
nativeLaunchEnv, runClaudeLaunch, childEnv, qwenSessionId, configureJcodeMcp, configureKimiMcp,
|
|
865
|
+
buildKimiTuiLaunch, startModeOf },
|
|
866
|
+
};
|
|
867
|
+
return entry.execute(context);
|
|
868
|
+
}])),
|
|
1044
869
|
|
|
1045
870
|
models: async () => {
|
|
1046
871
|
const action = outputArguments()[0] ?? "status";
|
|
@@ -1125,7 +950,7 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1125
950
|
|
|
1126
951
|
watch: async () => {
|
|
1127
952
|
const peer = args[0];
|
|
1128
|
-
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]");
|
|
1129
954
|
if (args.slice(1).some(arg => arg !== "--no-thoughts" && !/^--color=(auto|always|never)$/.test(arg))) fail("unsupported watch option");
|
|
1130
955
|
const color = resolveColor(args.find(arg => arg.startsWith("--color="))?.slice(8), { isTTY: !!process.stdout.isTTY, ...process.env });
|
|
1131
956
|
if (color instanceof Error) fail(color.message);
|
|
@@ -1430,9 +1255,10 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1430
1255
|
roster: async () => {
|
|
1431
1256
|
const options = outputOptions(), flags = args.filter(arg => arg.startsWith("--")), positional = outputArguments().filter(arg => arg !== "--yes" && arg !== "--now");
|
|
1432
1257
|
const [sub = "list", peer, ...extra] = positional;
|
|
1433
|
-
if (!["list", "add", "remove"].includes(sub) || extra.length || (sub === "list" ? peer !== undefined : !
|
|
1258
|
+
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]");
|
|
1434
1259
|
const hub = await connect();
|
|
1435
1260
|
try {
|
|
1261
|
+
requireNamedPeerFeature(hub, peer);
|
|
1436
1262
|
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; };
|
|
1437
1263
|
if (sub === "list") {
|
|
1438
1264
|
const view = await request({ t: "settings_get" });
|
|
@@ -1793,7 +1619,22 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1793
1619
|
if (!turn.ended) fail(`turn ${id} is still running`);
|
|
1794
1620
|
const { start_tree: startTree, end_tree: endTree } = turn;
|
|
1795
1621
|
if (!startTree || !endTree) fail(`turn ${id} has no ${startTree ? "end" : "start"} snapshot: the hub stopped during it, or a snapshot failed (see hub.log)`);
|
|
1796
|
-
const
|
|
1622
|
+
const recordedWorkspace = turnRecords(t => t.workspace(id), undefined);
|
|
1623
|
+
const undoWorkspace = (): string => {
|
|
1624
|
+
// Old turns, including external peers and ids declared only later, belong to the canonical checkout.
|
|
1625
|
+
if (recordedWorkspace === undefined) return cwd;
|
|
1626
|
+
const declarations = readInstanceConfig(cwd).instances;
|
|
1627
|
+
if (!declarations || !Object.hasOwn(declarations, turn.peer)) fail(`turn ${id}'s recorded instance is no longer declared; nothing was restored`);
|
|
1628
|
+
const config = projectConfig();
|
|
1629
|
+
const current = resolveInstanceExecutionCwd(cwd, turn.peer, declarations, effectiveInstanceRoles(turn.peer, declarations, config.roles));
|
|
1630
|
+
if (realPath(current) !== realPath(recordedWorkspace)) fail(`turn ${id}'s recorded execution workspace changed; nothing was restored`);
|
|
1631
|
+
return current;
|
|
1632
|
+
};
|
|
1633
|
+
const undoCwd = undoWorkspace();
|
|
1634
|
+
const requireUndoWorkspace = () => {
|
|
1635
|
+
if (realPath(undoWorkspace()) !== realPath(undoCwd)) fail(`turn ${id}'s execution workspace changed; nothing was restored`);
|
|
1636
|
+
};
|
|
1637
|
+
const repo = repoOf(undoCwd) ?? fail(`${undoCwd} is not in a git work tree`);
|
|
1797
1638
|
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)`);
|
|
1798
1639
|
// Another peer's turn that overlapped this one in time has its changes in this turn's diff as well.
|
|
1799
1640
|
const undoConfig = projectConfig();
|
|
@@ -1815,11 +1656,12 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1815
1656
|
if (first.undone) return console.log(`turn ${id} is already undone: its files are as they were before it started`);
|
|
1816
1657
|
console.log(first.restore.length ? `turn ${id} changed:\n ${first.restore.join("\n ")}` : `turn ${id} changed no files`);
|
|
1817
1658
|
console.log("(every change made in the project during the turn counts as its own, including any by Claude or by you)");
|
|
1818
|
-
const projections = new TextProjections({ cwd, stateDir, config: undoConfig.text_projection ?? {}, deny: undoConfig.local.deny, readAllow: undoConfig.local.read_allow, log: line => console.error(line) });
|
|
1819
|
-
const preview = projectedDiff({ repo, from: endTree, to: startTree, paths: first.restore, projections, deny: undoConfig.local.deny, privatePath: path => matchesPolicy(
|
|
1659
|
+
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) });
|
|
1660
|
+
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 ?? []) });
|
|
1820
1661
|
if (preview) console.log(terminalText("Undo preview (end snapshot to start snapshot):\n" + preview));
|
|
1821
1662
|
|
|
1822
1663
|
if (!args.includes("--yes")) return console.log("nothing was changed; add --yes to restore these files");
|
|
1664
|
+
requireUndoWorkspace();
|
|
1823
1665
|
if (args.includes("--context")) {
|
|
1824
1666
|
console.log(await taskOp("turn_revert", { turn: id }));
|
|
1825
1667
|
reverted = true;
|
|
@@ -1827,6 +1669,7 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1827
1669
|
// A peer may have written meanwhile (the revert alone can take seconds): plan again right before restoring.
|
|
1828
1670
|
const second = plan();
|
|
1829
1671
|
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)" : ""}`);
|
|
1672
|
+
requireUndoWorkspace();
|
|
1830
1673
|
restore(repo, startTree, second.restore);
|
|
1831
1674
|
if (second.restore.length) console.log(`restored to their state before ${id}`);
|
|
1832
1675
|
},
|
|
@@ -1934,9 +1777,10 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1934
1777
|
}
|
|
1935
1778
|
if (["assign", "clear"].includes(args[0] ?? "")) {
|
|
1936
1779
|
const [op, peer, id] = args;
|
|
1937
|
-
if (!peer || !
|
|
1780
|
+
if (!peer || !peerDefinition(peer) || !id && op === "assign" || args.length > 3) fail("usage: ahub role assign <peer> <id> | clear <peer> [<id>]");
|
|
1938
1781
|
const hub = await connect();
|
|
1939
1782
|
try {
|
|
1783
|
+
requireNamedPeerFeature(hub, peer);
|
|
1940
1784
|
const view = await hub.request({ t: "settings_get" }); if (!view.ok) fail(view.error);
|
|
1941
1785
|
const row = (view.rows as SettingRow[]).find(row => row.key === `catalog_roles.${peer}`); if (!row) fail("this hub does not support catalog role settings");
|
|
1942
1786
|
const current = Array.isArray(row!.pending) ? row!.pending : Array.isArray(row!.value) ? row!.value : [];
|
|
@@ -1955,15 +1799,7 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1955
1799
|
return res.status === 0 ? res.stdout.trim().split("\n")[0]! : undefined;
|
|
1956
1800
|
};
|
|
1957
1801
|
const checks: DoctorCheck[] = [];
|
|
1958
|
-
const row = (ok: boolean | undefined, name: string, detail: string) => {
|
|
1959
|
-
const section: DoctorCheck["section"] = ["bun", "claude", "codex", "kimi", "qwen", "jcode"].includes(name) ? "Tools"
|
|
1960
|
-
: name === "config" || name === "retired setting" ? "Config"
|
|
1961
|
-
: name.startsWith("memory") ? "Memory"
|
|
1962
|
-
: ["omniroute", "omniroute key", "local fixed_model", "switchyard", "pi mlx"].includes(name) ? "Models" : "Hub";
|
|
1963
|
-
const optional = ["claude", "codex", "kimi", "qwen", "jcode", "ahub daemon", "switchyard", "pi mlx"].includes(name) || section === "Memory";
|
|
1964
|
-
const level: DoctorCheck["level"] = ok === undefined ? (["switchyard", "omniroute"].includes(name) ? "warn" : "unknown") : ok ? "ok" : optional ? "warn" : "fail";
|
|
1965
|
-
checks.push({ section, level, name, detail });
|
|
1966
|
-
};
|
|
1802
|
+
const row = (ok: boolean | undefined, name: string, detail: string) => { checks.push(nativeDoctorCheck(ok, name, detail)); };
|
|
1967
1803
|
|
|
1968
1804
|
try {
|
|
1969
1805
|
const catalog = loadRoleCatalog(cwd);
|
|
@@ -1972,7 +1808,7 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1972
1808
|
const config = loadConfig(cwd), assigned = catalogAssignments(config.catalog_roles);
|
|
1973
1809
|
for (const peer of Object.keys(assigned)) selectedRoles(peer, config.roles, assigned, catalog);
|
|
1974
1810
|
} catch { row(false, "catalog roles", "invalid assignment id, shape or flow base"); }
|
|
1975
|
-
for (const bin of
|
|
1811
|
+
for (const bin of DOCTOR_TOOL_PROBES) {
|
|
1976
1812
|
const v = version(bin);
|
|
1977
1813
|
row(!!v, bin, v ?? "not found on PATH");
|
|
1978
1814
|
}
|
|
@@ -1986,26 +1822,24 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1986
1822
|
row(!status.deliveryError && pending === 0, "delivery recovery", status.deliveryError ?? (pending ? `${pending} deliveries need review; run ahub queue list` : "journal healthy; no deliveries need review"));
|
|
1987
1823
|
const observed = await hub.request({ t: "recovery", op: "inspect", expectedInstanceId: status.instanceId });
|
|
1988
1824
|
for (const peer of Object.values(observed.recovery?.peers ?? {}) as { id: string; state: string; sessionId?: string; threadId?: string }[]) {
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
row(!!identity, `${peer.id} recovery ID`, identity ? "recorded" : `missing; verify the current conversation and reconnect with ahub ${peer.id} before upgrading`);
|
|
1825
|
+
const check = nativeDoctorRecoveryIdentity(peer);
|
|
1826
|
+
if (check) row(check.ok, check.name, check.detail);
|
|
1992
1827
|
}
|
|
1993
1828
|
} finally { hub.close(); }
|
|
1994
1829
|
}
|
|
1995
|
-
const plugin = pluginState(parseList<InstalledPlugin>(spawnSync(
|
|
1996
|
-
row(plugin.state === "current",
|
|
1830
|
+
const plugin = pluginState(parseList<InstalledPlugin>(spawnSync(DOCTOR_PLUGIN_PROBE.command, [...DOCTOR_PLUGIN_PROBE.argv], { encoding: "utf8" }).stdout ?? ""), join(import.meta.dir, "..", ".."));
|
|
1831
|
+
row(plugin.state === "current", DOCTOR_PLUGIN_PROBE.name, plugin.state === "missing" ? "missing: run ahub setup" : plugin.state === "current" ? `agent-hub@agent-hub ${plugin.version}` : `agent-hub@agent-hub is stale (${plugin.why}): run ahub setup`);
|
|
1997
1832
|
|
|
1998
1833
|
const config = loadConfig(cwd);
|
|
1999
1834
|
row(!config.ignored, "config", config.ignored ? `${config.ignored.join("; ")} (move them to .agenthub/config.local.json)` : "no machine-local field ignored");
|
|
2000
1835
|
for (const line of config.retired ?? []) row(false, "retired setting", line);
|
|
2001
|
-
|
|
2002
|
-
row(verifiedJcodeCommand(
|
|
1836
|
+
for (const probe of nativeDoctorOptionalProbes(config)) {
|
|
1837
|
+
if (probe.kind === "source-identity") row(verifiedJcodeCommand(probe.command, cwd), probe.name, probe.detail);
|
|
1838
|
+
else {
|
|
1839
|
+
const v = version(probe.command[0]!);
|
|
1840
|
+
row(v === probe.expected, probe.name, v === probe.expected ? probe.confirmed : probe.unconfirmed);
|
|
1841
|
+
}
|
|
2003
1842
|
}
|
|
2004
|
-
if (config.roster.qwen === true) {
|
|
2005
|
-
// ponytail: keep this bounded CLI-version probe and 0.25.0 pin in the A1 guard baseline; A2 moves every version probe together when VersionContext gains its observed input.
|
|
2006
|
-
const v = version(config.qwen_cmd[0]!);
|
|
2007
|
-
row(v === "0.25.0", "qwen", v === "0.25.0" ? "CLI reports 0.25.0; native ACP identity/modes and provider access require a separate live check" : "Qwen Code 0.25.0 executable not confirmed; provider and ACP readiness unknown");
|
|
2008
|
-
} // issue #83
|
|
2009
1843
|
const omni = new OmniRoute(config.omniroute);
|
|
2010
1844
|
const gateway = await omni.base();
|
|
2011
1845
|
row(gateway ? true : config.omniroute.urls.length || process.env.AGENTHUB_OMNIROUTE_URL ? false : undefined, "omniroute", gateway ? `${new URL(gateway).host} healthy` : config.omniroute.urls.length || process.env.AGENTHUB_OMNIROUTE_URL ? "no candidate reachable (VPN off?); ahub local cannot run" : "not configured: set omniroute.urls in .agenthub/config.local.json (any OpenAI-compatible gateway); ahub local cannot run");
|
|
@@ -2031,7 +1865,7 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
2031
1865
|
row(mem.ok, "memory worker", mem.ok ? `claude-mem ${mem.version ?? ""} at ${memory.url}` : `unavailable at ${memory.url} (the hub works without it)`);
|
|
2032
1866
|
const bridge = spawnSync("dot", ["ai", "memory", "status"], { encoding: "utf8" });
|
|
2033
1867
|
const out = bridge.error ? "" : bridge.stdout.replace(/\x1b\[[0-9;]*m/g, "");
|
|
2034
|
-
for (const tool of
|
|
1868
|
+
for (const tool of DOCTOR_MEMORY_CAPTURE_PEERS) {
|
|
2035
1869
|
const ready = new RegExp(`✓\\s+${tool} ready`).test(out);
|
|
2036
1870
|
row(bridge.error ? undefined : ready, `memory capture: ${tool}`, bridge.error ? "unknown (`dot ai memory status` not available)" : `${ready ? "ready" : "not ready"}, per ` + "`dot ai memory status`");
|
|
2037
1871
|
}
|
|
@@ -2058,12 +1892,14 @@ async function runConductorCommand(): Promise<void> {
|
|
|
2058
1892
|
input = { peer: args[0] };
|
|
2059
1893
|
} else {
|
|
2060
1894
|
// Native TUIs return a human launch command through the daemon; an agent shell never executes it.
|
|
2061
|
-
|
|
2062
|
-
|
|
1895
|
+
const parseStart = definitionOf(cmd)?.start.conductorStartArgs;
|
|
1896
|
+
const parsed = parseStart ? parseStart(args) : args.length ? new Error(`conductor starts accept no launch overrides; use ahub ${cmd}`) : {};
|
|
1897
|
+
if (parsed instanceof Error) fail(parsed.message);
|
|
2063
1898
|
op = "hub_peer_start";
|
|
2064
1899
|
// #269: a mode may only repeat the peer's start mode, which a person sets; without one the hub takes that mode.
|
|
2065
|
-
input = { peer: cmd, ...
|
|
1900
|
+
input = { peer: selectedInstance ?? cmd, ...parsed };
|
|
2066
1901
|
}
|
|
1902
|
+
if (selectedInstance) { const hub = await connect(); try { requirePeerInstances(hub.features); } finally { hub.close(); } }
|
|
2067
1903
|
console.log(await taskOp(op, input));
|
|
2068
1904
|
}
|
|
2069
1905
|
const run = identity.role === "tools" && commandAccess === "conductor" ? runConductorCommand : (Object.hasOwn(commands, cmd) ? commands[cmd] : undefined) ?? (() => fail(`unknown command "${cmd}"; run ahub help`));
|