@ag-eco/agentplate-cli 0.14.0 → 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.14.0",
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 {
@@ -710,6 +711,40 @@ describe("startCoordinator", () => {
710
711
  ).rejects.toThrow(ValidationError);
711
712
  });
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
+
713
748
  test("--json outputs JSON with expected fields", async () => {
714
749
  const { deps } = makeDeps();
715
750
  const originalSleep = Bun.sleep;
@@ -1451,6 +1486,24 @@ describe("resolveAttach", () => {
1451
1486
  });
1452
1487
  });
1453
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
+
1454
1507
  describe("watchdog integration", () => {
1455
1508
  describe("startCoordinator with --watchdog", () => {
1456
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, getRuntimeNames } 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.
@@ -420,6 +434,12 @@ export async function startCoordinatorSession(
420
434
  );
421
435
  }
422
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
+
423
443
  if (isRunningAsRoot()) {
424
444
  throw new AgentError(
425
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.",
@@ -528,10 +548,16 @@ export async function startCoordinatorSession(
528
548
  // Headless start path: bypass tmux entirely and spawn the coordinator
529
549
  // process directly via runtime.buildDirectSpawn(). Same hooks, identity,
530
550
  // and run-tracking as the tmux path — only the spawn mechanism differs.
531
- if (headlessFlag === true) {
551
+ if (useHeadless) {
532
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
+ : "";
533
558
  throw new ValidationError(
534
- `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}`,
535
561
  { field: "runtime", value: runtime.id },
536
562
  );
537
563
  }
@@ -894,6 +920,7 @@ async function startPersistentAgent(
894
920
  monitor: boolean;
895
921
  profile?: string;
896
922
  runtime?: string;
923
+ headless?: boolean;
897
924
  acceptExistingWatchdog?: boolean;
898
925
  },
899
926
  deps: CoordinatorDeps = {},
@@ -1678,6 +1705,11 @@ export function createPersistentAgentCommand(
1678
1705
  "--runtime <name>",
1679
1706
  "Runtime adapter: claude | codex | pi | opencode | gemini (default: config or claude)",
1680
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)")
1681
1713
  .option("--json", "Output as JSON")
1682
1714
  .action(
1683
1715
  async (opts: {
@@ -1688,6 +1720,7 @@ export function createPersistentAgentCommand(
1688
1720
  json?: boolean;
1689
1721
  profile?: string;
1690
1722
  runtime?: string;
1723
+ headless?: boolean;
1691
1724
  }) => {
1692
1725
  // opts.attach = true if --attach, false if --no-attach, undefined if neither
1693
1726
  const shouldAttach = opts.attach !== undefined ? opts.attach : !!process.stdout.isTTY;
@@ -1701,6 +1734,8 @@ export function createPersistentAgentCommand(
1701
1734
  monitor: opts.monitor ?? false,
1702
1735
  profile: opts.profile,
1703
1736
  runtime: opts.runtime,
1737
+ // true (--headless) | false (--no-headless) | undefined (platform default)
1738
+ headless: opts.headless,
1704
1739
  },
1705
1740
  deps,
1706
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, getRuntimeNames } 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)", () => {
@@ -209,3 +209,22 @@ describe("getRuntimeNames", () => {
209
209
  }
210
210
  });
211
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
+ });
@@ -67,6 +67,21 @@ export function getRuntimeNames(): string[] {
67
67
  return [...runtimes.keys()];
68
68
  }
69
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
+
70
85
  /**
71
86
  * Resolve a runtime adapter by name.
72
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.14.0";
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
  }