@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
package/src/cli/launch.ts CHANGED
@@ -6,6 +6,8 @@ import { resolve, join, basename, dirname, isAbsolute } from "node:path";
6
6
  import { processLiveness, processSignature } from "../pi/process-signature.ts";
7
7
  import { realPath } from "../hub/project.ts";
8
8
  import { peerChildEnv, processTable } from "../hub/child-process.ts";
9
+ import { definitionOf } from "../adapters/registry.ts";
10
+ import { kimiAcpCommand } from "../adapters/definitions/native-daemon-kimi.ts";
9
11
  export const CLAUDE_CHANNEL = "plugin:agent-hub@agent-hub";
10
12
 
11
13
  /** Launch identity belongs to the native child, never to the agent that invoked the wrapper. */
@@ -13,31 +15,12 @@ export function nativeLaunchEnv(tool: "claude" | "codex" | "pi" | "kimi", source
13
15
  const env = peerChildEnv(tool, source);
14
16
  delete env.AGENTHUB_UNATTENDED;
15
17
  // Channel evidence for the plugin (issue #205): set beside the development-channel flag `buildLaunch` always passes.
16
- if (tool === "claude") env.AGENTHUB_CHANNEL = "1";
17
- if (tool === "kimi") delete env.AGENTHUB_MODE; // its MCP child must attach pull-only, never as a tools socket
18
+ const definition = definitionOf(tool);
19
+ if (definition?.identity.mcp === "channel-plugin") env.AGENTHUB_CHANNEL = "1";
20
+ if (definition?.attach?.pullOnlyForms?.includes("tui")) delete env.AGENTHUB_MODE; // its MCP child must attach pull-only, never as a tools socket
18
21
  return env;
19
22
  }
20
23
 
21
- /** A scoped candidate MCP server can be selected without promoting the installed plugin. */
22
- function claudeChannel(userArgs: string[]): string {
23
- const bundle = resolve(import.meta.dir, "../../plugins/agent-hub/server.js");
24
- for (let i = 0; i < userArgs.length; i++) {
25
- const arg = userArgs[i]!;
26
- const path = arg === "--mcp-config" ? userArgs[i + 1] : arg.startsWith("--mcp-config=") ? arg.slice("--mcp-config=".length) : undefined;
27
- if (!path) continue;
28
- try {
29
- const server = JSON.parse(readFileSync(resolve(path), "utf8")).mcpServers?.["agent-hub"];
30
- if (server?.command === "bun" && Array.isArray(server.args) && server.args.some((value: unknown) => typeof value === "string" && resolve(value) === bundle)) return "server:agent-hub";
31
- } catch { /* The native CLI reports invalid user-supplied MCP configuration. */ }
32
- }
33
- return CLAUDE_CHANNEL;
34
- }
35
-
36
- const OWNED: Record<"claude" | "codex", string[]> = {
37
- claude: ["--dangerously-load-development-channels", "--dangerously-skip-permissions"],
38
- codex: ["--remote", "--yolo", "--dangerously-bypass-approvals-and-sandbox"],
39
- };
40
-
41
24
  export const UNATTENDED_WARNING =
42
25
  "WARNING: --unattended disables permission prompts. Other agents' messages are untrusted input and this agent will act on its own judgment without asking you.";
43
26
 
@@ -308,89 +291,20 @@ export function buildLaunch(
308
291
  userArgs: string[],
309
292
  ctx: { unattended: boolean; proxyUrl?: string; codexBin?: string; statusLine?: StatusLineTee; facts?: FactsHook; preview?: boolean },
310
293
  ): Launch {
311
- // Hub-level switches are consumed here; everything else passes through to the tool.
312
- const passthrough = userArgs.filter((a) => !["--unattended", "--safe", "--new"].includes(a));
313
- const dup = passthrough.find((a) => OWNED[tool].some((flag) => a === flag || a.startsWith(`${flag}=`)));
314
- if (dup) throw new Error(`${dup} is managed by the hub; use "hub ${tool} --unattended" or drop the flag`);
315
-
316
- const unattended = ctx.unattended || userArgs.includes("--unattended");
317
- const warning = unattended ? { warning: UNATTENDED_WARNING } : {};
318
- if (tool === "claude") {
319
- // `--settings` takes one value, so a user-supplied one wins and Claude has no quota source for that session.
320
- const own = passthrough.some((a) => a === "--settings" || a.startsWith("--settings="));
321
- let settingsArgs = ctx.statusLine && !own ? ["--settings", sessionSettings(ctx.statusLine, ctx.facts)] : [];
322
- let nativeArgs = passthrough;
323
- let settingsFile: string | undefined;
324
- if (own) {
325
- if (passthrough.filter(a => a === "--settings" || a.startsWith("--settings=")).length > 1) throw new Error("pass --settings only once so the hub permission hook stays installed");
326
- const index = passthrough.findIndex(a => a === "--settings" || a.startsWith("--settings="));
327
- const arg = passthrough[index]!;
328
- const value = arg === "--settings" ? passthrough[index + 1] : arg.slice("--settings=".length);
329
- if (!value) throw new Error("--settings requires a JSON object or readable file so the hub permission hook can be installed");
330
- let caller: Record<string, unknown>;
331
- try {
332
- caller = JSON.parse(value.trim().startsWith("{") ? value : readFileSync(resolve(value), "utf8"));
333
- if (!caller || typeof caller !== "object" || Array.isArray(caller)) throw new Error("not an object");
334
- } catch { throw new Error("cannot read --settings as a JSON object; the hub permission hook must be installed"); }
335
- if (ctx.facts && caller.disableAllHooks === true) throw new Error("--settings disableAllHooks prevents the required hub permission hook; enable hooks to launch");
336
- const injected = JSON.parse(sessionSettings(undefined, ctx.facts));
337
- const callerHooks = caller.hooks;
338
- if (callerHooks !== undefined && (!callerHooks || typeof callerHooks !== "object" || Array.isArray(callerHooks))) throw new Error("--settings hooks must be an object");
339
- const hooks = { ...(callerHooks as Record<string, unknown> | undefined) };
340
- for (const [event, entries] of Object.entries(injected.hooks ?? {})) {
341
- const existing = hooks[event];
342
- if (existing !== undefined && !Array.isArray(existing)) throw new Error(`--settings ${event} hooks must be an array`);
343
- hooks[event] = [...(existing as unknown[] | undefined ?? []), ...(entries as unknown[])];
344
- }
345
- settingsArgs = ["--settings", JSON.stringify({ ...caller, ...(ctx.facts ? { hooks } : {}) })];
346
- nativeArgs = passthrough.filter((_, i) => i !== index && (arg !== "--settings" || i !== index + 1));
347
- } else if (ctx.facts && !ctx.statusLine) {
348
- const injected = JSON.parse(sessionSettings(undefined, ctx.facts));
349
- settingsArgs = ["--settings", JSON.stringify({ hooks: injected.hooks })];
350
- }
351
- if (settingsArgs.length && !ctx.preview) {
352
- const stateDir = ctx.facts?.stateDir ?? ctx.statusLine?.stateDir;
353
- if (!stateDir) throw new Error("Claude session settings require the hub state directory");
354
- const file = join(stateDir, `claude-settings-${randomUUID()}.json`);
355
- try {
356
- mkdirSync(stateDir, { recursive: true, mode: 0o700 });
357
- writeFileSync(`${file}.tmp`, settingsArgs[1]!, { mode: 0o600, flag: "wx" });
358
- renameSync(`${file}.tmp`, file);
359
- } catch {
360
- try { unlinkSync(`${file}.tmp`); } catch { /* no temp file was written */ }
361
- throw new Error("cannot write private Claude session settings in the hub state directory");
362
- }
363
- settingsArgs[1] = file;
364
- settingsFile = file;
365
- }
366
- const notes = [
367
- unattended ? UNATTENDED_WARNING : "",
368
- ctx.statusLine && own ? "note: you passed --settings, so the hub's status line tee is off and the budget coordinator cannot see Claude's quota (ahub budget set claude <0..1> still works)." : "",
369
- ].filter(Boolean);
370
- return {
371
- cmd: "claude",
372
- args: ["--dangerously-load-development-channels", claudeChannel(passthrough), ...(unattended ? ["--dangerously-skip-permissions"] : []), ...settingsArgs, ...nativeArgs],
373
- ...(settingsFile ? { settingsFile } : {}),
374
- permissionHook: !!ctx.facts,
375
- ...(ctx.facts ? { hookPurpose: ctx.facts.purpose ?? "facts" } : {}),
376
- unattended,
377
- ...(notes.length ? { warning: notes.join("\n") } : {}),
378
- };
379
- }
380
- if (!ctx.proxyUrl) throw new Error("codex proxy url is missing");
381
- return {
382
- cmd: ctx.codexBin ?? "codex",
383
- args: ["--enable", "tui_app_server", "--remote", ctx.proxyUrl, ...(unattended ? ["--dangerously-bypass-approvals-and-sandbox"] : []), ...passthrough],
384
- unattended,
385
- ...warning,
386
- };
294
+ const adapter = definitionOf(tool);
295
+ if (!adapter) throw new Error("native launch definition unavailable");
296
+ const { metadata: _metadata, ...launch } = adapter.start.launch({
297
+ instance: tool, form: "tui", projectRoot: process.cwd(), stateDir: ctx.facts?.stateDir ?? ctx.statusLine?.stateDir ?? "",
298
+ entrypoint: resolve(import.meta.dir, "main.js"), userArgs, env: {}, ...ctx,
299
+ });
300
+ return launch;
387
301
  }
388
302
 
389
303
 
390
304
  /** The daemon and preview use exactly the same ACP command insertion. */
391
305
  export function buildKimiLaunch(command: string[], model?: string): Launch {
392
- const [cmd, ...args] = command;
393
- return { cmd: cmd!, args: model ? ["--model", model, ...args] : args };
306
+ const [cmd, ...args] = kimiAcpCommand(command, model);
307
+ return { cmd: cmd!, args };
394
308
  }
395
309
 
396
310
  export { buildQwenLaunch } from "../adapters/definitions/qwen-native.ts";
package/src/cli/main.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env bun
2
- import { definitionOf } from "../adapters/registry.ts";
2
+ import { BUILTIN_CLI_ENTRIES, definitionOf, DOCTOR_TOOL_PROBES, DOCTOR_MEMORY_CAPTURE_PEERS, DOCTOR_PLUGIN_PROBE, nativeDoctorCheck, nativeDoctorRecoveryIdentity, nativeDoctorOptionalProbes } from "../adapters/registry.ts";
3
+ import { parsePiFlags } from "../adapters/definitions/native-cli-pi.ts";
3
4
  import { supportsBoardReleaseFilter, supportsBoardColumnMetadata } from "./board-read.ts";
4
5
  import { matchesPolicy } from "../hub/path-policy.ts";
5
6
  import { projectedDiff } from "../hub/projected-diff.ts";
@@ -16,7 +17,6 @@ import { configureJcodeMcp } from "./jcode-mcp.ts";
16
17
  import { qwenSessionId } from "../hub/crash.ts";
17
18
  import { isPermissionMode } from "../hub/permission-mode.ts";
18
19
  import { parseSettingText, settingDef, settingText, type SettingRow } from "../hub/settings.ts";
19
- import { isSupportedPeer } from "../hub/peer-catalog.ts";
20
20
  import { startModeOf } from "../hub/start-mode.ts";
21
21
  import { currentRouting } from "../hub/routing.ts";
22
22
  import { spawn, spawnSync } from "node:child_process";
@@ -104,7 +104,7 @@ const [given = "help", ...rest] = argv;
104
104
  // `ahub <command> -h` alone asks for that command's help instead of running it. Elsewhere `-h` may be message text
105
105
  // (`ahub say try ls -h`), and claude and codex pass their arguments on to the agent.
106
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;
107
+ const cmd = asksHelp(given) || (definitionOf(given)?.start.passthrough !== true && rest.length === 1 && asksHelp(rest[0])) ? "help" : given;
108
108
  const args = cmd === "help" && given !== "help" && !asksHelp(given) ? [given] : rest;
109
109
  let selected: { root: string; stateDir: string };
110
110
  try {
@@ -233,32 +233,7 @@ function execWithEnv(bin: string, argv: string[], extra: NodeJS.ProcessEnv): nev
233
233
  }
234
234
 
235
235
  /** `mode` is absent when no flag chose one: the caller takes the configured start mode (#269). `--headless` is `--mode headless`. */
236
- function piFlags(): { mode?: "headless" | "tui"; backend?: "auto" | "dgx" | "mlx"; model?: string; sessionId?: string; sessionFile?: string; replaceSession?: boolean } {
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
- }
236
+ const piFlags = () => parsePiFlags(args, fail);
262
237
 
263
238
  async function projectRows() {
264
239
  const registry = new Registry();
@@ -577,7 +552,7 @@ async function upgrade(kind: "restart" | "upgrade"): Promise<void> {
577
552
 
578
553
  /** Strict person command: recorded identity only, no session or terminal overrides. */
579
554
  function parseRecoveryReattach(argv: readonly string[]): { id: string; peer: TerminalPeer } | undefined {
580
- if (argv.length !== 3 || argv[0] !== "reattach" || !operationIdPattern.test(argv[1]!) || !["codex", "claude", "pi"].includes(argv[2]!)) return undefined;
555
+ if (argv.length !== 3 || argv[0] !== "reattach" || !operationIdPattern.test(argv[1]!) || definitionOf(argv[2]!)?.session.launchRecord !== true) return undefined;
581
556
  return { id: argv[1]!, peer: argv[2] as TerminalPeer };
582
557
  }
583
558
 
@@ -827,220 +802,17 @@ const commands: Record<string, () => Promise<void> | void> = {
827
802
  if (process.stdin.isTTY && process.stdout.isTTY && !args.includes("--no-console")) await runConsole({ client: await connect(), cwd, stateDir, liveTest });
828
803
  },
829
804
 
830
- claude: async () => {
831
- if (args.includes("--print-command") || args.includes("--dry-run")) {
832
- try { return console.log(JSON.stringify(launcherPreview("claude", args, cwd, stateDir, unattendedEnv), null, 2)); }
833
- catch { fail("cannot preview launch: invalid arguments or configuration (details withheld to protect credentials)"); }
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");
1001
- const hub = await connect();
1002
- try {
1003
- const reply = await hub.request({ t: "peer_start", peer: "pi" }, 35_000);
1004
- if (reply.ok !== true) {
1005
- const error = String(reply.error ?? "pi start refused");
1006
- 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);
1007
- }
1008
- 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 "..."');
1009
- } finally { hub.close(); }
1010
- }
1011
- // #215: as for Codex, a TUI launch by the recovery operation holding the lock records itself before the hub round
1012
- // trip, so a resume in that window never reads it as gone; an ordinary launch records only after the hub accepted it.
1013
- const recovering = options.mode === "tui" && !!process.env.AGENTHUB_RECOVERY_OPERATION && process.env.AGENTHUB_RECOVERY_OPERATION === recoveryLock();
1014
- const before = readControl(stateDir);
1015
- if (recovering && before?.instanceId) await recordTerminalLaunch("pi", cwd, stateDir, before.instanceId);
1016
- const hub = await connect();
1017
- const res = await hub.request({ t: "start", peer: "pi", args: options, operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
1018
- hub.close();
1019
- if (!res.ok) fail(res.error);
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
- },
805
+ ...Object.fromEntries(BUILTIN_CLI_ENTRIES.map(entry => [entry.command, async () => {
806
+ // Preserve the original guard before native effects; previews remain read-only and precede this guard.
807
+ if (entry.lifecycleAdmission && !args.includes("--print-command") && !args.includes("--dry-run")) assertLifecycleAvailable();
808
+ return entry.execute({ args, cwd, stateDir, cliDir: import.meta.dir, unattendedEnv, definition: entry.definition,
809
+ projectConfig, connect, fail, exec, execWithEnv, takeFlags,
810
+ helpers: { launcherPreview, readControl, recordTerminalLaunch, recoveryLock, processSignature,
811
+ buildLaunch, claudeObservationHooks, recordClaudeLaunch, cleanupStaleClaudeSettings, cleanupClaudeSettings,
812
+ nativeLaunchEnv, runClaudeLaunch, childEnv, qwenSessionId, configureJcodeMcp, configureKimiMcp,
813
+ buildKimiTuiLaunch, startModeOf },
814
+ });
815
+ }])),
1044
816
 
1045
817
  models: async () => {
1046
818
  const action = outputArguments()[0] ?? "status";
@@ -1430,7 +1202,7 @@ const commands: Record<string, () => Promise<void> | void> = {
1430
1202
  roster: async () => {
1431
1203
  const options = outputOptions(), flags = args.filter(arg => arg.startsWith("--")), positional = outputArguments().filter(arg => arg !== "--yes" && arg !== "--now");
1432
1204
  const [sub = "list", peer, ...extra] = positional;
1433
- if (!["list", "add", "remove"].includes(sub) || extra.length || (sub === "list" ? peer !== undefined : !isSupportedPeer(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]");
1205
+ if (!["list", "add", "remove"].includes(sub) || extra.length || (sub === "list" ? peer !== undefined : !definitionOf(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
1206
  const hub = await connect();
1435
1207
  try {
1436
1208
  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; };
@@ -1934,7 +1706,7 @@ const commands: Record<string, () => Promise<void> | void> = {
1934
1706
  }
1935
1707
  if (["assign", "clear"].includes(args[0] ?? "")) {
1936
1708
  const [op, peer, id] = args;
1937
- if (!peer || !isSupportedPeer(peer) || !id && op === "assign" || args.length > 3) fail("usage: ahub role assign <peer> <id> | clear <peer> [<id>]");
1709
+ if (!peer || !definitionOf(peer ?? "") || !id && op === "assign" || args.length > 3) fail("usage: ahub role assign <peer> <id> | clear <peer> [<id>]");
1938
1710
  const hub = await connect();
1939
1711
  try {
1940
1712
  const view = await hub.request({ t: "settings_get" }); if (!view.ok) fail(view.error);
@@ -1955,15 +1727,7 @@ const commands: Record<string, () => Promise<void> | void> = {
1955
1727
  return res.status === 0 ? res.stdout.trim().split("\n")[0]! : undefined;
1956
1728
  };
1957
1729
  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
- };
1730
+ const row = (ok: boolean | undefined, name: string, detail: string) => { checks.push(nativeDoctorCheck(ok, name, detail)); };
1967
1731
 
1968
1732
  try {
1969
1733
  const catalog = loadRoleCatalog(cwd);
@@ -1972,7 +1736,7 @@ const commands: Record<string, () => Promise<void> | void> = {
1972
1736
  const config = loadConfig(cwd), assigned = catalogAssignments(config.catalog_roles);
1973
1737
  for (const peer of Object.keys(assigned)) selectedRoles(peer, config.roles, assigned, catalog);
1974
1738
  } catch { row(false, "catalog roles", "invalid assignment id, shape or flow base"); }
1975
- for (const bin of ["bun", "claude", "codex", "kimi"]) {
1739
+ for (const bin of DOCTOR_TOOL_PROBES) {
1976
1740
  const v = version(bin);
1977
1741
  row(!!v, bin, v ?? "not found on PATH");
1978
1742
  }
@@ -1986,26 +1750,24 @@ const commands: Record<string, () => Promise<void> | void> = {
1986
1750
  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
1751
  const observed = await hub.request({ t: "recovery", op: "inspect", expectedInstanceId: status.instanceId });
1988
1752
  for (const peer of Object.values(observed.recovery?.peers ?? {}) as { id: string; state: string; sessionId?: string; threadId?: string }[]) {
1989
- if (!["claude", "codex"].includes(peer.id) || peer.state === "offline") continue;
1990
- const identity = peer.id === "claude" ? peer.sessionId : peer.threadId;
1991
- row(!!identity, `${peer.id} recovery ID`, identity ? "recorded" : `missing; verify the current conversation and reconnect with ahub ${peer.id} before upgrading`);
1753
+ const check = nativeDoctorRecoveryIdentity(peer);
1754
+ if (check) row(check.ok, check.name, check.detail);
1992
1755
  }
1993
1756
  } finally { hub.close(); }
1994
1757
  }
1995
- const plugin = pluginState(parseList<InstalledPlugin>(spawnSync("claude", ["plugin", "list", "--json"], { encoding: "utf8" }).stdout ?? ""), join(import.meta.dir, "..", ".."));
1996
- row(plugin.state === "current", "claude plugin", 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`);
1758
+ const plugin = pluginState(parseList<InstalledPlugin>(spawnSync(DOCTOR_PLUGIN_PROBE.command, [...DOCTOR_PLUGIN_PROBE.argv], { encoding: "utf8" }).stdout ?? ""), join(import.meta.dir, "..", ".."));
1759
+ 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
1760
 
1998
1761
  const config = loadConfig(cwd);
1999
1762
  row(!config.ignored, "config", config.ignored ? `${config.ignored.join("; ")} (move them to .agenthub/config.local.json)` : "no machine-local field ignored");
2000
1763
  for (const line of config.retired ?? []) row(false, "retired setting", line);
2001
- if (config.roster.jcode === true) {
2002
- row(verifiedJcodeCommand(config.jcode_cmd, cwd), "jcode", "CLI/source identity only; provider, project MCP and native teardown require separate receipts; native policy unmanaged");
1764
+ for (const probe of nativeDoctorOptionalProbes(config)) {
1765
+ if (probe.kind === "source-identity") row(verifiedJcodeCommand(probe.command, cwd), probe.name, probe.detail);
1766
+ else {
1767
+ const v = version(probe.command[0]!);
1768
+ row(v === probe.expected, probe.name, v === probe.expected ? probe.confirmed : probe.unconfirmed);
1769
+ }
2003
1770
  }
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
1771
  const omni = new OmniRoute(config.omniroute);
2010
1772
  const gateway = await omni.base();
2011
1773
  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 +1793,7 @@ const commands: Record<string, () => Promise<void> | void> = {
2031
1793
  row(mem.ok, "memory worker", mem.ok ? `claude-mem ${mem.version ?? ""} at ${memory.url}` : `unavailable at ${memory.url} (the hub works without it)`);
2032
1794
  const bridge = spawnSync("dot", ["ai", "memory", "status"], { encoding: "utf8" });
2033
1795
  const out = bridge.error ? "" : bridge.stdout.replace(/\x1b\[[0-9;]*m/g, "");
2034
- for (const tool of ["codex", "kimi"]) {
1796
+ for (const tool of DOCTOR_MEMORY_CAPTURE_PEERS) {
2035
1797
  const ready = new RegExp(`✓\\s+${tool} ready`).test(out);
2036
1798
  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
1799
  }
@@ -2058,11 +1820,12 @@ async function runConductorCommand(): Promise<void> {
2058
1820
  input = { peer: args[0] };
2059
1821
  } else {
2060
1822
  // Native TUIs return a human launch command through the daemon; an agent shell never executes it.
2061
- if (cmd === "pi" && args.length === 1 && args[0] === "--headless") args.splice(0, 1, "--mode", "headless");
2062
- if (args.length && !(cmd === "pi" && args.length === 2 && args[0] === "--mode" && ["headless", "tui"].includes(args[1] ?? ""))) fail(`conductor starts accept no launch overrides; use ahub ${cmd}${cmd === "pi" ? " [--mode headless|tui]" : ""}`);
1823
+ const parseStart = definitionOf(cmd)?.start.conductorStartArgs;
1824
+ const parsed = parseStart ? parseStart(args) : args.length ? new Error(`conductor starts accept no launch overrides; use ahub ${cmd}`) : {};
1825
+ if (parsed instanceof Error) fail(parsed.message);
2063
1826
  op = "hub_peer_start";
2064
1827
  // #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, ...(cmd === "pi" && args[1] ? { mode: args[1] } : {}) };
1828
+ input = { peer: cmd, ...parsed };
2066
1829
  }
2067
1830
  console.log(await taskOp(op, input));
2068
1831
  }
package/src/cli/output.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { definitionOf } from "../adapters/registry.ts";
1
2
  import { boardColumns, type BoardPeerInput } from "../ui/task-board.ts";
2
3
  import { peerStyle } from "../ui/peer-palette.ts";
3
4
  import { cells, TABLES, table, fitLine, wrap, duration, relative, plural, terminalText, initialConsoleState, stateTone, type Span, type Tone } from "./console-state.ts";
@@ -234,8 +235,8 @@ export function renderStatus(data: StatusOutput, columns?: number, now = Date.no
234
235
  : flat(p.paused).replace(/^(budget: .*), resets .+$/, "$1; reset unknown");
235
236
  out.push(...detail("paused", pauseText, columns, "attention"));
236
237
  }
237
- if (p.toolsOnly) out.push(...detail(id === "kimi" && p.pullOnly ? "pull-only" : "tools-only", flat(p.toolsOnly).replace(/^(?:tools-only|pull-only):\s*/, ""), columns, "attention"));
238
- if (p.pullOnly && id === "kimi") out.push(...detail("kimi last read", typeof p.lastInboxAt === "number" ? relative(p.lastInboxAt, now) : "never", columns, "muted"));
238
+ if (p.toolsOnly) out.push(...detail(definitionOf(id)?.attach?.pullOnlyForms?.includes("tui") && p.pullOnly ? "pull-only" : "tools-only", flat(p.toolsOnly).replace(/^(?:tools-only|pull-only):\s*/, ""), columns, "attention"));
239
+ if (p.pullOnly && definitionOf(id)?.attach?.pullOnlyForms?.includes("tui")) out.push(...detail(`${id} last read`, typeof p.lastInboxAt === "number" ? relative(p.lastInboxAt, now) : "never", columns, "muted"));
239
240
  if (p.permissionNote) out.push(...detail("permission", p.permissionNote, columns, "muted"));
240
241
  if (p.requestedModel && p.servedBy && p.requestedModel !== p.servedBy) out.push(...detail("requested model", p.requestedModel, columns));
241
242
  if (typeof p.oldestQueuedAt === "number") out.push(...detail(`${id} oldest queued`, relative(p.oldestQueuedAt, now), columns, "muted"));