@ag-eco/agentplate-cli 0.13.4 → 0.14.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ag-eco/agentplate-cli",
3
- "version": "0.13.4",
3
+ "version": "0.14.1",
4
4
  "description": "Multi-agent orchestration for AI coding agents — spawn workers in git worktrees via tmux, coordinate through SQLite mail, merge with tiered conflict resolution. Pluggable runtime adapters for Claude Code, Pi, and more.",
5
5
  "author": "Jaymin West",
6
6
  "license": "MIT",
@@ -28,6 +28,7 @@ import {
28
28
  coordinatorCommand,
29
29
  createCoordinatorCommand,
30
30
  resolveAttach,
31
+ resolveHeadless,
31
32
  startCoordinatorSession,
32
33
  } from "./coordinator.ts";
33
34
  import {
@@ -682,6 +683,68 @@ describe("startCoordinator", () => {
682
683
  expect(cmd).not.toContain("--model opus");
683
684
  });
684
685
 
686
+ test("--runtime overrides the spawned runtime adapter (pi)", async () => {
687
+ const { deps, calls } = makeDeps();
688
+ const originalSleep = Bun.sleep;
689
+ Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
690
+
691
+ try {
692
+ await captureStdout(() =>
693
+ coordinatorCommand(["start", "--no-attach", "--json", "--runtime", "pi"], deps),
694
+ );
695
+ } finally {
696
+ Bun.sleep = originalSleep;
697
+ }
698
+
699
+ expect(calls.createSession).toHaveLength(1);
700
+ const cmd = calls.createSession[0]?.command ?? "";
701
+ // Pi's buildSpawnCommand emits `pi --model <provider/model>`; the default
702
+ // claude runtime would not. Proves --runtime took precedence over config.
703
+ expect(cmd).toContain("pi --model");
704
+ expect(cmd).toContain("opus");
705
+ });
706
+
707
+ test("--runtime rejects an unknown adapter with ValidationError", async () => {
708
+ const { deps } = makeDeps();
709
+ await expect(
710
+ coordinatorCommand(["start", "--no-attach", "--runtime", "bogus"], deps),
711
+ ).rejects.toThrow(ValidationError);
712
+ });
713
+
714
+ test("--headless uses the direct-spawn path instead of tmux", async () => {
715
+ const { deps, calls } = makeDeps();
716
+ const spawnCalls: string[][] = [];
717
+ deps._spawnHeadless = async (argv) => {
718
+ spawnCalls.push(argv);
719
+ return { pid: 4321, stdin: { write: () => 0 }, stdout: null };
720
+ };
721
+ const originalSleep = Bun.sleep;
722
+ Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
723
+
724
+ try {
725
+ await captureStdout(() =>
726
+ coordinatorCommand(["start", "--no-attach", "--json", "--headless"], deps),
727
+ );
728
+ } finally {
729
+ Bun.sleep = originalSleep;
730
+ }
731
+
732
+ // Headless path: the runtime is spawned directly; tmux is never touched.
733
+ expect(spawnCalls).toHaveLength(1);
734
+ expect(calls.createSession).toHaveLength(0);
735
+ // Session is recorded with an empty tmuxSession and the direct-spawn pid.
736
+ const sessions = loadSessionsFromDb();
737
+ expect(sessions[0]?.tmuxSession).toBe("");
738
+ expect(sessions[0]?.pid).toBe(4321);
739
+ });
740
+
741
+ test("--headless with a tmux-only runtime (pi) is rejected with ValidationError", async () => {
742
+ const { deps } = makeDeps();
743
+ await expect(
744
+ coordinatorCommand(["start", "--no-attach", "--headless", "--runtime", "pi"], deps),
745
+ ).rejects.toThrow(ValidationError);
746
+ });
747
+
685
748
  test("--json outputs JSON with expected fields", async () => {
686
749
  const { deps } = makeDeps();
687
750
  const originalSleep = Bun.sleep;
@@ -1423,6 +1486,24 @@ describe("resolveAttach", () => {
1423
1486
  });
1424
1487
  });
1425
1488
 
1489
+ describe("resolveHeadless", () => {
1490
+ test("explicit flag wins on every platform", () => {
1491
+ expect(resolveHeadless(true, "darwin")).toBe(true);
1492
+ expect(resolveHeadless(true, "win32")).toBe(true);
1493
+ expect(resolveHeadless(false, "win32")).toBe(false);
1494
+ expect(resolveHeadless(false, "linux")).toBe(false);
1495
+ });
1496
+
1497
+ test("defaults to headless on native Windows when unset", () => {
1498
+ expect(resolveHeadless(undefined, "win32")).toBe(true);
1499
+ });
1500
+
1501
+ test("defaults to tmux on non-Windows platforms when unset", () => {
1502
+ expect(resolveHeadless(undefined, "darwin")).toBe(false);
1503
+ expect(resolveHeadless(undefined, "linux")).toBe(false);
1504
+ });
1505
+ });
1506
+
1426
1507
  describe("watchdog integration", () => {
1427
1508
  describe("startCoordinator with --watchdog", () => {
1428
1509
  test("calls watchdog.start() when --watchdog flag is present", async () => {
@@ -24,7 +24,7 @@ import { jsonOutput } from "../json.ts";
24
24
  import { printHint, printSuccess, printWarning } from "../logging/color.ts";
25
25
  import { createMailClient } from "../mail/client.ts";
26
26
  import { createMailStore } from "../mail/store.ts";
27
- import { getRuntime } from "../runtimes/registry.ts";
27
+ import { getHeadlessRuntimeNames, getRuntime, getRuntimeNames } from "../runtimes/registry.ts";
28
28
  import { openSessionStore } from "../sessions/compat.ts";
29
29
  import { createRunStore, createSessionStore } from "../sessions/store.ts";
30
30
  import { resolveBackend, trackerCliName } from "../tracker/factory.ts";
@@ -322,6 +322,20 @@ export function resolveAttach(args: string[], isTTY: boolean): boolean {
322
322
  return isTTY;
323
323
  }
324
324
 
325
+ /**
326
+ * Resolve whether to spawn the coordinator headless (no tmux).
327
+ *
328
+ * An explicit flag wins (`--headless` → true, `--no-headless` → false). When
329
+ * unspecified, default to headless on native Windows — where tmux does not
330
+ * exist — and to the tmux path everywhere else.
331
+ *
332
+ * @param flag - Explicit flag value (true | false) or undefined when unset.
333
+ * @param platform - process.platform of the host running `ap`.
334
+ */
335
+ export function resolveHeadless(flag: boolean | undefined, platform: NodeJS.Platform): boolean {
336
+ return flag ?? platform === "win32";
337
+ }
338
+
325
339
  /**
326
340
  * Options for the reusable coordinator session startup core.
327
341
  * Used by startCoordinatorSession() and consumed by commands like ap discover.
@@ -332,6 +346,13 @@ export interface CoordinatorSessionOptions {
332
346
  watchdog: boolean;
333
347
  monitor: boolean;
334
348
  profile?: string;
349
+ /**
350
+ * Runtime adapter override (e.g. "claude", "codex", "pi", "opencode",
351
+ * "gemini"). Takes precedence over config's per-capability and default
352
+ * runtime. When omitted, resolution falls back to
353
+ * runtime.capabilities[capability] → runtime.default → "claude".
354
+ */
355
+ runtime?: string;
335
356
  /** Override coordinator name (default: "coordinator"). */
336
357
  coordinatorName?: string;
337
358
  /** Generic persistent agent name override. Preferred over coordinatorName for new callers. */
@@ -386,6 +407,7 @@ export async function startCoordinatorSession(
386
407
  watchdog: watchdogFlag,
387
408
  monitor: monitorFlag,
388
409
  profile: profileFlag,
410
+ runtime: runtimeOverride,
389
411
  coordinatorName: coordinatorNameOpt,
390
412
  agentName: agentNameOpt,
391
413
  capability: capabilityOpt,
@@ -402,6 +424,22 @@ export async function startCoordinatorSession(
402
424
  const displayName = displayNameOpt ?? COORDINATOR_SPEC.displayName;
403
425
  const beaconBuilder = beaconBuilderOpt ?? buildCoordinatorBeacon;
404
426
 
427
+ // Fail fast on an unknown --runtime before doing any setup work, listing the
428
+ // valid adapters. getRuntime() would also reject it, but later and with a
429
+ // plain Error; this gives an immediate, typed, actionable message.
430
+ if (runtimeOverride && !getRuntimeNames().includes(runtimeOverride)) {
431
+ throw new ValidationError(
432
+ `Unknown runtime "${runtimeOverride}". Available: ${getRuntimeNames().join(", ")}`,
433
+ { field: "runtime", value: runtimeOverride },
434
+ );
435
+ }
436
+
437
+ // Resolve the effective spawn mode. An explicit --headless/--no-headless wins;
438
+ // otherwise default to headless on native Windows (where tmux is unavailable)
439
+ // and tmux everywhere else. Programmatic callers that pass an explicit boolean
440
+ // (e.g. the web UI's headless: true) are unaffected.
441
+ const useHeadless = resolveHeadless(headlessFlag, process.platform);
442
+
405
443
  if (isRunningAsRoot()) {
406
444
  throw new AgentError(
407
445
  "Cannot spawn agents as root (UID 0). The claude CLI rejects --permission-mode bypassPermissions when run as root, causing the tmux session to die immediately. Run agentplate as a non-root user.",
@@ -478,7 +516,7 @@ export async function startCoordinatorSession(
478
516
  );
479
517
  const manifest = await manifestLoader.load();
480
518
  const resolvedModel = resolveModel(config, manifest, capability, "opus");
481
- const runtime = getRuntime(undefined, config, capability);
519
+ const runtime = getRuntime(runtimeOverride, config, capability);
482
520
 
483
521
  // Deploy hooks to the project root so the coordinator gets event logging,
484
522
  // mail check --inject, and activity tracking via the standard hook pipeline.
@@ -510,10 +548,16 @@ export async function startCoordinatorSession(
510
548
  // Headless start path: bypass tmux entirely and spawn the coordinator
511
549
  // process directly via runtime.buildDirectSpawn(). Same hooks, identity,
512
550
  // and run-tracking as the tmux path — only the spawn mechanism differs.
513
- if (headlessFlag === true) {
551
+ if (useHeadless) {
514
552
  if (!runtime.buildDirectSpawn) {
553
+ const headlessCapable = getHeadlessRuntimeNames().join(", ");
554
+ const winHint =
555
+ process.platform === "win32"
556
+ ? ` On native Windows, tmux is unavailable, so tmux-only runtimes like "${runtime.id}" cannot run as a coordinator — use --runtime claude, or run agentplate under WSL2.`
557
+ : "";
515
558
  throw new ValidationError(
516
- `Headless coordinator start requires a runtime with buildDirectSpawn (got: ${runtime.id})`,
559
+ `Runtime "${runtime.id}" cannot run a headless coordinator (no direct-spawn support). ` +
560
+ `Headless-capable runtimes: ${headlessCapable}.${winHint}`,
517
561
  { field: "runtime", value: runtime.id },
518
562
  );
519
563
  }
@@ -875,6 +919,8 @@ async function startPersistentAgent(
875
919
  watchdog: boolean;
876
920
  monitor: boolean;
877
921
  profile?: string;
922
+ runtime?: string;
923
+ headless?: boolean;
878
924
  acceptExistingWatchdog?: boolean;
879
925
  },
880
926
  deps: CoordinatorDeps = {},
@@ -1645,7 +1691,7 @@ export function createPersistentAgentCommand(
1645
1691
 
1646
1692
  cmd
1647
1693
  .command("start")
1648
- .description(`Start the ${spec.commandName} (spawns Claude Code at project root)`)
1694
+ .description(`Start the ${spec.commandName} (spawns the configured runtime at project root)`)
1649
1695
  .option("--attach", "Always attach to tmux session after start")
1650
1696
  .option("--no-attach", "Never attach to tmux session after start")
1651
1697
  .option("--watchdog", `Auto-start watchdog daemon with ${spec.commandName}`)
@@ -1655,6 +1701,15 @@ export function createPersistentAgentCommand(
1655
1701
  )
1656
1702
  .option("--monitor", `Auto-start Tier 2 monitor agent with ${spec.commandName}`)
1657
1703
  .option("--profile <name>", "Trellis profile to apply to spawned agents")
1704
+ .option(
1705
+ "--runtime <name>",
1706
+ "Runtime adapter: claude | codex | pi | opencode | gemini (default: config or claude)",
1707
+ )
1708
+ .option(
1709
+ "--headless",
1710
+ "Spawn without tmux (direct subprocess). Required on native Windows; runtime must be headless-capable (claude)",
1711
+ )
1712
+ .option("--no-headless", "Force the tmux spawn path (overrides the Windows headless default)")
1658
1713
  .option("--json", "Output as JSON")
1659
1714
  .action(
1660
1715
  async (opts: {
@@ -1664,6 +1719,8 @@ export function createPersistentAgentCommand(
1664
1719
  monitor?: boolean;
1665
1720
  json?: boolean;
1666
1721
  profile?: string;
1722
+ runtime?: string;
1723
+ headless?: boolean;
1667
1724
  }) => {
1668
1725
  // opts.attach = true if --attach, false if --no-attach, undefined if neither
1669
1726
  const shouldAttach = opts.attach !== undefined ? opts.attach : !!process.stdout.isTTY;
@@ -1676,6 +1733,9 @@ export function createPersistentAgentCommand(
1676
1733
  acceptExistingWatchdog: opts.acceptExistingWatchdog ?? false,
1677
1734
  monitor: opts.monitor ?? false,
1678
1735
  profile: opts.profile,
1736
+ runtime: opts.runtime,
1737
+ // true (--headless) | false (--no-headless) | undefined (platform default)
1738
+ headless: opts.headless,
1679
1739
  },
1680
1740
  deps,
1681
1741
  );
@@ -7,7 +7,7 @@ import { CursorRuntime } from "./cursor.ts";
7
7
  import { GeminiRuntime } from "./gemini.ts";
8
8
  import { OpenCodeRuntime } from "./opencode.ts";
9
9
  import { PiRuntime } from "./pi.ts";
10
- import { getRuntime } from "./registry.ts";
10
+ import { getHeadlessRuntimeNames, getRuntime, getRuntimeNames } from "./registry.ts";
11
11
 
12
12
  describe("getRuntime", () => {
13
13
  it("returns a ClaudeRuntime by default (no args)", () => {
@@ -194,3 +194,37 @@ describe("getRuntime", () => {
194
194
  });
195
195
  });
196
196
  });
197
+
198
+ describe("getRuntimeNames", () => {
199
+ it("lists every registered adapter, including the coordinator-supported set", () => {
200
+ const names = getRuntimeNames();
201
+ for (const expected of ["claude", "codex", "pi", "opencode", "gemini"]) {
202
+ expect(names).toContain(expected);
203
+ }
204
+ });
205
+
206
+ it("returns names that all resolve via getRuntime", () => {
207
+ for (const name of getRuntimeNames()) {
208
+ expect(getRuntime(name).id).toBe(name);
209
+ }
210
+ });
211
+ });
212
+
213
+ describe("getHeadlessRuntimeNames", () => {
214
+ it("includes claude (buildDirectSpawn) and sapling (static headless)", () => {
215
+ const names = getHeadlessRuntimeNames();
216
+ expect(names).toContain("claude");
217
+ expect(names).toContain("sapling");
218
+ });
219
+
220
+ it("excludes tmux-only runtimes like pi", () => {
221
+ expect(getHeadlessRuntimeNames()).not.toContain("pi");
222
+ });
223
+
224
+ it("every headless name actually resolves to a headless-capable runtime", () => {
225
+ for (const name of getHeadlessRuntimeNames()) {
226
+ const rt = getRuntime(name);
227
+ expect(typeof rt.buildDirectSpawn === "function" || rt.headless === true).toBe(true);
228
+ }
229
+ });
230
+ });
@@ -55,6 +55,33 @@ export function getAllRuntimes(): AgentRuntime[] {
55
55
  ];
56
56
  }
57
57
 
58
+ /**
59
+ * Names of all registered runtime adapters, in registration order.
60
+ *
61
+ * Used to validate a user-supplied `--runtime <name>` before resolution so the
62
+ * error can list the valid choices (see `ap coordinator start --runtime`).
63
+ *
64
+ * @returns Array of runtime names (e.g. "claude", "codex", "pi").
65
+ */
66
+ export function getRuntimeNames(): string[] {
67
+ return [...runtimes.keys()];
68
+ }
69
+
70
+ /**
71
+ * Names of runtimes that can spawn an agent without tmux — i.e. those that
72
+ * implement `buildDirectSpawn()` or statically declare `headless = true`.
73
+ *
74
+ * These are the only runtimes usable for a headless coordinator (the no-tmux
75
+ * path), which matters on native Windows where tmux is unavailable.
76
+ *
77
+ * @returns Array of headless-capable runtime names (e.g. "claude", "sapling").
78
+ */
79
+ export function getHeadlessRuntimeNames(): string[] {
80
+ return getAllRuntimes()
81
+ .filter((r) => typeof r.buildDirectSpawn === "function" || r.headless === true)
82
+ .map((r) => r.id);
83
+ }
84
+
58
85
  /**
59
86
  * Resolve a runtime adapter by name.
60
87
  *
package/src/version.ts CHANGED
@@ -2,4 +2,4 @@
2
2
  * Single source of truth for the package version, shared by every bundled bin
3
3
  * (ap/agentplate, lm/loam, sr, tl). Updated by scripts/version-bump.ts.
4
4
  */
5
- export const VERSION = "0.13.4";
5
+ export const VERSION = "0.14.1";
@@ -598,10 +598,24 @@ export async function waitForTuiReady(
598
598
  * Throws AgentError with a clear message if tmux is not available.
599
599
  */
600
600
  export async function ensureTmuxAvailable(): Promise<void> {
601
- const { exitCode } = await runCommand(["tmux", "-V"]);
602
- if (exitCode !== 0) {
601
+ // Bun.spawn throws (rather than returning a non-zero exit) when the tmux
602
+ // binary isn't on PATH — e.g. native Windows, where tmux does not exist. Catch
603
+ // that so the operator gets actionable guidance instead of a raw
604
+ // "Executable not found in $PATH" stack.
605
+ let available = false;
606
+ try {
607
+ const { exitCode } = await runCommand(["tmux", "-V"]);
608
+ available = exitCode === 0;
609
+ } catch {
610
+ available = false;
611
+ }
612
+ if (!available) {
613
+ const winHint =
614
+ process.platform === "win32"
615
+ ? " On native Windows, tmux is unavailable — start the coordinator with --headless (claude runtime), or run agentplate under WSL2."
616
+ : "";
603
617
  throw new AgentError(
604
- "tmux is not installed or not on PATH. Install tmux to use agentplate agent orchestration.",
618
+ `tmux is not installed or not on PATH. Install tmux to use agentplate agent orchestration.${winHint}`,
605
619
  );
606
620
  }
607
621
  }