@staix/agent-hub 0.12.33 → 0.12.34
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 +9 -0
- package/README.md +3 -3
- package/docs/agent-notes/adapters.md +7 -3
- package/docs/agent-notes/budget.md +5 -3
- package/docs/agent-notes/daemon.md +3 -3
- package/docs/agent-notes/tasks.md +2 -1
- package/docs/agent-notes/tests.md +2 -1
- package/docs/operations.md +20 -10
- package/docs/quickstart.md +4 -2
- package/docs/security.md +4 -1
- package/docs/smoke.md +1 -1
- package/docs/specs/2026-09-19-agent-hub-design.md +246 -4
- package/docs/verification/2026-10-18-0.12.34.md +48 -0
- package/docs/verified.json +29 -18
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +22882 -16496
- package/src/adapters/codex-appserver.ts +81 -11
- 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 +26 -0
- package/src/adapters/definitions/native-cli-entry-jcode.ts +24 -0
- package/src/adapters/definitions/native-cli-entry-kimi.ts +52 -0
- package/src/adapters/definitions/native-cli-entry-local.ts +14 -0
- package/src/adapters/definitions/native-cli-entry-pi.ts +51 -0
- package/src/adapters/definitions/native-cli-entry-qwen.ts +20 -0
- package/src/adapters/definitions/native-cli-entry-types.ts +40 -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.ts +8 -4
- package/src/adapters/registry-types.ts +97 -10
- package/src/adapters/registry.ts +103 -3
- package/src/cli/console-state.ts +5 -5
- package/src/cli/identity.ts +6 -5
- package/src/cli/jcode-mcp.ts +2 -58
- package/src/cli/launch.ts +14 -100
- package/src/cli/main.ts +35 -272
- package/src/cli/output.ts +3 -2
- package/src/cli/preview.ts +14 -125
- package/src/cli/recovery-guidance.ts +17 -13
- package/src/cli/setup.ts +2 -80
- package/src/cli/shell-quote.ts +3 -0
- package/src/cli/terminal-recovery.ts +17 -10
- package/src/cli/upgrade-interactive.ts +4 -2
- package/src/cli/upgrade-restore-state.ts +2 -1
- package/src/cli/upgrade-runtime.ts +92 -141
- package/src/cli/upgrade.ts +8 -7
- package/src/hub/budget.ts +6 -30
- package/src/hub/conductor.ts +8 -4
- package/src/hub/config-apply.ts +9 -13
- package/src/hub/config-reload.ts +7 -2
- package/src/hub/context-window.ts +3 -27
- package/src/hub/crash.ts +2 -35
- package/src/hub/daemon.ts +226 -232
- package/src/hub/execution-budget.ts +2 -1
- package/src/hub/hub-tools.ts +3 -3
- package/src/hub/peer-catalog.ts +4 -13
- package/src/hub/permission-mode.ts +9 -12
- package/src/hub/recovery-continuity.ts +6 -5
- package/src/hub/report.ts +9 -8
- package/src/hub/roster.ts +6 -7
- package/src/hub/routing.ts +21 -10
- package/src/hub/settings.ts +6 -5
- package/src/hub/settled-roles.ts +1 -1
- package/src/hub/start-mode.ts +8 -6
- package/src/hub/tasks.ts +13 -10
- package/src/hub/tooling.ts +4 -60
- package/src/hub/ui.ts +3 -1
- package/src/memory/recall.ts +4 -5
- package/src/models/route/signals.ts +3 -1
- package/src/pi/launch.ts +2 -1
- package/src/ui/index.html +3 -2
- package/src/ui/office-model.ts +3 -1
- package/src/ui/peer-palette.ts +16 -6
- package/templates/routing.toml +2 -2
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import type { ClaudeLaunchRecord } from "../../cli/launch.ts";
|
|
5
|
+
import type { NativeCliEntryContext } from "./native-cli-entry-types.ts";
|
|
6
|
+
|
|
7
|
+
export async function runClaudeEntry(ctx: NativeCliEntryContext): Promise<void> {
|
|
8
|
+
const { args, cwd, stateDir, cliDir, unattendedEnv, projectConfig, fail } = ctx;
|
|
9
|
+
const { launcherPreview, readControl, recordTerminalLaunch, claudeObservationHooks, buildLaunch, runClaudeLaunch, processSignature, recordClaudeLaunch, cleanupStaleClaudeSettings, nativeLaunchEnv, childEnv, cleanupClaudeSettings } = ctx.helpers;
|
|
10
|
+
if (args.includes("--print-command") || args.includes("--dry-run")) {
|
|
11
|
+
try { return console.log(JSON.stringify(launcherPreview("claude", args, cwd, stateDir, unattendedEnv), null, 2)); }
|
|
12
|
+
catch { fail("cannot preview launch: invalid arguments or configuration (details withheld to protect credentials)"); }
|
|
13
|
+
}
|
|
14
|
+
const control = readControl(stateDir);
|
|
15
|
+
if (control?.instanceId) {
|
|
16
|
+
process.env.AGENTHUB_INSTANCE_ID = control.instanceId;
|
|
17
|
+
const terminal = await recordTerminalLaunch("claude", cwd, stateDir, control.instanceId);
|
|
18
|
+
if (!terminal) process.env.AGENTHUB_LAUNCH_ID = randomUUID();
|
|
19
|
+
}
|
|
20
|
+
// `--settings` outranks project and user settings, so the tee has to wrap whichever status line would have won:
|
|
21
|
+
// project local, then project, then user.
|
|
22
|
+
let original: { command?: string; refreshInterval?: number; padding?: number } | undefined;
|
|
23
|
+
for (const file of [join(cwd, ".claude", "settings.local.json"), join(cwd, ".claude", "settings.json"), join(process.env.HOME ?? "", ".claude", "settings.json")]) {
|
|
24
|
+
try {
|
|
25
|
+
original ??= JSON.parse(readFileSync(file, "utf8")).statusLine;
|
|
26
|
+
} catch {
|
|
27
|
+
// no such file, or no status line in it
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
// Turn-free facts and opted-in task sweeps share native PreToolUse/PostToolUse/Stop observations.
|
|
31
|
+
const facts = claudeObservationHooks(projectConfig(), { script: join(cliDir, "facts-hook.ts"), stateDir });
|
|
32
|
+
let previousLaunch: unknown;
|
|
33
|
+
try { previousLaunch = JSON.parse(readFileSync(join(stateDir, "claude-launch.json"), "utf8")); } catch { /* no previous managed launch */ }
|
|
34
|
+
const launch = buildLaunch("claude", args, { unattended: unattendedEnv, statusLine: { script: join(cliDir, "statusline-tee.ts"), stateDir, ...(original ? { original } : {}) }, ...(facts ? { facts } : {}) });
|
|
35
|
+
let result: Awaited<ReturnType<typeof runClaudeLaunch>>;
|
|
36
|
+
try {
|
|
37
|
+
let launchRecord: ClaudeLaunchRecord | undefined;
|
|
38
|
+
if (control?.instanceId) {
|
|
39
|
+
// Publish only the final launch's installed hook and effective permission flags.
|
|
40
|
+
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 };
|
|
41
|
+
recordClaudeLaunch(stateDir, launchRecord);
|
|
42
|
+
cleanupStaleClaudeSettings(stateDir, previousLaunch);
|
|
43
|
+
}
|
|
44
|
+
if (launch.warning) console.error(launch.warning);
|
|
45
|
+
const env = nativeLaunchEnv("claude", childEnv());
|
|
46
|
+
result = await runClaudeLaunch(launch, { cwd, stateDir, env: { ...env, AGENTHUB_STATE_DIR: stateDir, AGENTHUB_PROJECT_DIR: cwd }, ...(launchRecord ? { record: launchRecord } : {}) });
|
|
47
|
+
} finally {
|
|
48
|
+
cleanupClaudeSettings(stateDir, launch.settingsFile);
|
|
49
|
+
}
|
|
50
|
+
if (result.error) fail(`cannot run ${launch.cmd}: ${result.error.message}`);
|
|
51
|
+
process.exit(result.status ?? 1);
|
|
52
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { NativeCliEntryContext } from "./native-cli-entry-types.ts";
|
|
2
|
+
|
|
3
|
+
export async function runCodexEntry(ctx: NativeCliEntryContext): Promise<void> {
|
|
4
|
+
const { args, cwd, stateDir, unattendedEnv, projectConfig, connect, fail, exec } = ctx;
|
|
5
|
+
const { launcherPreview, buildLaunch, recoveryLock, readControl, recordTerminalLaunch } = ctx.helpers;
|
|
6
|
+
if (args.includes("--print-command") || args.includes("--dry-run")) {
|
|
7
|
+
try { return console.log(JSON.stringify(launcherPreview("codex", args, cwd, stateDir, unattendedEnv), null, 2)); }
|
|
8
|
+
catch { fail("cannot preview launch: invalid arguments or configuration (details withheld to protect credentials)"); }
|
|
9
|
+
}
|
|
10
|
+
const launch0 = buildLaunch("codex", args, { unattended: unattendedEnv, proxyUrl: "pending" }); // refuse bad flags before starting anything
|
|
11
|
+
// #215: a launch by a recovery operation records itself before the hub round trip, so the coordinator never takes
|
|
12
|
+
// a launcher still starting for one that never ran. An ordinary launch records only once the hub accepted it: a
|
|
13
|
+
// refused start must not replace the record of a Codex already running here (one row per peer and instance).
|
|
14
|
+
const recovering = !!process.env.AGENTHUB_RECOVERY_OPERATION && process.env.AGENTHUB_RECOVERY_OPERATION === recoveryLock(); // not an id a restored session inherited
|
|
15
|
+
const before = readControl(stateDir);
|
|
16
|
+
if (recovering && before?.instanceId) await recordTerminalLaunch("codex", cwd, stateDir, before.instanceId);
|
|
17
|
+
const hub = await connect();
|
|
18
|
+
const res = await hub.request({ t: "start", peer: "codex", args: { unattended: launch0.unattended === true }, operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
|
|
19
|
+
hub.close();
|
|
20
|
+
if (!res.ok) fail(res.error);
|
|
21
|
+
const control = readControl(stateDir);
|
|
22
|
+
if (!recovering && control?.instanceId) await recordTerminalLaunch("codex", cwd, stateDir, control.instanceId);
|
|
23
|
+
const launch = buildLaunch("codex", args, { unattended: unattendedEnv, proxyUrl: res.proxyUrl, codexBin: projectConfig().codex_bin });
|
|
24
|
+
if (launch0.warning) console.error(launch0.warning);
|
|
25
|
+
exec(launch.cmd, launch.args, "codex");
|
|
26
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import type { NativeCliEntryContext } from "./native-cli-entry-types.ts";
|
|
3
|
+
|
|
4
|
+
export async function runJcodeEntry(ctx: NativeCliEntryContext): Promise<void> {
|
|
5
|
+
const { args, cwd, stateDir, cliDir, unattendedEnv, connect, fail, takeFlags } = ctx;
|
|
6
|
+
const { launcherPreview, qwenSessionId, configureJcodeMcp } = ctx.helpers;
|
|
7
|
+
if (args.includes("--print-command") || args.includes("--dry-run")) {
|
|
8
|
+
try { return console.log(JSON.stringify(launcherPreview("jcode", args, cwd, stateDir, unattendedEnv), null, 2)); }
|
|
9
|
+
catch { fail("cannot preview Jcode launch: invalid arguments or configuration (details withheld)"); }
|
|
10
|
+
}
|
|
11
|
+
const { one, rest } = takeFlags(args, ["--mode", "--model", "--provider-profile", "--session-id"], []);
|
|
12
|
+
if (rest.length || (one["--mode"] !== undefined && one["--mode"] !== "headless")) fail("usage: ahub jcode [--mode headless] [--model <alias>] [--provider-profile <name>] [--session-id <id>]");
|
|
13
|
+
if (one["--session-id"] !== undefined && !qwenSessionId(one["--session-id"])) fail("invalid Jcode session identity");
|
|
14
|
+
const hub = await connect();
|
|
15
|
+
try {
|
|
16
|
+
const snapshot = (await hub.request({ t: "status" })).status;
|
|
17
|
+
if (snapshot?.roster?.find((row: any) => row.peer === "jcode")?.on !== true) fail("jcode is off roster; add it with ahub roster add jcode");
|
|
18
|
+
const written = configureJcodeMcp(cwd, stateDir, resolve(cliDir, "../../plugins/agent-hub/server.js"));
|
|
19
|
+
if (written.tracked) console.error(".jcode/mcp.json is tracked; review this local machine-path change before committing it");
|
|
20
|
+
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);
|
|
21
|
+
if (!reply.ok) fail(reply.error);
|
|
22
|
+
console.log(reply.already ? "jcode is already attached" : "jcode attached (headless); native policy unmanaged, NO hub sandbox");
|
|
23
|
+
} finally { hub.close(); }
|
|
24
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import type { NativeCliEntryContext } from "./native-cli-entry-types.ts";
|
|
4
|
+
|
|
5
|
+
export async function runKimiEntry(ctx: NativeCliEntryContext): Promise<void> {
|
|
6
|
+
const { args, cwd, stateDir, cliDir, unattendedEnv, projectConfig, connect, fail, takeFlags } = ctx;
|
|
7
|
+
const { launcherPreview, startModeOf, buildKimiTuiLaunch, configureKimiMcp, nativeLaunchEnv, childEnv } = ctx.helpers;
|
|
8
|
+
if (args.includes("--print-command") || args.includes("--dry-run")) {
|
|
9
|
+
try { return console.log(JSON.stringify(launcherPreview("kimi", args, cwd, stateDir, unattendedEnv), null, 2)); }
|
|
10
|
+
catch { fail("cannot preview launch: invalid arguments or configuration (details withheld to protect credentials)"); }
|
|
11
|
+
}
|
|
12
|
+
const { one, rest } = takeFlags(args, ["--mode", "--model"], []);
|
|
13
|
+
const mode = one["--mode"] ?? startModeOf(projectConfig().peers, "kimi"), model = one["--model"];
|
|
14
|
+
if (rest.length || !["tui", "headless"].includes(mode)) fail("usage: ahub kimi [--mode tui|headless] [--model <alias>]");
|
|
15
|
+
if (mode === "tui" && one["--mode"] === undefined && !(process.stdin.isTTY && process.stdout.isTTY)) {
|
|
16
|
+
if (model) fail("Kimi's TUI needs a terminal for these options; run in a terminal or add --mode headless");
|
|
17
|
+
const hub = await connect();
|
|
18
|
+
try {
|
|
19
|
+
const reply = await hub.request({ t: "peer_start", peer: "kimi" }, 35_000);
|
|
20
|
+
if (!reply.ok) fail(reply.error);
|
|
21
|
+
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`);
|
|
22
|
+
} finally { hub.close(); }
|
|
23
|
+
}
|
|
24
|
+
const hub = await connect();
|
|
25
|
+
try {
|
|
26
|
+
if (mode === "tui") {
|
|
27
|
+
const launch = buildKimiTuiLaunch(projectConfig().kimi_cmd, model);
|
|
28
|
+
const snapshot = (await hub.request({ t: "status" })).status;
|
|
29
|
+
if (snapshot?.roster?.find((row: any) => row.peer === "kimi")?.on === false) fail("kimi is off roster; add it with ahub roster add kimi");
|
|
30
|
+
const peer = snapshot?.peers?.kimi;
|
|
31
|
+
if (peer && !["offline"].includes(peer.state) && !peer.pullOnly) fail("Kimi is running headless; run ahub stop kimi before starting its TUI");
|
|
32
|
+
const admission = await hub.request({ t: "start", peer: "kimi", args: { mode: "tui" }, operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
|
|
33
|
+
if (!admission.ok) fail(admission.error);
|
|
34
|
+
let result: ReturnType<typeof spawnSync>;
|
|
35
|
+
try {
|
|
36
|
+
const written = configureKimiMcp(cwd, stateDir, resolve(cliDir, "../../plugins/agent-hub/server.js"), "tui");
|
|
37
|
+
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.");
|
|
38
|
+
result = spawnSync(launch.cmd, launch.args, { cwd, stdio: "inherit", env: { ...nativeLaunchEnv("kimi", childEnv()), AGENTHUB_STATE_DIR: stateDir, AGENTHUB_PROJECT_DIR: cwd } });
|
|
39
|
+
} finally {
|
|
40
|
+
if (admission.admissionId) await hub.request({ t: "kimi_launch_end", admissionId: admission.admissionId }).catch(() => {});
|
|
41
|
+
}
|
|
42
|
+
if (result.error) fail(`cannot run ${launch.cmd}: ${result.error.message}`);
|
|
43
|
+
process.exit(result.status ?? 1);
|
|
44
|
+
}
|
|
45
|
+
const peer = (await hub.request({ t: "status" })).status?.peers?.kimi;
|
|
46
|
+
if (peer?.pullOnly && peer.state !== "offline") fail("Kimi runs in a terminal; end it there before starting a headless one");
|
|
47
|
+
configureKimiMcp(cwd, stateDir, resolve(cliDir, "../../plugins/agent-hub/server.js"), "headless");
|
|
48
|
+
const res = await hub.request({ t: "start", peer: "kimi", args: { mode: "headless", model }, operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
|
|
49
|
+
if (!res.ok) fail(res.error);
|
|
50
|
+
console.log(res.already ? "kimi is already attached" : 'kimi attached (headless). Talk to it with: ahub say @kimi "..."');
|
|
51
|
+
} finally { hub.close(); }
|
|
52
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { NativeCliEntryContext } from "./native-cli-entry-types.ts";
|
|
2
|
+
|
|
3
|
+
export async function runLocalEntry(ctx: NativeCliEntryContext): Promise<void> {
|
|
4
|
+
const { args, connect, fail } = ctx;
|
|
5
|
+
const opt = (flag: string) => (args.includes(flag) ? args[args.indexOf(flag) + 1] ?? fail(`${flag} needs a value`) : undefined);
|
|
6
|
+
for (const flag of ["--route", "--model"]) if (args.includes(flag) && (!opt(flag) || opt(flag)!.startsWith("--"))) fail(`${flag} needs a value`);
|
|
7
|
+
if (args.includes("--route") && args.includes("--model")) fail("use --route or --model, not both");
|
|
8
|
+
if (args.some((a, i) => i % 2 === 0 && !["--route", "--model"].includes(a))) fail("usage: ahub local [--route <id> | --model <id>]");
|
|
9
|
+
const hub = await connect();
|
|
10
|
+
const res = await hub.request({ t: "start", peer: "local", args: { route: opt("--route"), model: opt("--model") }, operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
|
|
11
|
+
hub.close();
|
|
12
|
+
if (!res.ok) fail(res.error);
|
|
13
|
+
console.log(res.already ? "local is already attached" : `local attached on ${res.model}. Give it work with: ahub say @local "..."`);
|
|
14
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { parsePiFlags } from "./native-cli-pi.ts";
|
|
2
|
+
import type { NativeCliEntryContext } from "./native-cli-entry-types.ts";
|
|
3
|
+
|
|
4
|
+
export async function runPiEntry(ctx: NativeCliEntryContext): Promise<void> {
|
|
5
|
+
const { args, cwd, stateDir, unattendedEnv, projectConfig, connect, fail, execWithEnv } = ctx;
|
|
6
|
+
const { launcherPreview, startModeOf, recoveryLock, readControl, recordTerminalLaunch } = ctx.helpers;
|
|
7
|
+
// --headless is --mode headless everywhere this command goes: the preview, the hub and the conductor path.
|
|
8
|
+
if (args.includes("--headless") && !args.includes("--mode")) args.splice(args.indexOf("--headless"), 1, "--mode", "headless");
|
|
9
|
+
if (args.includes("--print-command") || args.includes("--dry-run")) {
|
|
10
|
+
try { return console.log(JSON.stringify(launcherPreview("pi", args, cwd, stateDir, unattendedEnv), null, 2)); }
|
|
11
|
+
catch { fail("cannot preview launch: invalid arguments or configuration (details withheld to protect credentials)"); }
|
|
12
|
+
}
|
|
13
|
+
const flags = parsePiFlags(args, fail);
|
|
14
|
+
// #269: with no flag, Pi's start mode decides, and that is its TUI unless a person set headless.
|
|
15
|
+
const options = { ...flags, mode: flags.mode ?? startModeOf(projectConfig().peers, "pi") };
|
|
16
|
+
if (flags.mode === undefined && options.mode === "tui" && !(process.stdin.isTTY && process.stdout.isTTY)) {
|
|
17
|
+
// The default asked for the TUI and there is no terminal here (the console's command line, a script): the hub
|
|
18
|
+
// opens one, or says how to. It is never downgraded to headless. The fixed command carries no option, so one
|
|
19
|
+
// that was given needs a terminal of its own. An explicit --mode tui is taken at its word, as before.
|
|
20
|
+
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");
|
|
21
|
+
const hub = await connect();
|
|
22
|
+
try {
|
|
23
|
+
const reply = await hub.request({ t: "peer_start", peer: "pi" }, 35_000);
|
|
24
|
+
if (reply.ok !== true) {
|
|
25
|
+
const error = String(reply.error ?? "pi start refused");
|
|
26
|
+
fail(/unknown (?:control )?(?:message|request|command)(?:\b|:)|this hub does not know "peer_start"/i.test(error) ? `${error}; upgrade the running hub, or run ahub pi --mode headless` : error);
|
|
27
|
+
}
|
|
28
|
+
return console.log(reply.already ? "pi is already attached" : reply.opened ? `pi: opened a terminal running ${reply.command}; it attaches when its TUI is ready` : 'pi attached (headless). Talk to it with: ahub say @pi "..."');
|
|
29
|
+
} finally { hub.close(); }
|
|
30
|
+
}
|
|
31
|
+
// #215: as for Codex, a TUI launch by the recovery operation holding the lock records itself before the hub round
|
|
32
|
+
// trip, so a resume in that window never reads it as gone; an ordinary launch records only after the hub accepted it.
|
|
33
|
+
const recovering = options.mode === "tui" && !!process.env.AGENTHUB_RECOVERY_OPERATION && process.env.AGENTHUB_RECOVERY_OPERATION === recoveryLock();
|
|
34
|
+
const before = readControl(stateDir);
|
|
35
|
+
if (recovering && before?.instanceId) await recordTerminalLaunch("pi", cwd, stateDir, before.instanceId);
|
|
36
|
+
const hub = await connect();
|
|
37
|
+
const res = await hub.request({ t: "start", peer: "pi", args: options, operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
|
|
38
|
+
hub.close();
|
|
39
|
+
if (!res.ok) fail(res.error);
|
|
40
|
+
if (options.mode === "tui") {
|
|
41
|
+
const launch = res.launch;
|
|
42
|
+
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");
|
|
43
|
+
const launchEnv = { ...(launch.env as NodeJS.ProcessEnv) };
|
|
44
|
+
delete launchEnv.AGENTHUB_RECOVERY_OPERATION;
|
|
45
|
+
delete launchEnv.AGENTHUB_UNATTENDED;
|
|
46
|
+
const control = readControl(stateDir);
|
|
47
|
+
if (!recovering && control?.instanceId) await recordTerminalLaunch("pi", cwd, stateDir, control.instanceId);
|
|
48
|
+
return execWithEnv(launch.cmd, launch.args, launchEnv);
|
|
49
|
+
}
|
|
50
|
+
console.log(res.already ? "pi is already attached" : 'pi attached (headless). Talk to it with: ahub say @pi "..."');
|
|
51
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { NativeCliEntryContext } from "./native-cli-entry-types.ts";
|
|
2
|
+
|
|
3
|
+
export async function runQwenEntry(ctx: NativeCliEntryContext): Promise<void> {
|
|
4
|
+
const { args, cwd, stateDir, unattendedEnv, connect, fail, takeFlags } = ctx;
|
|
5
|
+
const { launcherPreview } = ctx.helpers;
|
|
6
|
+
if (args.includes("--print-command") || args.includes("--dry-run")) {
|
|
7
|
+
try { return console.log(JSON.stringify(launcherPreview("qwen", args, cwd, stateDir, unattendedEnv), null, 2)); }
|
|
8
|
+
catch { fail("cannot preview Qwen launch: invalid arguments or configuration (details withheld)"); }
|
|
9
|
+
}
|
|
10
|
+
const { one, rest } = takeFlags(args, ["--mode", "--model", "--session-id"], []);
|
|
11
|
+
const adapter = ctx.definition;
|
|
12
|
+
if (one["--session-id"] !== undefined && !adapter.session.crashIdentity?.({ sessionId: one["--session-id"] }).sessionId) fail("invalid Qwen session identity");
|
|
13
|
+
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>]");
|
|
14
|
+
const hub = await connect();
|
|
15
|
+
try {
|
|
16
|
+
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);
|
|
17
|
+
if (!reply.ok) fail(reply.error);
|
|
18
|
+
console.log(reply.already ? "qwen is already attached" : "qwen attached (headless); use ahub say @qwen and the console for approvals");
|
|
19
|
+
} finally { hub.close(); }
|
|
20
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { HubConfig } from "../../hub/daemon.ts";
|
|
2
|
+
import type { ControlClient } from "../../hub/control-client.ts";
|
|
3
|
+
import type { AdapterDefinition } from "../registry-types.ts";
|
|
4
|
+
|
|
5
|
+
/** Private static builtin relocation ports. The CLI binds identity, project and authority before invocation. */
|
|
6
|
+
export interface NativeCliEntryContext {
|
|
7
|
+
args: string[]; cwd: string; stateDir: string; cliDir: string; unattendedEnv: boolean; definition: AdapterDefinition;
|
|
8
|
+
projectConfig(): HubConfig;
|
|
9
|
+
connect(): Promise<ControlClient>;
|
|
10
|
+
fail(message: string): never;
|
|
11
|
+
exec(bin: string, argv: string[], tool?: "claude" | "codex" | "kimi"): never;
|
|
12
|
+
execWithEnv(bin: string, argv: string[], extra: NodeJS.ProcessEnv): never;
|
|
13
|
+
takeFlags(argv: string[], single: string[], repeated: string[]): { one: Record<string, string>; many: Record<string, string[]>; rest: string[] };
|
|
14
|
+
helpers: {
|
|
15
|
+
launcherPreview: typeof import("../../cli/preview.ts").launcherPreview;
|
|
16
|
+
readControl: typeof import("../../hub/control-client.ts").readControl;
|
|
17
|
+
recordTerminalLaunch: typeof import("../../cli/terminal-recovery.ts").recordTerminalLaunch;
|
|
18
|
+
recoveryLock: typeof import("../../hub/recovery-store.ts").recoveryLock;
|
|
19
|
+
processSignature: typeof import("../../pi/process-signature.ts").processSignature;
|
|
20
|
+
buildLaunch: typeof import("../../cli/launch.ts").buildLaunch;
|
|
21
|
+
claudeObservationHooks: typeof import("../../cli/launch.ts").claudeObservationHooks;
|
|
22
|
+
recordClaudeLaunch: typeof import("../../cli/launch.ts").recordClaudeLaunch;
|
|
23
|
+
cleanupStaleClaudeSettings: typeof import("../../cli/launch.ts").cleanupStaleClaudeSettings;
|
|
24
|
+
cleanupClaudeSettings: typeof import("../../cli/launch.ts").cleanupClaudeSettings;
|
|
25
|
+
nativeLaunchEnv: typeof import("../../cli/launch.ts").nativeLaunchEnv;
|
|
26
|
+
runClaudeLaunch: typeof import("../../cli/launch.ts").runClaudeLaunch;
|
|
27
|
+
childEnv: typeof import("../../hub/child-process.ts").childEnv;
|
|
28
|
+
qwenSessionId: typeof import("../../hub/crash.ts").qwenSessionId;
|
|
29
|
+
configureJcodeMcp: typeof import("../../cli/jcode-mcp.ts").configureJcodeMcp;
|
|
30
|
+
configureKimiMcp: typeof import("../../cli/kimi-mcp.ts").configureKimiMcp;
|
|
31
|
+
buildKimiTuiLaunch: typeof import("../../cli/launch.ts").buildKimiTuiLaunch;
|
|
32
|
+
startModeOf: typeof import("../../hub/start-mode.ts").startModeOf;
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
export interface BuiltinCliEntry {
|
|
36
|
+
command: string;
|
|
37
|
+
/** Original CLI guard timing: previews precede it; Pi/local retain their daemon-enforced start admission. */
|
|
38
|
+
lifecycleAdmission: boolean;
|
|
39
|
+
execute(context: NativeCliEntryContext): Promise<void>;
|
|
40
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { BuiltinCliEntry } from "./native-cli-entry-types.ts";
|
|
2
|
+
import { runClaudeEntry } from "./native-cli-entry-claude.ts";
|
|
3
|
+
import { runCodexEntry } from "./native-cli-entry-codex.ts";
|
|
4
|
+
import { runJcodeEntry } from "./native-cli-entry-jcode.ts";
|
|
5
|
+
import { runQwenEntry } from "./native-cli-entry-qwen.ts";
|
|
6
|
+
import { runKimiEntry } from "./native-cli-entry-kimi.ts";
|
|
7
|
+
import { runPiEntry } from "./native-cli-entry-pi.ts";
|
|
8
|
+
import { runLocalEntry } from "./native-cli-entry-local.ts";
|
|
9
|
+
|
|
10
|
+
// Fixed trusted builtins only. No project configuration, caller payload or external module can register an entry.
|
|
11
|
+
export const builtinCliEntries: readonly BuiltinCliEntry[] = Object.freeze([
|
|
12
|
+
{ command: "claude", lifecycleAdmission: true, execute: runClaudeEntry },
|
|
13
|
+
{ command: "codex", lifecycleAdmission: true, execute: runCodexEntry },
|
|
14
|
+
{ command: "jcode", lifecycleAdmission: true, execute: runJcodeEntry },
|
|
15
|
+
{ command: "qwen", lifecycleAdmission: true, execute: runQwenEntry },
|
|
16
|
+
{ command: "kimi", lifecycleAdmission: true, execute: runKimiEntry },
|
|
17
|
+
{ command: "pi", lifecycleAdmission: false, execute: runPiEntry },
|
|
18
|
+
{ command: "local", lifecycleAdmission: false, execute: runLocalEntry },
|
|
19
|
+
]);
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { closeSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { hubGitSync } from "../../hub/git.ts";
|
|
5
|
+
|
|
6
|
+
export const JCODE_MCP_FILE = ".jcode/mcp.json", JCODE_MCP_NAME = "agent-hub-jcode";
|
|
7
|
+
type Document = Record<string, unknown>;
|
|
8
|
+
const object = (value: unknown): value is Document => !!value && typeof value === "object" && !Array.isArray(value);
|
|
9
|
+
|
|
10
|
+
function document(file: string): Document {
|
|
11
|
+
const stat = lstatSync(file, { throwIfNoEntry: false });
|
|
12
|
+
if (!stat) return {};
|
|
13
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.size > 256 * 1024) throw new Error("Jcode MCP configuration must be a bounded regular file, not a link");
|
|
14
|
+
let value: unknown; try { value = JSON.parse(readFileSync(file, "utf8")); } catch { throw new Error("Jcode MCP configuration is invalid JSON"); }
|
|
15
|
+
if (!object(value)) throw new Error("Jcode MCP configuration must be an object");
|
|
16
|
+
if (value.servers !== undefined && value.mcpServers !== undefined) throw new Error("Jcode MCP configuration has ambiguous server maps");
|
|
17
|
+
for (const key of ["servers", "mcpServers"]) if (value[key] !== undefined && !object(value[key])) throw new Error("Jcode MCP server map must be an object");
|
|
18
|
+
return value;
|
|
19
|
+
}
|
|
20
|
+
const map = (doc: Document): Document => (doc.mcpServers ?? doc.servers ?? {}) as Document;
|
|
21
|
+
const owned = (entry: unknown, cwd: string): entry is Document => object(entry) && object(entry.env) && entry.env.AGENTHUB_MODE === "tools" && entry.env.AGENTHUB_PEER_ID === "jcode" && entry.env.AGENTHUB_PROJECT_DIR === cwd;
|
|
22
|
+
|
|
23
|
+
export function jcodeMcpEntry(cwd: string, stateDir: string, bundle: string) {
|
|
24
|
+
return { command: "bun", args: ["run", bundle], shared: false, env: { AGENTHUB_MODE: "tools", AGENTHUB_DELIVERY_ONLY: "true", AGENTHUB_PEER_ID: "jcode", AGENTHUB_STATE_DIR: stateDir, AGENTHUB_PROJECT_DIR: cwd } };
|
|
25
|
+
}
|
|
26
|
+
/** The native loader reads later project files last. Never shadow a foreign definition. */
|
|
27
|
+
function inspect(cwd: string): Document {
|
|
28
|
+
const directory = lstatSync(join(cwd, ".jcode"), { throwIfNoEntry: false });
|
|
29
|
+
if (directory && (!directory.isDirectory() || directory.isSymbolicLink())) throw new Error(".jcode must be a project directory, not a link");
|
|
30
|
+
for (const relative of [".mcp.json", ".claude/mcp.json"]) {
|
|
31
|
+
const parent = relative.startsWith(".claude/") ? lstatSync(join(cwd, ".claude"), { throwIfNoEntry: false }) : undefined;
|
|
32
|
+
if (parent?.isSymbolicLink()) throw new Error("Jcode MCP override layer is a link; inspect it before launching");
|
|
33
|
+
if (Object.hasOwn(map(document(join(cwd, relative))), JCODE_MCP_NAME)) throw new Error("a later project MCP layer overrides agent-hub-jcode; resolve it in your editor");
|
|
34
|
+
}
|
|
35
|
+
return document(join(cwd, JCODE_MCP_FILE));
|
|
36
|
+
}
|
|
37
|
+
export function jcodeMcpReady(cwd: string, stateDir: string, bundle: string): boolean {
|
|
38
|
+
const entry = map(inspect(cwd))[JCODE_MCP_NAME];
|
|
39
|
+
return JSON.stringify(entry) === JSON.stringify(jcodeMcpEntry(cwd, stateDir, bundle));
|
|
40
|
+
}
|
|
41
|
+
/** Only the console launcher calls this; daemon starts use jcodeMcpReady read-only. */
|
|
42
|
+
export function configureJcodeMcp(cwd: string, stateDir: string, bundle: string, remove = false): { changed: boolean; tracked: boolean } {
|
|
43
|
+
const current = inspect(cwd), servers = { ...map(current) }, existing = servers[JCODE_MCP_NAME];
|
|
44
|
+
if (existing !== undefined && !owned(existing, cwd)) throw new Error("agent-hub-jcode is a foreign MCP entry; it was not changed");
|
|
45
|
+
if (remove) delete servers[JCODE_MCP_NAME]; else servers[JCODE_MCP_NAME] = jcodeMcpEntry(cwd, stateDir, bundle);
|
|
46
|
+
const key = current.servers !== undefined ? "servers" : "mcpServers";
|
|
47
|
+
const next = { ...current, ...(current[key] !== undefined || !remove ? { [key]: servers } : {}) };
|
|
48
|
+
const tracked = hubGitSync(["ls-files", "--error-unmatch", "--", JCODE_MCP_FILE], { cwd, encoding: "utf8", timeout: 3000 }).status === 0;
|
|
49
|
+
if (JSON.stringify(next) === JSON.stringify(current)) return { changed: false, tracked };
|
|
50
|
+
mkdirSync(join(cwd, ".jcode"), { recursive: true });
|
|
51
|
+
const file = join(cwd, JCODE_MCP_FILE), temporary = `${file}.${randomUUID()}.tmp`;
|
|
52
|
+
try {
|
|
53
|
+
const fd = openSync(temporary, "wx", 0o600);
|
|
54
|
+
try { writeFileSync(fd, `${JSON.stringify(next, null, 2)}\n`); } finally { closeSync(fd); }
|
|
55
|
+
renameSync(temporary, file);
|
|
56
|
+
} finally { if (existsSync(temporary)) unlinkSync(temporary); }
|
|
57
|
+
return { changed: true, tracked };
|
|
58
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { LaunchContext, LaunchPlan } from "../registry-types.ts";
|
|
2
|
+
import type { Launch } from "../../cli/launch.ts";
|
|
3
|
+
import { UNATTENDED_WARNING } from "../../cli/launch.ts";
|
|
4
|
+
import { shellQuote } from "../../cli/shell-quote.ts";
|
|
5
|
+
|
|
6
|
+
export interface NativeLaunchPolicy { hubSwitches: readonly string[]; ownedFlags: readonly string[] }
|
|
7
|
+
export function nativeLaunchArguments(tool: string, ctx: LaunchContext, policy: NativeLaunchPolicy) {
|
|
8
|
+
const passthrough = ctx.userArgs.filter(arg => !policy.hubSwitches.includes(arg));
|
|
9
|
+
const dup = passthrough.find(arg => policy.ownedFlags.some(flag => arg === flag || arg.startsWith(`${flag}=`)));
|
|
10
|
+
if (dup) throw new Error(`${dup} is managed by the hub; use "hub ${tool} --unattended" or drop the flag`);
|
|
11
|
+
const unattended = ctx.unattended || ctx.userArgs.includes("--unattended");
|
|
12
|
+
return { passthrough, unattended, warning: unattended ? { warning: UNATTENDED_WARNING } : {} };
|
|
13
|
+
}
|
|
14
|
+
export function nativeLaunchPlan(launch: Launch, ctx: LaunchContext): LaunchPlan {
|
|
15
|
+
const argv = [launch.cmd, ...launch.args];
|
|
16
|
+
return { ...launch, metadata: { packageEntrypoint: ctx.entrypoint, argv, env: {}, command: argv.map(shellQuote).join(" ") } };
|
|
17
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export function parsePiFlags(args: string[], fail: (message: string) => never): { mode?: "headless" | "tui"; backend?: "auto" | "dgx" | "mlx"; model?: string; sessionId?: string; sessionFile?: string; replaceSession?: boolean } {
|
|
2
|
+
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>]";
|
|
3
|
+
if (args.includes("--headless")) {
|
|
4
|
+
if (args.includes("--mode")) fail(usage);
|
|
5
|
+
args.splice(args.indexOf("--headless"), 1, "--mode", "headless");
|
|
6
|
+
}
|
|
7
|
+
for (const flag of ["--mode", "--backend", "--model", "--session-id", "--session-file"]) {
|
|
8
|
+
const index = args.indexOf(flag);
|
|
9
|
+
if (index >= 0 && (!args[index + 1] || args[index + 1]!.startsWith("--"))) fail(`${flag} needs a value`);
|
|
10
|
+
}
|
|
11
|
+
const mode = (args.includes("--mode") ? args[args.indexOf("--mode") + 1] : undefined) as string | undefined;
|
|
12
|
+
const backend = (args.includes("--backend") ? args[args.indexOf("--backend") + 1] : undefined) as string | undefined;
|
|
13
|
+
const model = args.includes("--model") ? args[args.indexOf("--model") + 1] : undefined;
|
|
14
|
+
const sessionId = args.includes("--session-id") ? args[args.indexOf("--session-id") + 1] : undefined;
|
|
15
|
+
const sessionFile = args.includes("--session-file") ? args[args.indexOf("--session-file") + 1] : undefined;
|
|
16
|
+
const replaceSession = args.includes("--replace-session");
|
|
17
|
+
if (replaceSession && (!sessionFile || sessionId)) fail("--replace-session requires --session-file, without --session-id");
|
|
18
|
+
const valueFlags = new Set(["--mode", "--backend", "--model", "--session-id", "--session-file"]);
|
|
19
|
+
for (let i = 0; i < args.length; i++) {
|
|
20
|
+
if (args[i] === "--replace-session") continue;
|
|
21
|
+
if (!valueFlags.has(args[i]!)) fail(`unknown Pi option: ${args[i]}`);
|
|
22
|
+
i++;
|
|
23
|
+
}
|
|
24
|
+
if ((mode !== undefined && !["headless", "tui"].includes(mode)) || (backend !== undefined && !["auto", "dgx", "mlx"].includes(backend)) || (sessionId && sessionFile)) fail(usage);
|
|
25
|
+
return { ...(replaceSession ? { replaceSession: true } : {}), ...(mode ? { mode: mode as "headless" | "tui" } : {}), ...(backend ? { backend: backend as "auto" | "dgx" | "mlx" } : {}), ...(model ? { model } : {}), ...(sessionId ? { sessionId } : {}), ...(sessionFile ? { sessionFile } : {}) };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The conductor can repeat only the selected form; native startup options remain person-only. */
|
|
29
|
+
export function piConductorStartArgs(args: string[]): { mode?: "headless" | "tui" } | Error {
|
|
30
|
+
if (args.length === 1 && args[0] === "--headless") args.splice(0, 1, "--mode", "headless");
|
|
31
|
+
if (args.length && !(args.length === 2 && args[0] === "--mode" && ["headless", "tui"].includes(args[1] ?? ""))) return new Error("conductor starts accept no launch overrides; use ahub pi [--mode headless|tui]");
|
|
32
|
+
return args[1] ? { mode: args[1] as "headless" | "tui" } : {};
|
|
33
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { Launch } from "../../cli/launch.ts";
|
|
4
|
+
import { REDACTED, publicArg, previewState, finishPreview, type NativePreviewContext } from "./native-cli-preview-common.ts";
|
|
5
|
+
|
|
6
|
+
export function previewClaude(ctx: NativePreviewContext) {
|
|
7
|
+
const { args, cwd, stateDir, cliDir, unattended, config } = ctx;
|
|
8
|
+
const { buildLaunch, claudeObservationHooks } = ctx.helpers;
|
|
9
|
+
const state = previewState(args, ctx.baseEnvNames), { unresolved } = state;
|
|
10
|
+
let envNames = state.envNames;
|
|
11
|
+
let launch: Launch;
|
|
12
|
+
let original: { command?: string; refreshInterval?: number; padding?: number } | undefined;
|
|
13
|
+
for (const file of [join(cwd, ".claude/settings.local.json"), join(cwd, ".claude/settings.json"), join(process.env.HOME ?? "", ".claude/settings.json")]) {
|
|
14
|
+
try { original ??= JSON.parse(readFileSync(file, "utf8")).statusLine; } catch { /* optional */ }
|
|
15
|
+
}
|
|
16
|
+
// Build with original inputs so channel selection and owned-flag validation match normal launch.
|
|
17
|
+
const facts = claudeObservationHooks(config, { script: join(cliDir, "facts-hook.ts"), stateDir });
|
|
18
|
+
launch = buildLaunch("claude", args, { preview: true, unattended, statusLine: { script: join(cliDir, "statusline-tee.ts"), stateDir, ...(original ? { original: { command: original.command ? REDACTED : "", ...(typeof original.refreshInterval === "number" && Number.isFinite(original.refreshInterval) ? { refreshInterval: original.refreshInterval } : {}), ...(typeof original.padding === "number" && Number.isFinite(original.padding) ? { padding: original.padding } : {}) } } : {}) }, ...(facts ? { facts } : {}) });
|
|
19
|
+
// Merged caller settings change the native argument count. Rebuild a redacted preview,
|
|
20
|
+
// retaining the managed hooks but never serializing caller settings or their file path.
|
|
21
|
+
const safeArgs = args.map((arg, i) => {
|
|
22
|
+
if (args[i - 1] === "--settings") return "{}";
|
|
23
|
+
if (arg.startsWith("--settings=")) return "--settings={}";
|
|
24
|
+
if (["--unattended", "--safe", "--new"].includes(arg)) return arg;
|
|
25
|
+
return publicArg(arg);
|
|
26
|
+
});
|
|
27
|
+
const publicLaunch = buildLaunch("claude", safeArgs, { preview: true, unattended, statusLine: { script: join(cliDir, "statusline-tee.ts"), stateDir,
|
|
28
|
+
...(original ? { original: { command: original.command ? REDACTED : "", ...(typeof original.refreshInterval === "number" && Number.isFinite(original.refreshInterval) ? { refreshInterval: original.refreshInterval } : {}), ...(typeof original.padding === "number" && Number.isFinite(original.padding) ? { padding: original.padding } : {}) } } : {}) }, facts });
|
|
29
|
+
publicLaunch.args.splice(0, 2, ...launch.args.slice(0, 2)); // channel selection was verified against the original MCP config
|
|
30
|
+
launch = publicLaunch;
|
|
31
|
+
|
|
32
|
+
envNames.push("AGENTHUB_CHANNEL");
|
|
33
|
+
envNames.push("AGENTHUB_INSTANCE_ID", "AGENTHUB_LAUNCH_ID");
|
|
34
|
+
unresolved.push({ field: "env.AGENTHUB_INSTANCE_ID", reason: "injected when an existing daemon instance is read at launch; preview does not read runtime state" });
|
|
35
|
+
unresolved.push({ field: "env.AGENTHUB_LAUNCH_ID", reason: "allocated only for a verified Orca terminal launch; preview does not query Orca, allocate identity or write launch records" });
|
|
36
|
+
return finishPreview(ctx, launch, envNames, unresolved, undefined, launch.args.includes("--settings") ? launch.args[launch.args.indexOf("--settings") + 1] : undefined);
|
|
37
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Launch } from "../../cli/launch.ts";
|
|
2
|
+
import { REDACTED, UNRESOLVED, publicArg, previewState, finishPreview, type NativePreviewContext } from "./native-cli-preview-common.ts";
|
|
3
|
+
|
|
4
|
+
export function previewCodex(ctx: NativePreviewContext) {
|
|
5
|
+
const { args, unattended, config } = ctx;
|
|
6
|
+
const { buildLaunch } = ctx.helpers;
|
|
7
|
+
const state = previewState(args, ctx.baseEnvNames), { unresolved } = state;
|
|
8
|
+
let envNames = state.envNames;
|
|
9
|
+
let launch: Launch;
|
|
10
|
+
// A fresh native proxy is assigned by start, never guessed from an old status file.
|
|
11
|
+
launch = buildLaunch("codex", args, { unattended, proxyUrl: UNRESOLVED, codexBin: config.codex_bin === "codex" ? "codex" : REDACTED });
|
|
12
|
+
const passthrough = args.filter(arg => !["--unattended", "--safe", "--new"].includes(arg));
|
|
13
|
+
launch.args = launch.args.slice(0, launch.args.length - passthrough.length).concat(passthrough.map(publicArg));
|
|
14
|
+
unresolved.push({ field: "proxyUrl", reason: "assigned when the native adapter starts; preview does not start it" });
|
|
15
|
+
|
|
16
|
+
envNames.push("AGENTHUB_INSTANCE_ID", "AGENTHUB_LAUNCH_ID");
|
|
17
|
+
unresolved.push({ field: "env.AGENTHUB_INSTANCE_ID", reason: "injected when a verified Orca terminal launch is recorded against the daemon; preview does not record it" });
|
|
18
|
+
unresolved.push({ field: "env.AGENTHUB_LAUNCH_ID", reason: "allocated only for a verified Orca terminal launch; preview does not query Orca, allocate identity or write launch records" });
|
|
19
|
+
return finishPreview(ctx, launch, envNames, unresolved, undefined);
|
|
20
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { loadConfig } from "../../hub/daemon.ts";
|
|
2
|
+
import type { AdapterDefinition } from "../registry-types.ts";
|
|
3
|
+
import type { Launch } from "../../cli/launch.ts";
|
|
4
|
+
|
|
5
|
+
export const REDACTED = "[redacted]";
|
|
6
|
+
export const UNRESOLVED = "[unresolved]";
|
|
7
|
+
/** Fixed, pure/read-only CLI planning helpers. No command executor, control client or writer enters this port. */
|
|
8
|
+
export interface NativePreviewHelpers {
|
|
9
|
+
buildLaunch: typeof import("../../cli/launch.ts").buildLaunch;
|
|
10
|
+
claudeObservationHooks: typeof import("../../cli/launch.ts").claudeObservationHooks;
|
|
11
|
+
buildKimiLaunch: typeof import("../../cli/launch.ts").buildKimiLaunch;
|
|
12
|
+
buildKimiTuiLaunch: typeof import("../../cli/launch.ts").buildKimiTuiLaunch;
|
|
13
|
+
nativeLaunchEnv: typeof import("../../cli/launch.ts").nativeLaunchEnv;
|
|
14
|
+
childEnv: typeof import("../../hub/child-process.ts").childEnv;
|
|
15
|
+
startModeOf: typeof import("../../hub/start-mode.ts").startModeOf;
|
|
16
|
+
kimiMcpPlan: typeof import("../../cli/kimi-mcp.ts").kimiMcpPlan;
|
|
17
|
+
jcodeCommand: typeof import("../jcode.ts").jcodeCommand;
|
|
18
|
+
qwenSessionId: typeof import("../../hub/crash.ts").qwenSessionId;
|
|
19
|
+
relayModelIds: typeof import("../../models/relay.ts").relayModelIds;
|
|
20
|
+
buildPiLaunch: typeof import("../../pi/launch.ts").buildPiLaunch;
|
|
21
|
+
}
|
|
22
|
+
export interface NativePreviewContext {
|
|
23
|
+
tool: string; args: string[]; cwd: string; stateDir: string; cliDir: string; unattended: boolean;
|
|
24
|
+
config: ReturnType<typeof loadConfig>; definition: AdapterDefinition; baseEnvNames: string[]; helpers: NativePreviewHelpers;
|
|
25
|
+
}
|
|
26
|
+
export interface NativePreviewMetadata { mode: "tui" | "headless"; mcp: { file: string; action: string; changed: boolean; tracked: boolean } }
|
|
27
|
+
export type NativePreviewBuilder = (ctx: NativePreviewContext) => NativePreview;
|
|
28
|
+
export interface NativePreview {
|
|
29
|
+
tool: string; mode?: "tui" | "headless"; mcp?: NativePreviewMetadata["mcp"];
|
|
30
|
+
cmd: string; args: string[]; settings: string | undefined; envNames: string[]; warning?: string;
|
|
31
|
+
unresolved: { field: string; reason: string }[]; redaction: string; needs: string[];
|
|
32
|
+
}
|
|
33
|
+
/** Arbitrary native arguments can contain secrets even without a credential-looking key. */
|
|
34
|
+
export function publicArg(arg: string): string {
|
|
35
|
+
const flags = new Set(["--settings", "--model", "--mcp-config", "--session-id", "--session-file", "--resume", "--continue", "--print", "--safe", "--new"]);
|
|
36
|
+
const key = arg.split("=", 1)[0]!;
|
|
37
|
+
return flags.has(key) ? (arg.includes("=") ? key + "=" + REDACTED : key) : REDACTED;
|
|
38
|
+
}
|
|
39
|
+
export function nativeCommand(command: string[], trusted: string[]): string[] {
|
|
40
|
+
return command.map((value, i) => value === trusted[i] ? value : REDACTED);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function previewState(args: readonly string[], baseEnvNames: readonly string[]) {
|
|
44
|
+
const unresolved: { field: string; reason: string }[] = [];
|
|
45
|
+
const envNames = [...baseEnvNames];
|
|
46
|
+
const value = (flag: string) => {
|
|
47
|
+
const at = args.indexOf(flag);
|
|
48
|
+
if (at < 0) return undefined;
|
|
49
|
+
const next = args[at + 1];
|
|
50
|
+
if (!next || next.startsWith("--")) throw new Error("missing option value");
|
|
51
|
+
return next;
|
|
52
|
+
};
|
|
53
|
+
return { unresolved, envNames, value };
|
|
54
|
+
}
|
|
55
|
+
export function finishPreview(ctx: NativePreviewContext, launch: Launch, envNames: string[], unresolved: NativePreview["unresolved"], metadata?: NativePreviewMetadata, settings?: string): NativePreview {
|
|
56
|
+
return { tool: ctx.tool, ...(metadata ?? {}), cmd: launch.cmd, args: launch.args, settings, envNames: [...new Set(envNames)].sort(), ...(launch.warning ? { warning: launch.warning } : {}), unresolved, redaction: "Arbitrary user arguments, settings, commands and configured executable overrides are redacted. Environment values are never displayed.", needs: ["Native executable, accounts and runtime readiness are not checked."] };
|
|
57
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Launch } from "../../cli/launch.ts";
|
|
2
|
+
import { REDACTED, UNRESOLVED, nativeCommand, previewState, finishPreview, type NativePreviewContext } from "./native-cli-preview-common.ts";
|
|
3
|
+
|
|
4
|
+
export function previewJcode(ctx: NativePreviewContext) {
|
|
5
|
+
const { args, config } = ctx;
|
|
6
|
+
const { jcodeCommand, qwenSessionId } = ctx.helpers;
|
|
7
|
+
const state = previewState(args, ctx.baseEnvNames), { unresolved, value } = state;
|
|
8
|
+
let envNames = state.envNames;
|
|
9
|
+
let launch: Launch;
|
|
10
|
+
const mode = value("--mode") ?? "headless", model = value("--model"), profile = value("--provider-profile"), session = value("--session-id");
|
|
11
|
+
if (mode !== "headless") throw new Error("Jcode TUI driving is unverified");
|
|
12
|
+
for (let i = 0; i < args.length; i += 2) if (!["--model", "--mode", "--provider-profile", "--session-id"].includes(args[i]!) || !args[i + 1]) throw new Error("unknown Jcode option");
|
|
13
|
+
if (session !== undefined && !qwenSessionId(session)) throw new Error("invalid Jcode session identity");
|
|
14
|
+
const cmd = jcodeCommand(nativeCommand(config.jcode_cmd, ["jcode"]), "acp", UNRESOLVED, UNRESOLVED, model ? REDACTED : undefined, profile || config.jcode_provider_profile ? REDACTED : undefined);
|
|
15
|
+
launch = { cmd: cmd[0]!, args: cmd.slice(1) };
|
|
16
|
+
unresolved.push({ field: "ownedDaemon", reason: "private daemon/socket and source-pinned native identity verified at start; preview starts nothing" });
|
|
17
|
+
unresolved.push({ field: "hubTools", reason: "project MCP bridge required; native tool-call receipt remains unverified" });
|
|
18
|
+
|
|
19
|
+
return finishPreview(ctx, launch, envNames, unresolved, undefined);
|
|
20
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import type { Launch } from "../../cli/launch.ts";
|
|
3
|
+
import { REDACTED, nativeCommand, previewState, finishPreview, type NativePreviewContext, type NativePreviewMetadata } from "./native-cli-preview-common.ts";
|
|
4
|
+
|
|
5
|
+
export function previewKimi(ctx: NativePreviewContext) {
|
|
6
|
+
const { args, cwd, stateDir, cliDir, config } = ctx;
|
|
7
|
+
const { childEnv, startModeOf, buildKimiLaunch, buildKimiTuiLaunch, nativeLaunchEnv, kimiMcpPlan } = ctx.helpers;
|
|
8
|
+
const state = previewState(args, ctx.baseEnvNames), { unresolved, value } = state;
|
|
9
|
+
let envNames = state.envNames;
|
|
10
|
+
let launch: Launch;
|
|
11
|
+
let kimiMetadata: NativePreviewMetadata | undefined;
|
|
12
|
+
const model = value("--model");
|
|
13
|
+
const mode = value("--mode") ?? startModeOf(config.peers, "kimi");
|
|
14
|
+
if (mode !== "tui" && mode !== "headless") throw new Error("invalid Kimi mode");
|
|
15
|
+
for (let i = 0; i < args.length; i += 2) if (!["--model", "--mode"].includes(args[i]!) || !args[i + 1]) throw new Error("unknown Kimi option");
|
|
16
|
+
if (mode === "headless") {
|
|
17
|
+
launch = buildKimiLaunch(nativeCommand(config.kimi_cmd, ["kimi", "acp"]), model ? REDACTED : undefined);
|
|
18
|
+
unresolved.push({ field: "sessionId", reason: "ACP session identity is available only after native initialization" });
|
|
19
|
+
} else {
|
|
20
|
+
const native = buildKimiTuiLaunch(config.kimi_cmd, model ? REDACTED : undefined);
|
|
21
|
+
launch = { ...native, cmd: native.cmd === "kimi" ? "kimi" : REDACTED, args: native.args.map(arg => arg === "--model" || arg === REDACTED ? arg : REDACTED) };
|
|
22
|
+
envNames = Object.keys(nativeLaunchEnv("kimi", childEnv())).concat(["AGENTHUB_STATE_DIR", "AGENTHUB_PROJECT_DIR"]);
|
|
23
|
+
}
|
|
24
|
+
const { file, action, changed, tracked } = kimiMcpPlan(cwd, stateDir, join(cliDir, "../../plugins/agent-hub/server.js"), mode);
|
|
25
|
+
kimiMetadata = { mode, mcp: { file, action, changed, tracked } };
|
|
26
|
+
|
|
27
|
+
return finishPreview(ctx, launch, envNames, unresolved, kimiMetadata);
|
|
28
|
+
}
|