@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.
Files changed (100) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +3 -3
  3. package/docs/agent-notes/adapters.md +7 -3
  4. package/docs/agent-notes/budget.md +5 -3
  5. package/docs/agent-notes/daemon.md +3 -3
  6. package/docs/agent-notes/tasks.md +2 -1
  7. package/docs/agent-notes/tests.md +2 -1
  8. package/docs/operations.md +20 -10
  9. package/docs/quickstart.md +4 -2
  10. package/docs/security.md +4 -1
  11. package/docs/smoke.md +1 -1
  12. package/docs/specs/2026-09-19-agent-hub-design.md +246 -4
  13. package/docs/verification/2026-10-18-0.12.34.md +48 -0
  14. package/docs/verified.json +29 -18
  15. package/package.json +1 -1
  16. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  17. package/plugins/agent-hub/server.js +22882 -16496
  18. package/src/adapters/codex-appserver.ts +81 -11
  19. package/src/adapters/definitions/builtin-metadata.ts +101 -7
  20. package/src/adapters/definitions/native-cli-claude-launch.ts +90 -0
  21. package/src/adapters/definitions/native-cli-claude-setup.ts +80 -0
  22. package/src/adapters/definitions/native-cli-codex-launch.ts +14 -0
  23. package/src/adapters/definitions/native-cli-doctor.ts +46 -0
  24. package/src/adapters/definitions/native-cli-entry-claude.ts +52 -0
  25. package/src/adapters/definitions/native-cli-entry-codex.ts +26 -0
  26. package/src/adapters/definitions/native-cli-entry-jcode.ts +24 -0
  27. package/src/adapters/definitions/native-cli-entry-kimi.ts +52 -0
  28. package/src/adapters/definitions/native-cli-entry-local.ts +14 -0
  29. package/src/adapters/definitions/native-cli-entry-pi.ts +51 -0
  30. package/src/adapters/definitions/native-cli-entry-qwen.ts +20 -0
  31. package/src/adapters/definitions/native-cli-entry-types.ts +40 -0
  32. package/src/adapters/definitions/native-cli-entry.ts +19 -0
  33. package/src/adapters/definitions/native-cli-jcode-mcp.ts +58 -0
  34. package/src/adapters/definitions/native-cli-launch-common.ts +17 -0
  35. package/src/adapters/definitions/native-cli-pi.ts +33 -0
  36. package/src/adapters/definitions/native-cli-preview-claude.ts +37 -0
  37. package/src/adapters/definitions/native-cli-preview-codex.ts +20 -0
  38. package/src/adapters/definitions/native-cli-preview-common.ts +57 -0
  39. package/src/adapters/definitions/native-cli-preview-jcode.ts +20 -0
  40. package/src/adapters/definitions/native-cli-preview-kimi.ts +28 -0
  41. package/src/adapters/definitions/native-cli-preview-pi.ts +36 -0
  42. package/src/adapters/definitions/native-cli-preview-qwen.ts +22 -0
  43. package/src/adapters/definitions/native-cli-preview.ts +16 -0
  44. package/src/adapters/definitions/native-config-defaults.ts +19 -0
  45. package/src/adapters/definitions/native-config-policy.ts +35 -0
  46. package/src/adapters/definitions/native-context.ts +35 -0
  47. package/src/adapters/definitions/native-daemon-kimi.ts +5 -0
  48. package/src/adapters/definitions/native-daemon-policy.ts +18 -0
  49. package/src/adapters/definitions/native-display.ts +18 -0
  50. package/src/adapters/definitions/native-factories.ts +48 -0
  51. package/src/adapters/definitions/native-policy-aux.ts +130 -0
  52. package/src/adapters/definitions/native-quota.ts +29 -0
  53. package/src/adapters/definitions/native-recovery-continuity.ts +12 -0
  54. package/src/adapters/definitions/native-recovery.ts +88 -0
  55. package/src/adapters/definitions/qwen.ts +8 -4
  56. package/src/adapters/registry-types.ts +97 -10
  57. package/src/adapters/registry.ts +103 -3
  58. package/src/cli/console-state.ts +5 -5
  59. package/src/cli/identity.ts +6 -5
  60. package/src/cli/jcode-mcp.ts +2 -58
  61. package/src/cli/launch.ts +14 -100
  62. package/src/cli/main.ts +35 -272
  63. package/src/cli/output.ts +3 -2
  64. package/src/cli/preview.ts +14 -125
  65. package/src/cli/recovery-guidance.ts +17 -13
  66. package/src/cli/setup.ts +2 -80
  67. package/src/cli/shell-quote.ts +3 -0
  68. package/src/cli/terminal-recovery.ts +17 -10
  69. package/src/cli/upgrade-interactive.ts +4 -2
  70. package/src/cli/upgrade-restore-state.ts +2 -1
  71. package/src/cli/upgrade-runtime.ts +92 -141
  72. package/src/cli/upgrade.ts +8 -7
  73. package/src/hub/budget.ts +6 -30
  74. package/src/hub/conductor.ts +8 -4
  75. package/src/hub/config-apply.ts +9 -13
  76. package/src/hub/config-reload.ts +7 -2
  77. package/src/hub/context-window.ts +3 -27
  78. package/src/hub/crash.ts +2 -35
  79. package/src/hub/daemon.ts +226 -232
  80. package/src/hub/execution-budget.ts +2 -1
  81. package/src/hub/hub-tools.ts +3 -3
  82. package/src/hub/peer-catalog.ts +4 -13
  83. package/src/hub/permission-mode.ts +9 -12
  84. package/src/hub/recovery-continuity.ts +6 -5
  85. package/src/hub/report.ts +9 -8
  86. package/src/hub/roster.ts +6 -7
  87. package/src/hub/routing.ts +21 -10
  88. package/src/hub/settings.ts +6 -5
  89. package/src/hub/settled-roles.ts +1 -1
  90. package/src/hub/start-mode.ts +8 -6
  91. package/src/hub/tasks.ts +13 -10
  92. package/src/hub/tooling.ts +4 -60
  93. package/src/hub/ui.ts +3 -1
  94. package/src/memory/recall.ts +4 -5
  95. package/src/models/route/signals.ts +3 -1
  96. package/src/pi/launch.ts +2 -1
  97. package/src/ui/index.html +3 -2
  98. package/src/ui/office-model.ts +3 -1
  99. package/src/ui/peer-palette.ts +16 -6
  100. package/templates/routing.toml +2 -2
@@ -1,134 +1,23 @@
1
- import { definitionOf, kindOf } from "../adapters/registry.ts";
2
- import { jcodeCommand } from "../adapters/jcode.ts";
3
- import { qwenSessionId } from "../hub/crash.ts";
4
- import { readFileSync } from "node:fs";
5
- import { join, resolve } from "node:path";
1
+ import { definitionOf, builtinPreviewOf } from "../adapters/registry.ts";
6
2
  import { loadConfig } from "../hub/daemon.ts";
7
3
  import { childEnv } from "../hub/child-process.ts";
8
- import { relayModelIds } from "../models/relay.ts";
9
- import { buildPiLaunch } from "../pi/launch.ts";
10
4
  import { startModeOf } from "../hub/start-mode.ts";
11
- import { buildLaunch, buildKimiLaunch, buildKimiTuiLaunch, nativeLaunchEnv, claudeObservationHooks, type Launch } from "./launch.ts";
5
+ import { buildLaunch, buildKimiLaunch, buildKimiTuiLaunch, nativeLaunchEnv, claudeObservationHooks } from "./launch.ts";
12
6
  import { kimiMcpPlan } from "./kimi-mcp.ts";
7
+ import { jcodeCommand } from "../adapters/jcode.ts";
8
+ import { qwenSessionId } from "../hub/crash.ts";
9
+ import { relayModelIds } from "../models/relay.ts";
10
+ import { buildPiLaunch } from "../pi/launch.ts";
13
11
 
14
- const REDACTED = "[redacted]";
15
- const UNRESOLVED = "[unresolved]";
16
- /** Arbitrary native arguments can contain secrets even without a credential-looking key. */
17
- function publicArg(arg: string): string {
18
- const flags = new Set(["--settings", "--model", "--mcp-config", "--session-id", "--session-file", "--resume", "--continue", "--print", "--safe", "--new"]);
19
- const key = arg.split("=", 1)[0]!;
20
- return flags.has(key) ? (arg.includes("=") ? key + "=" + REDACTED : key) : REDACTED;
21
- }
22
- function nativeCommand(command: string[], trusted: string[]): string[] {
23
- return command.map((value, i) => value === trusted[i] ? value : REDACTED);
24
- }
25
- /** Read-only preview. Inputs passed to shared native builders are redacted before serialization. */
12
+ /** Read-only builtin preview. Native redaction uses the same launch builders as actual startup. */
26
13
  export function launcherPreview(tool: "claude" | "codex" | "kimi" | "pi" | "qwen" | "jcode", raw: string[], cwd: string, stateDir: string, unattended: boolean) {
27
14
  const args = raw.filter(arg => !["--print-command", "--dry-run", "--json"].includes(arg));
28
15
  const config = loadConfig(cwd);
29
- const unresolved: { field: string; reason: string }[] = [];
30
- let launch: Launch;
31
- let kimiMetadata: { mode: "tui" | "headless"; mcp: { file: string; action: string; changed: boolean; tracked: boolean } } | undefined;
32
- let envNames = Object.keys(childEnv()).concat(["AGENTHUB_STATE_DIR", "AGENTHUB_PROJECT_DIR"]);
33
- const value = (flag: string) => {
34
- const at = args.indexOf(flag);
35
- if (at < 0) return undefined;
36
- const next = args[at + 1];
37
- if (!next || next.startsWith("--")) throw new Error("missing option value");
38
- return next;
39
- };
40
- if (tool === "claude") {
41
- let original: { command?: string; refreshInterval?: number; padding?: number } | undefined;
42
- for (const file of [join(cwd, ".claude/settings.local.json"), join(cwd, ".claude/settings.json"), join(process.env.HOME ?? "", ".claude/settings.json")]) {
43
- try { original ??= JSON.parse(readFileSync(file, "utf8")).statusLine; } catch { /* optional */ }
44
- }
45
- // Build with original inputs so channel selection and owned-flag validation match normal launch.
46
- const facts = claudeObservationHooks(config, { script: join(import.meta.dir, "facts-hook.ts"), stateDir });
47
- launch = buildLaunch(tool, args, { preview: true, unattended, statusLine: { script: join(import.meta.dir, "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 } : {}) });
48
- // Merged caller settings change the native argument count. Rebuild a redacted preview,
49
- // retaining the managed hooks but never serializing caller settings or their file path.
50
- const safeArgs = args.map((arg, i) => {
51
- if (args[i - 1] === "--settings") return "{}";
52
- if (arg.startsWith("--settings=")) return "--settings={}";
53
- if (["--unattended", "--safe", "--new"].includes(arg)) return arg;
54
- return publicArg(arg);
55
- });
56
- const publicLaunch = buildLaunch(tool, safeArgs, { preview: true, unattended, statusLine: { script: join(import.meta.dir, "statusline-tee.ts"), stateDir,
57
- ...(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 });
58
- publicLaunch.args.splice(0, 2, ...launch.args.slice(0, 2)); // channel selection was verified against the original MCP config
59
- launch = publicLaunch;
60
- } else if (tool === "codex") {
61
- // A fresh native proxy is assigned by start, never guessed from an old status file.
62
- launch = buildLaunch(tool, args, { unattended, proxyUrl: UNRESOLVED, codexBin: config.codex_bin === "codex" ? "codex" : REDACTED });
63
- const passthrough = args.filter(arg => !["--unattended", "--safe", "--new"].includes(arg));
64
- launch.args = launch.args.slice(0, launch.args.length - passthrough.length).concat(passthrough.map(publicArg));
65
- unresolved.push({ field: "proxyUrl", reason: "assigned when the native adapter starts; preview does not start it" });
66
- } else if (tool === "jcode") {
67
- const mode = value("--mode") ?? "headless", model = value("--model"), profile = value("--provider-profile"), session = value("--session-id");
68
- if (mode !== "headless") throw new Error("Jcode TUI driving is unverified");
69
- 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");
70
- if (session !== undefined && !qwenSessionId(session)) throw new Error("invalid Jcode session identity");
71
- const cmd = jcodeCommand(nativeCommand(config.jcode_cmd, ["jcode"]), "acp", UNRESOLVED, UNRESOLVED, model ? REDACTED : undefined, profile || config.jcode_provider_profile ? REDACTED : undefined);
72
- launch = { cmd: cmd[0]!, args: cmd.slice(1) };
73
- unresolved.push({ field: "ownedDaemon", reason: "private daemon/socket and source-pinned native identity verified at start; preview starts nothing" });
74
- unresolved.push({ field: "hubTools", reason: "project MCP bridge required; native tool-call receipt remains unverified" });
75
- } else if (kindOf(tool) === "qwen") {
76
- const mode = value("--mode") ?? "headless", model = value("--model");
77
- const adapter = definitionOf(tool)!;
78
- if (!(adapter.start.forms as readonly string[]).includes(mode)) throw new Error("Qwen TUI is not offered before verification");
79
- for (let i = 0; i < args.length; i += 2) if (!["--model", "--mode", "--session-id"].includes(args[i]!) || !args[i + 1]) throw new Error("unknown Qwen option");
80
- const sessionId = value("--session-id");
81
- if (sessionId !== undefined && !adapter.session.crashIdentity?.({ sessionId }).sessionId) throw new Error("invalid Qwen session identity");
82
- if (sessionId) unresolved.push({ field: "sessionId", reason: "requested through ACP session/load at start; preview does not load or expose it" });
83
- launch = adapter.start.launch({ instance: tool, form: "headless", projectRoot: cwd, stateDir, entrypoint: join(import.meta.dir, "main.js"),
84
- userArgs: [], unattended: false, env: {}, preview: true, nativeCommand: nativeCommand(config.qwen_cmd, ["qwen", "--acp"]), model: model ? REDACTED : undefined });
85
- unresolved.push({ field: "nativeIdentity", reason: "Qwen Code 0.25.0 identity and permission modes are checked at ACP initialization; preview starts nothing" });
86
- } else if (tool === "kimi") {
87
- const model = value("--model");
88
- const mode = value("--mode") ?? startModeOf(config.peers, "kimi");
89
- if (mode !== "tui" && mode !== "headless") throw new Error("invalid Kimi mode");
90
- for (let i = 0; i < args.length; i += 2) if (!["--model", "--mode"].includes(args[i]!) || !args[i + 1]) throw new Error("unknown Kimi option");
91
- if (mode === "headless") {
92
- launch = buildKimiLaunch(nativeCommand(config.kimi_cmd, ["kimi", "acp"]), model ? REDACTED : undefined);
93
- unresolved.push({ field: "sessionId", reason: "ACP session identity is available only after native initialization" });
94
- } else {
95
- const native = buildKimiTuiLaunch(config.kimi_cmd, model ? REDACTED : undefined);
96
- launch = { ...native, cmd: native.cmd === "kimi" ? "kimi" : REDACTED, args: native.args.map(arg => arg === "--model" || arg === REDACTED ? arg : REDACTED) };
97
- envNames = Object.keys(nativeLaunchEnv("kimi", childEnv())).concat(["AGENTHUB_STATE_DIR", "AGENTHUB_PROJECT_DIR"]);
98
- }
99
- const { file, action, changed, tracked } = kimiMcpPlan(cwd, stateDir, join(import.meta.dir, "../../plugins/agent-hub/server.js"), mode);
100
- kimiMetadata = { mode, mcp: { file, action, changed, tracked } };
101
- } else {
102
- const mode = value("--mode") ?? startModeOf(config.peers, "pi"); // #269: the same default `ahub pi` takes
103
- const backend = value("--backend") ?? config.pi.backend;
104
- if (!["headless", "tui"].includes(mode) || !["auto", "dgx", "mlx"].includes(backend) || (value("--session-id") && value("--session-file"))) throw new Error("invalid Pi options");
105
- if (args.includes("--replace-session") && (!value("--session-file") || value("--session-id"))) throw new Error("--replace-session requires --session-file, without --session-id");
106
- for (let i = 0; i < args.length; i++) {
107
- if (args[i] === "--replace-session") continue;
108
- if (!["--mode", "--backend", "--model", "--session-id", "--session-file"].includes(args[i]!) || !value(args[i]!)) throw new Error("unknown Pi option");
109
- i++;
110
- }
111
- if (args.includes("--replace-session")) unresolved.push({ field: "session replacement", reason: "requires a person, a validated managed file and an offline settled owner at runtime; preview changes nothing" });
112
- const models = relayModelIds({ enableHubAuto: true, mlx: config.mlx.enabled === false ? undefined : config.mlx, mlxAlias: "mlx/fast", allowedDGXmodels: { "dgx/coding": config.pi.dgx_coding, "dgx/fast": config.pi.dgx_fast } });
113
- const pi = buildPiLaunch({
114
- stateDir, cmd: nativeCommand(config.pi.cmd, ["pi"]), mode: mode as "headless" | "tui", backend: backend as "auto" | "dgx" | "mlx",
115
- ...(value("--model") ? { model: REDACTED } : {}),
116
- ...(value("--session-id") ? { sessionId: REDACTED } : {}),
117
- ...(value("--session-file") ? { sessionFile: REDACTED } : {}),
118
- relay: { url: UNRESOLVED, token: UNRESOLVED, models: models.map(id => ({ id })) },
119
- tools: [], preamble: REDACTED, maxSteps: config.pi.max_steps,
120
- }, {}, resolve(join(import.meta.dir, "../pi/extension.ts")), UNRESOLVED, UNRESOLVED);
121
- launch = pi;
122
- envNames = Object.keys(pi.env).concat(["PATH", "HOME", "USER", "SHELL", "TMPDIR", "TERM", "TERM_PROGRAM", "LANG", "LC_ALL", "LC_CTYPE", "NO_COLOR", "CODEX_HOME"].filter(key => process.env[key] !== undefined));
123
- if (mode === "tui") envNames.push("AGENTHUB_STATE_DIR", "AGENTHUB_PROJECT_DIR");
124
- unresolved.push({ field: "bridge/relay endpoints and owner credentials", reason: "assigned at runtime; preview does not bind a server or start a model" });
125
- if (!value("--session-id") && !value("--session-file")) unresolved.push({ field: "sessionId", reason: "new native session identity is not allocated by preview" });
126
- }
127
- if (tool === "claude") envNames.push("AGENTHUB_CHANNEL");
128
- if (tool === "claude" || tool === "codex") {
129
- envNames.push("AGENTHUB_INSTANCE_ID", "AGENTHUB_LAUNCH_ID");
130
- unresolved.push({ field: "env.AGENTHUB_INSTANCE_ID", reason: tool === "claude" ? "injected when an existing daemon instance is read at launch; preview does not read runtime state" : "injected when a verified Orca terminal launch is recorded against the daemon; preview does not record it" });
131
- 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" });
132
- }
133
- return { tool, ...(kimiMetadata ?? {}), cmd: launch.cmd, args: launch.args, settings: tool === "claude" && launch.args.includes("--settings") ? launch.args[launch.args.indexOf("--settings") + 1] : undefined, 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."] };
16
+ const definition = definitionOf(tool), preview = builtinPreviewOf(tool);
17
+ if (!definition || !preview) throw new Error("native preview definition unavailable");
18
+ return preview({ tool, args, cwd, stateDir, cliDir: import.meta.dir, unattended, config, definition,
19
+ baseEnvNames: Object.keys(childEnv()).concat(["AGENTHUB_STATE_DIR", "AGENTHUB_PROJECT_DIR"]),
20
+ helpers: { buildLaunch, claudeObservationHooks, buildKimiLaunch, buildKimiTuiLaunch, nativeLaunchEnv, childEnv,
21
+ startModeOf, kimiMcpPlan, jcodeCommand, qwenSessionId, relayModelIds, buildPiLaunch },
22
+ });
134
23
  }
@@ -1,3 +1,4 @@
1
+ import { ADAPTERS, definitionOf } from "../adapters/registry.ts";
1
2
  import { recordedFileState } from "./recovery-session-file.ts";
2
3
  import { decodeRestoreReceipt } from "./upgrade-restore-state.ts";
3
4
  import { shellQuote } from "./terminal-recovery.ts";
@@ -25,11 +26,13 @@ export interface RecoveryGuidanceOptions {
25
26
 
26
27
  const text = (value: unknown): value is string => typeof value === "string" && value.length > 0;
27
28
  const argv = (...words: string[]): string => words.map(shellQuote).join(" ");
28
- const peers: readonly RecoveryNativePeer[] = ["codex", "pi", "claude"];
29
+ const peers = (): RecoveryNativePeer[] => ADAPTERS.filter(adapter => adapter.session.launchRecord)
30
+ .sort((a, b) => Number(a.transport === "claude-channel") - Number(b.transport === "claude-channel"))
31
+ .map(adapter => adapter.kind as RecoveryNativePeer);
29
32
 
30
33
  function sourceSession(peer: RecoveryNativePeer, source: RecoveryPeer | undefined): { sessionId?: string; sessionFile?: string } {
31
- const sessionId = peer === "codex" ? source?.threadId : source?.sessionId;
32
- const sessionFile = peer === "pi" ? source?.sessionFile ?? source?.args?.sessionFile : undefined;
34
+ const sessionId = source?.[definitionOf(peer)?.session.idField ?? "sessionId"];
35
+ const sessionFile = definitionOf(peer)?.session.crashIdentity ? source?.sessionFile ?? source?.args?.sessionFile : undefined;
33
36
  return { ...(text(sessionId) ? { sessionId } : {}), ...(text(sessionFile) ? { sessionFile } : {}) };
34
37
  }
35
38
 
@@ -70,7 +73,7 @@ export function resolveAcceptedBinding(planned: PlannedProject, progress: Projec
70
73
  * current receipts and the original plan. A malformed current receipt otherwise stays unavailable.
71
74
  */
72
75
  export function recordedPeerGuidance(planned: PlannedProject, progress: ProjectProgress, options: RecoveryGuidanceOptions = {}): RecordedPeerGuidance[] {
73
- return peers.map(peer => {
76
+ return peers().map(peer => {
74
77
  let session: { sessionId?: string; sessionFile?: string } | undefined;
75
78
  let unavailable = false;
76
79
  const override = options.recorded && Object.hasOwn(options.recorded, peer) ? options.recorded[peer] : undefined;
@@ -82,18 +85,19 @@ export function recordedPeerGuidance(planned: PlannedProject, progress: ProjectP
82
85
  } catch { unavailable = true; }
83
86
  }
84
87
  const sessionId = text(session?.sessionId) ? session.sessionId : undefined;
85
- const sessionFile = peer === "pi" && text(session?.sessionFile) ? session.sessionFile : undefined;
88
+ const sessionFile = definitionOf(peer)?.session.crashIdentity && text(session?.sessionFile) ? session.sessionFile : undefined;
86
89
  if (unavailable || (!sessionId && !sessionFile)) return { peer, note: `${peer}: recorded session identity unavailable; inspect the retained recovery receipt before choosing a conversation` };
87
90
  const prefix = ["ahub", "--project", planned.project.root];
88
- const command = peer === "codex"
89
- ? sessionId ? argv(...prefix, "codex", "resume", sessionId) : undefined
90
- : peer === "pi"
91
- ? sessionFile ? argv(...prefix, "pi", "--session-file", sessionFile) : sessionId ? argv(...prefix, "pi", "--session-id", sessionId) : undefined
92
- : sessionId ? argv(...prefix, "claude", "--resume", sessionId) : undefined;
93
- let note = peer === "claude"
91
+ const adapter = definitionOf(peer)!;
92
+ const tail = adapter.session.resumeArgv?.({ sessionId: sessionId ?? "", ...(sessionFile ? { sessionFile } : {}) }, false);
93
+ const start = adapter.cli.startWords("tui");
94
+ // Person guidance preserves its legacy implied startup form; terminal creation pins the explicit form.
95
+ const personTail = tail && start.every((word, index) => tail[index] === word) ? [tail[0]!, ...tail.slice(start.length)] : tail;
96
+ const command = personTail ? argv(...prefix, ...personTail) : undefined;
97
+ let note = definitionOf(peer)?.transport === "claude-channel"
94
98
  ? "The existing Claude channel may reconnect while its native session remains open. Transcript availability is unknown; the recorded resume command is guidance, not proof that the transcript can be resumed."
95
- : peer === "codex" ? "Recorded Codex thread; leave the old native client before resuming this conversation."
96
- : "Recorded Pi conversation; preserve this session when starting Pi again.";
99
+ : definitionOf(peer)?.session.idField === "threadId" ? `Recorded ${definitionOf(peer)!.displayName} thread; leave the old native client before resuming this conversation.`
100
+ : `Recorded ${definitionOf(peer)!.displayName} conversation; preserve this session when starting ${definitionOf(peer)!.displayName} again.`;
97
101
  if (sessionFile && options.stopAndArchiveCommand && recordedFileState(sessionFile) === "missing") {
98
102
  note = `Recorded Pi session file is positively missing. Fresh-session is not supported for Pi. ${piArchiveExit(planned, options.stopAndArchiveCommand)}`;
99
103
  }
package/src/cli/setup.ts CHANGED
@@ -1,80 +1,2 @@
1
- import { readFileSync } from "node:fs";
2
- import { join } from "node:path";
3
- import { realPath } from "../hub/project.ts";
4
- import { VERSION } from "../version.ts";
5
-
6
- export const PLUGIN = "agent-hub@agent-hub";
7
- const MARKETPLACE = "agent-hub";
8
-
9
- export interface InstalledPlugin {
10
- id: string;
11
- version?: string;
12
- installPath?: string;
13
- }
14
- export interface Marketplace {
15
- name: string;
16
- path?: string;
17
- }
18
-
19
- /** `claude plugin list --json` / `claude plugin marketplace list --json`. Anything that does not parse is an empty list. */
20
- export function parseList<T>(json: string): T[] {
21
- try {
22
- const d = JSON.parse(json);
23
- return Array.isArray(d) ? d : [];
24
- } catch {
25
- return [];
26
- }
27
- }
28
-
29
- const real = (p: string) => {
30
- try {
31
- return realPath(p);
32
- } catch {
33
- return p;
34
- }
35
- };
36
- const read = (p: string) => {
37
- try {
38
- return readFileSync(p, "utf8");
39
- } catch {
40
- return undefined;
41
- }
42
- };
43
-
44
- export type PluginState = { state: "missing" } | { state: "current"; version: string } | { state: "stale"; version: string; why: string };
45
-
46
- /**
47
- * Is the installed Claude plugin the one this hub ships? The version is compared, and so is the bundle itself: Claude
48
- * Code runs a cached copy, and a copy taken from an earlier build of the same version speaks an older wire protocol.
49
- */
50
- export function pluginState(plugins: InstalledPlugin[], packageRoot: string): PluginState {
51
- const mine = plugins.find((p) => p.id === PLUGIN);
52
- if (!mine) return { state: "missing" };
53
- const version = mine.version ?? "unknown";
54
- if (version !== VERSION) return { state: "stale", version, why: `version ${version}, this hub is ${VERSION}` };
55
- const shipped = read(join(packageRoot, "plugins", "agent-hub", "server.js"));
56
- const cached = mine.installPath ? read(join(mine.installPath, "server.js")) : undefined;
57
- if (shipped !== undefined && cached !== undefined && shipped !== cached) return { state: "stale", version, why: "same version, but the cached bundle differs from the one this hub ships" };
58
- return { state: "current", version };
59
- }
60
-
61
- export interface SetupStep {
62
- why: string;
63
- argv: string[];
64
- }
65
-
66
- /**
67
- * The next thing `ahub setup` has to do, or nothing. One step at a time: the state is read again after every step,
68
- * because what a step leaves behind is Claude Code's business (removing a marketplace may uninstall its plugins).
69
- */
70
- export function nextStep(plugins: InstalledPlugin[], marketplaces: Marketplace[], packageRoot: string): SetupStep | undefined {
71
- const root = real(packageRoot);
72
- const market = marketplaces.find((m) => m.name === MARKETPLACE);
73
- if (market && real(market.path ?? "") !== root) return { why: `the ${MARKETPLACE} marketplace points at ${market.path ?? "another source"}, not at this package`, argv: ["claude", "plugin", "marketplace", "remove", MARKETPLACE] };
74
- if (!market) return { why: "register this package as a Claude Code plugin marketplace", argv: ["claude", "plugin", "marketplace", "add", root] };
75
- const state = pluginState(plugins, root);
76
- if (state.state === "missing") return { why: "install the channel plugin", argv: ["claude", "plugin", "install", PLUGIN] };
77
- // Uninstall rather than update: with an unchanged version number an update has nothing to do.
78
- if (state.state === "stale") return { why: `replace the installed plugin (${state.why})`, argv: ["claude", "plugin", "uninstall", PLUGIN] };
79
- return undefined;
80
- }
1
+ /** Compatibility exports; native plugin policy belongs to its adapter definition leaf. */
2
+ export { PLUGIN, parseList, pluginState, nextStep, type InstalledPlugin, type Marketplace, type PluginState, type SetupStep } from "../adapters/definitions/native-cli-claude-setup.ts";
@@ -0,0 +1,3 @@
1
+ export function shellQuote(value: string): string {
2
+ return `'${value.replace(/'/g, `'\\''`)}'`;
3
+ }
@@ -1,3 +1,6 @@
1
+ import { shellQuote } from "./shell-quote.ts";
2
+ export { shellQuote } from "./shell-quote.ts";
3
+ import { definitionOf } from "../adapters/registry.ts";
1
4
  import { isAbsolute, join, resolve } from "node:path";
2
5
  import { fileURLToPath } from "node:url";
3
6
  import { existsSync, mkdirSync, readFileSync } from "node:fs";
@@ -231,9 +234,7 @@ export class OrcaCommandError extends Error {
231
234
  }
232
235
 
233
236
  /** POSIX quoting for the single command-string argument accepted by Orca. */
234
- export function shellQuote(value: string): string {
235
- return `'${value.replace(/'/g, `'\\''`)}'`;
236
- }
237
+
237
238
 
238
239
  function normalizeOptions(options?: CommandRunner | TerminalRecoveryOptions): Required<TerminalRecoveryOptions> {
239
240
  const config: TerminalRecoveryOptions = typeof options === "function" ? { runner: options } : options ?? {};
@@ -474,7 +475,7 @@ function identityFrom(terminal: Record<string, unknown>): TerminalPeer | undefin
474
475
  const session = object(terminal.session);
475
476
  const candidates = [terminal.agentIdentity, terminal.agent, wait.agentIdentity, wait.agent, session.agentIdentity, session.agent];
476
477
  for (const candidate of candidates) {
477
- if (candidate === "codex" || candidate === "claude" || candidate === "pi") return candidate;
478
+ if (typeof candidate === "string" && definitionOf(candidate)?.session.launchRecord) return candidate as TerminalPeer;
478
479
  }
479
480
  return undefined;
480
481
  }
@@ -504,13 +505,17 @@ function requireAbsoluteEntrypoint(value: string): string {
504
505
 
505
506
  function launchFor(peer: TerminalPeer, projectRoot: string, sessionId: string, options: Required<TerminalRecoveryOptions>, source: Record<string, unknown>, recorded?: RecordedTerminalLaunch, ref?: SessionRef): LaunchMetadata {
506
507
  const packageEntrypoint = requireAbsoluteEntrypoint(options.packageEntrypoint);
507
- const argv = peer === "codex"
508
- ? ["bun", packageEntrypoint, "--project", projectRoot, "codex", "resume", sessionId]
509
- : peer === "claude"
510
- ? ["bun", packageEntrypoint, "--project", projectRoot, "claude", "--resume", sessionId]
511
- : ["bun", packageEntrypoint, "--project", projectRoot, "pi", "--mode", "tui", ...(ref?.backend ? ["--backend", ref.backend] : []), ...(ref?.model ? ["--model", ref.model] : []), ...(ref?.sessionFile ? ["--session-file", ref.sessionFile] : ["--session-id", sessionId])];
512
508
  const env = { ...allowedEnv(source), ...(recorded?.env ?? {}) };
513
509
  const prefix = ALLOWED_ENV.filter((name) => env[name] !== undefined).map((name) => `${name}=${shellQuote(env[name]!)}`);
510
+ const launch: LaunchMetadata = { packageEntrypoint, argv: ["bun", packageEntrypoint, "--project", projectRoot], env, command: "" };
511
+ launch.command = [...prefix, ...launch.argv.map(shellQuote)].join(" ");
512
+ // Identity and environment were independently verified before this pure two-phase launch construction.
513
+ const binding: TerminalBinding = { peer, projectRoot, sessionId, handle: nestedString(source, ["handle"])!,
514
+ incarnationId: nestedString(source, ["incarnationId"])!, worktreeId: nestedString(source, ["worktreeId"])!,
515
+ ...(ref?.sessionFile ? { sessionFile: ref.sessionFile } : {}), ...(ref?.backend ? { backend: ref.backend } : {}), ...(ref?.model ? { model: ref.model } : {}), launch, launchMetadata: launch };
516
+ const tail = definitionOf(peer)?.session.resumeArgv?.(binding, false);
517
+ if (!tail) throw new Error(`${peer}: no recorded terminal resume adapter`);
518
+ const argv = [...launch.argv, ...tail];
514
519
  const command = [...prefix, ...argv.map(shellQuote)].join(" ");
515
520
  return { packageEntrypoint, command, argv, env };
516
521
  }
@@ -669,7 +674,9 @@ export async function inspectTerminals(projectRoot: string, sessions: Partial<Re
669
674
  continue;
670
675
  }
671
676
  const recorded = records.find((item) => item.peer === peer && item.handle === handle && item.worktreeId === terminal.worktreeId && item.incarnationId === terminal.incarnationId);
672
- const sessionFile = peer === "pi" ? nestedString(terminal, ["sessionFile", "sessionPath"]) ?? nestedString(object(terminal.session), ["file", "path"]) : undefined;
677
+ // Orca observation supplies file identity; launch arguments are not observed native session-file evidence.
678
+ const { args: _launchArgs, ...observedTerminal } = terminal;
679
+ const sessionFile = definitionOf(peer)?.session.crashIdentity?.(observedTerminal).sessionFile;
673
680
  const launch = launchFor(peer, projectRoot, sessionId, config, terminal, recorded, { ...ref, ...(sessionFile ? { sessionFile } : {}) });
674
681
  const binding: TerminalBinding = { peer, handle, incarnationId, worktreeId, projectRoot, sessionId, ...(sessionFile ? { sessionFile } : {}), ...(ref.backend ? { backend: ref.backend } : {}), ...(ref.model ? { model: ref.model } : {}), launch, launchMetadata: launch };
675
682
  bindings.push(binding);
@@ -1,3 +1,4 @@
1
+ import { definitionOf } from "../adapters/registry.ts";
1
2
  import { renderPlan, renderOperation, renderBlocked, renderSummary, renderStep, renderWait, renderMenu, choiceInstruction, type RecoveryPresentationOptions, type MenuPresentationItem } from "./upgrade-presentation.ts";
2
3
  import { quoted, wrap } from "./console-state.ts";
3
4
  import { operationLines, stepLabel } from "./upgrade-labels.ts";
@@ -83,7 +84,8 @@ const left = (op: { id: string; sourceRoot?: string }, io: ScreenIO): string =>
83
84
  export function peerKind(planned: PlannedProject, peer: RecoveryPeer): "tui" | "headless" | "unmanaged" | "offline" {
84
85
  if (peer.state === "offline") return "offline";
85
86
  if ((planned.terminals as { peer: string }[]).some((t) => t.peer === peer.id)) return "tui";
86
- if (peer.id === "claude" || peer.id === "codex" || (peer.id === "pi" && peer.args?.mode === "tui")) return "unmanaged";
87
+ const unrecorded = definitionOf(peer.id)?.session.unrecordedTerminal;
88
+ if (unrecorded === "always" || unrecorded === "tui" && peer.args?.mode === "tui") return "unmanaged";
87
89
  return "headless";
88
90
  }
89
91
 
@@ -93,7 +95,7 @@ function peerAction(planned: PlannedProject, peer: RecoveryPeer): string {
93
95
  if (planned.freshStart?.includes(peer.id)) return "restarts as a new session (no turn to lose)";
94
96
  const terminal = (planned.terminals as { peer: string; handle: string }[]).find((t) => t.peer === peer.id);
95
97
  if (terminal) return `resumes its session in a new terminal (replaces ${terminal.handle})`;
96
- return peer.id === "pi" ? "restarts headless on its recorded session" : "restarts headless as a new session";
98
+ return definitionOf(peer.id)?.session.restoreOnStart ? "restarts headless on its recorded session" : "restarts headless as a new session";
97
99
  }
98
100
 
99
101
  const KINDS = { tui: "TUI", headless: "headless", unmanaged: "own", offline: "-" } as const;
@@ -1,3 +1,4 @@
1
+ import { definitionOf } from "../adapters/registry.ts";
1
2
  import type { TerminalBinding } from "./terminal-recovery.ts";
2
3
 
3
4
  export type RestoreReceipt =
@@ -23,7 +24,7 @@ function malformed(): never { throw new Error("invalid restore receipt"); }
23
24
  const object = (value: unknown): value is Record<string, unknown> => !!value && typeof value === "object" && !Array.isArray(value);
24
25
  const text = (value: unknown): value is string => typeof value === "string" && value.length > 0;
25
26
  function terminalBinding(value: unknown): TerminalBinding {
26
- if (!object(value) || typeof value.peer !== "string" || !["codex", "claude", "pi"].includes(value.peer)) malformed();
27
+ if (!object(value) || typeof value.peer !== "string" || !definitionOf(value.peer)?.session.launchRecord) malformed();
27
28
  for (const key of ["handle", "incarnationId", "worktreeId", "projectRoot", "sessionId"]) if (!text(value[key])) malformed();
28
29
  for (const key of ["sessionFile", "backend", "model"]) if (value[key] !== undefined && typeof value[key] !== "string") malformed();
29
30
  const launch = value.launch;