@ag-eco/agentplate-cli 0.14.0 → 0.15.0

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.15.0",
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,88 @@ 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 (codex) is rejected with ValidationError", async () => {
742
+ const { deps } = makeDeps();
743
+ await expect(
744
+ coordinatorCommand(["start", "--no-attach", "--headless", "--runtime", "codex"], deps),
745
+ ).rejects.toThrow(ValidationError);
746
+ });
747
+
748
+ test("--headless --runtime pi spawns RPC mode and frames the prompt via the connection", async () => {
749
+ const { deps, calls } = makeDeps();
750
+ const stdinWrites: string[] = [];
751
+ const spawnArgvs: string[][] = [];
752
+ deps._spawnHeadless = async (argv) => {
753
+ spawnArgvs.push(argv);
754
+ return {
755
+ pid: 5678,
756
+ stdin: {
757
+ write: (d: string | Uint8Array) => {
758
+ stdinWrites.push(typeof d === "string" ? d : new TextDecoder().decode(d));
759
+ return 0;
760
+ },
761
+ },
762
+ // RPC path requires a piped stdout; an immediately-closing stream is enough
763
+ // for the fire-and-forget sendPrompt used at startup.
764
+ stdout: new ReadableStream<Uint8Array>({
765
+ start(c) {
766
+ c.close();
767
+ },
768
+ }),
769
+ };
770
+ };
771
+ const originalSleep = Bun.sleep;
772
+ Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
773
+
774
+ try {
775
+ await captureStdout(() =>
776
+ coordinatorCommand(
777
+ ["start", "--no-attach", "--json", "--headless", "--runtime", "pi"],
778
+ deps,
779
+ ),
780
+ );
781
+ } finally {
782
+ Bun.sleep = originalSleep;
783
+ }
784
+
785
+ // Headless RPC: spawned `pi --mode rpc`, never touched tmux.
786
+ expect(calls.createSession).toHaveLength(0);
787
+ expect(spawnArgvs[0]?.slice(0, 3)).toEqual(["pi", "--mode", "rpc"]);
788
+ // The prompt is delivered as a framed Pi command, not raw stdin text.
789
+ expect(stdinWrites.some((w) => w.includes('"type":"prompt"'))).toBe(true);
790
+ // Session recorded headless (no tmux pane) with the spawn pid.
791
+ const sessions = loadSessionsFromDb();
792
+ expect(sessions[0]?.tmuxSession).toBe("");
793
+ expect(sessions[0]?.pid).toBe(5678);
794
+ });
795
+
713
796
  test("--json outputs JSON with expected fields", async () => {
714
797
  const { deps } = makeDeps();
715
798
  const originalSleep = Bun.sleep;
@@ -1451,6 +1534,24 @@ describe("resolveAttach", () => {
1451
1534
  });
1452
1535
  });
1453
1536
 
1537
+ describe("resolveHeadless", () => {
1538
+ test("explicit flag wins on every platform", () => {
1539
+ expect(resolveHeadless(true, "darwin")).toBe(true);
1540
+ expect(resolveHeadless(true, "win32")).toBe(true);
1541
+ expect(resolveHeadless(false, "win32")).toBe(false);
1542
+ expect(resolveHeadless(false, "linux")).toBe(false);
1543
+ });
1544
+
1545
+ test("defaults to headless on native Windows when unset", () => {
1546
+ expect(resolveHeadless(undefined, "win32")).toBe(true);
1547
+ });
1548
+
1549
+ test("defaults to tmux on non-Windows platforms when unset", () => {
1550
+ expect(resolveHeadless(undefined, "darwin")).toBe(false);
1551
+ expect(resolveHeadless(undefined, "linux")).toBe(false);
1552
+ });
1553
+ });
1554
+
1454
1555
  describe("watchdog integration", () => {
1455
1556
  describe("startCoordinator with --watchdog", () => {
1456
1557
  test("calls watchdog.start() when --watchdog flag is present", async () => {
@@ -24,7 +24,8 @@ 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 { setConnection } from "../runtimes/connections.ts";
28
+ import { getHeadlessRuntimeNames, getRuntime, getRuntimeNames } from "../runtimes/registry.ts";
28
29
  import { openSessionStore } from "../sessions/compat.ts";
29
30
  import { createRunStore, createSessionStore } from "../sessions/store.ts";
30
31
  import { resolveBackend, trackerCliName } from "../tracker/factory.ts";
@@ -322,6 +323,20 @@ export function resolveAttach(args: string[], isTTY: boolean): boolean {
322
323
  return isTTY;
323
324
  }
324
325
 
326
+ /**
327
+ * Resolve whether to spawn the coordinator headless (no tmux).
328
+ *
329
+ * An explicit flag wins (`--headless` → true, `--no-headless` → false). When
330
+ * unspecified, default to headless on native Windows — where tmux does not
331
+ * exist — and to the tmux path everywhere else.
332
+ *
333
+ * @param flag - Explicit flag value (true | false) or undefined when unset.
334
+ * @param platform - process.platform of the host running `ap`.
335
+ */
336
+ export function resolveHeadless(flag: boolean | undefined, platform: NodeJS.Platform): boolean {
337
+ return flag ?? platform === "win32";
338
+ }
339
+
325
340
  /**
326
341
  * Options for the reusable coordinator session startup core.
327
342
  * Used by startCoordinatorSession() and consumed by commands like ap discover.
@@ -420,6 +435,12 @@ export async function startCoordinatorSession(
420
435
  );
421
436
  }
422
437
 
438
+ // Resolve the effective spawn mode. An explicit --headless/--no-headless wins;
439
+ // otherwise default to headless on native Windows (where tmux is unavailable)
440
+ // and tmux everywhere else. Programmatic callers that pass an explicit boolean
441
+ // (e.g. the web UI's headless: true) are unaffected.
442
+ const useHeadless = resolveHeadless(headlessFlag, process.platform);
443
+
423
444
  if (isRunningAsRoot()) {
424
445
  throw new AgentError(
425
446
  "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 +549,16 @@ export async function startCoordinatorSession(
528
549
  // Headless start path: bypass tmux entirely and spawn the coordinator
529
550
  // process directly via runtime.buildDirectSpawn(). Same hooks, identity,
530
551
  // and run-tracking as the tmux path — only the spawn mechanism differs.
531
- if (headlessFlag === true) {
552
+ if (useHeadless) {
532
553
  if (!runtime.buildDirectSpawn) {
554
+ const headlessCapable = getHeadlessRuntimeNames().join(", ");
555
+ const winHint =
556
+ process.platform === "win32"
557
+ ? ` 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.`
558
+ : "";
533
559
  throw new ValidationError(
534
- `Headless coordinator start requires a runtime with buildDirectSpawn (got: ${runtime.id})`,
560
+ `Runtime "${runtime.id}" cannot run a headless coordinator (no direct-spawn support). ` +
561
+ `Headless-capable runtimes: ${headlessCapable}.${winHint}`,
535
562
  { field: "runtime", value: runtime.id },
536
563
  );
537
564
  }
@@ -555,16 +582,8 @@ export async function startCoordinatorSession(
555
582
  const headlessLogDir = join(agentplateDir, "logs", "coordinator", logTimestamp);
556
583
  await mkdir(headlessLogDir, { recursive: true });
557
584
 
558
- const headlessProc = await spawnHeadless(argv, {
559
- cwd: projectRoot,
560
- env: { ...(process.env as Record<string, string>), ...directEnv },
561
- stdoutFile: join(headlessLogDir, "stdout.log"),
562
- stderrFile: join(headlessLogDir, "stderr.log"),
563
- agentName: coordinatorName,
564
- });
565
-
566
- // Build the initial stdin prompt from agent definition + pending dispatch
567
- // mail + activation beacon. Replaces SessionStart hooks (no-op headless).
585
+ // Build the initial prompt from agent definition + pending dispatch mail +
586
+ // activation beacon. Replaces SessionStart hooks (no-op headless).
568
587
  const agentDefPath = join(projectRoot, ".agentplate", "agent-defs", agentDefFile);
569
588
  const agentDefHandle = Bun.file(agentDefPath);
570
589
  const primeContext = (await agentDefHandle.exists()) ? await agentDefHandle.text() : "";
@@ -588,7 +607,46 @@ export async function startCoordinatorSession(
588
607
  mailSection || undefined,
589
608
  beacon,
590
609
  );
591
- await headlessProc.stdin.write(initialPrompt);
610
+
611
+ const spawnEnv = { ...(process.env as Record<string, string>), ...directEnv };
612
+ let headlessProc: Awaited<ReturnType<typeof spawnHeadless>>;
613
+ if (runtime.connect) {
614
+ // EXPERIMENTAL RPC runtime (e.g. Pi --mode rpc): stdout must be a pipe so
615
+ // the connection's reader can route get_state responses, and the prompt +
616
+ // mail are delivered as framed RPC commands rather than raw stdin text.
617
+ // We register the runtime's own connection instead of the generic
618
+ // stdin-writer (no agentName → spawnHeadless skips that registration).
619
+ // Trade-off: Pi event lines are not file-logged in this path yet.
620
+ headlessProc = await spawnHeadless(argv, {
621
+ cwd: projectRoot,
622
+ env: spawnEnv,
623
+ stderrFile: join(headlessLogDir, "stderr.log"),
624
+ });
625
+ if (!headlessProc.stdout) {
626
+ throw new AgentError(
627
+ `Runtime "${runtime.id}" needs a piped stdout for RPC mode but none was provided`,
628
+ { agentName: coordinatorName },
629
+ );
630
+ }
631
+ const connection = runtime.connect({
632
+ stdin: headlessProc.stdin,
633
+ stdout: headlessProc.stdout,
634
+ });
635
+ setConnection(coordinatorName, connection);
636
+ await connection.sendPrompt(initialPrompt);
637
+ } else {
638
+ // Stream-json runtimes (Claude): the generic HeadlessClaudeConnection
639
+ // (registered by agentName) writes the prompt + mail as raw stdin text,
640
+ // and stdout is captured to a log file for `ap logs`.
641
+ headlessProc = await spawnHeadless(argv, {
642
+ cwd: projectRoot,
643
+ env: spawnEnv,
644
+ stdoutFile: join(headlessLogDir, "stdout.log"),
645
+ stderrFile: join(headlessLogDir, "stderr.log"),
646
+ agentName: coordinatorName,
647
+ });
648
+ await headlessProc.stdin.write(initialPrompt);
649
+ }
592
650
 
593
651
  // Create run record + current-run.txt + session row.
594
652
  const sessionId = `session-${Date.now()}-${coordinatorName}`;
@@ -894,6 +952,7 @@ async function startPersistentAgent(
894
952
  monitor: boolean;
895
953
  profile?: string;
896
954
  runtime?: string;
955
+ headless?: boolean;
897
956
  acceptExistingWatchdog?: boolean;
898
957
  },
899
958
  deps: CoordinatorDeps = {},
@@ -1678,6 +1737,11 @@ export function createPersistentAgentCommand(
1678
1737
  "--runtime <name>",
1679
1738
  "Runtime adapter: claude | codex | pi | opencode | gemini (default: config or claude)",
1680
1739
  )
1740
+ .option(
1741
+ "--headless",
1742
+ "Spawn without tmux (direct subprocess). Required on native Windows; runtime must be headless-capable (claude, or pi via experimental --mode rpc)",
1743
+ )
1744
+ .option("--no-headless", "Force the tmux spawn path (overrides the Windows headless default)")
1681
1745
  .option("--json", "Output as JSON")
1682
1746
  .action(
1683
1747
  async (opts: {
@@ -1688,6 +1752,7 @@ export function createPersistentAgentCommand(
1688
1752
  json?: boolean;
1689
1753
  profile?: string;
1690
1754
  runtime?: string;
1755
+ headless?: boolean;
1691
1756
  }) => {
1692
1757
  // opts.attach = true if --attach, false if --no-attach, undefined if neither
1693
1758
  const shouldAttach = opts.attach !== undefined ? opts.attach : !!process.stdout.isTTY;
@@ -1701,6 +1766,8 @@ export function createPersistentAgentCommand(
1701
1766
  monitor: opts.monitor ?? false,
1702
1767
  profile: opts.profile,
1703
1768
  runtime: opts.runtime,
1769
+ // true (--headless) | false (--no-headless) | undefined (platform default)
1770
+ headless: opts.headless,
1704
1771
  },
1705
1772
  deps,
1706
1773
  );
@@ -787,3 +787,84 @@ describe("PiRuntime integration: registry resolves 'pi'", () => {
787
787
  expect(() => getRuntime("does-not-exist")).toThrow('Unknown runtime: "does-not-exist"');
788
788
  });
789
789
  });
790
+
791
+ describe("PiRuntime headless RPC: buildDirectSpawn", () => {
792
+ const runtime = new PiRuntime();
793
+
794
+ test("returns `pi --mode rpc` with no --model when model omitted", () => {
795
+ expect(runtime.buildDirectSpawn({ cwd: "/x", env: {}, instructionPath: "AGENTS.md" })).toEqual([
796
+ "pi",
797
+ "--mode",
798
+ "rpc",
799
+ ]);
800
+ });
801
+
802
+ test("appends the expanded --model when provided", () => {
803
+ expect(
804
+ runtime.buildDirectSpawn({
805
+ cwd: "/x",
806
+ env: {},
807
+ model: "opus",
808
+ instructionPath: "AGENTS.md",
809
+ }),
810
+ ).toEqual(["pi", "--mode", "rpc", "--model", "anthropic/claude-opus-4-6"]);
811
+ });
812
+ });
813
+
814
+ describe("PiRuntime headless RPC: connect() command framing", () => {
815
+ const runtime = new PiRuntime();
816
+
817
+ function fakeProc(emit?: string): {
818
+ proc: { stdin: { write(d: string | Uint8Array): number }; stdout: ReadableStream<Uint8Array> };
819
+ writes: string[];
820
+ } {
821
+ const writes: string[] = [];
822
+ const stdout = new ReadableStream<Uint8Array>({
823
+ start(c) {
824
+ if (emit) c.enqueue(new TextEncoder().encode(emit));
825
+ c.close();
826
+ },
827
+ });
828
+ const stdin = {
829
+ write: (d: string | Uint8Array): number => {
830
+ writes.push(typeof d === "string" ? d : new TextDecoder().decode(d));
831
+ return 0;
832
+ },
833
+ };
834
+ return { proc: { stdin, stdout }, writes };
835
+ }
836
+
837
+ test("sendPrompt frames a `prompt` command", async () => {
838
+ const { proc, writes } = fakeProc();
839
+ const conn = runtime.connect(proc);
840
+ await conn.sendPrompt("hello");
841
+ conn.close();
842
+ expect(writes).toContain('{"type":"prompt","message":"hello"}\n');
843
+ });
844
+
845
+ test("followUp frames a `follow_up` command", async () => {
846
+ const { proc, writes } = fakeProc();
847
+ const conn = runtime.connect(proc);
848
+ await conn.followUp("more work");
849
+ conn.close();
850
+ expect(writes).toContain('{"type":"follow_up","message":"more work"}\n');
851
+ });
852
+
853
+ test("abort frames an `abort` command", async () => {
854
+ const { proc, writes } = fakeProc();
855
+ const conn = runtime.connect(proc);
856
+ await conn.abort();
857
+ conn.close();
858
+ expect(writes).toContain('{"type":"abort"}\n');
859
+ });
860
+
861
+ test("getState maps an isStreaming response to working", async () => {
862
+ const { proc } = fakeProc(
863
+ '{"type":"response","id":0,"command":"get_state","data":{"isStreaming":true}}\n',
864
+ );
865
+ const conn = runtime.connect(proc);
866
+ const state = await conn.getState();
867
+ expect(state.status).toBe("working");
868
+ conn.close();
869
+ });
870
+ });
@@ -7,9 +7,13 @@ import type { PiRuntimeConfig, ResolvedModel } from "../types.ts";
7
7
  import { generatePiGuardExtension } from "./pi-guards.ts";
8
8
  import type {
9
9
  AgentRuntime,
10
+ ConnectionState,
11
+ DirectSpawnOpts,
10
12
  HooksDef,
11
13
  OverlayContent,
12
14
  ReadyState,
15
+ RpcProcessHandle,
16
+ RuntimeConnection,
13
17
  SpawnOpts,
14
18
  TranscriptSummary,
15
19
  } from "./types.ts";
@@ -24,12 +28,174 @@ const DEFAULT_PI_CONFIG: PiRuntimeConfig = {
24
28
  },
25
29
  };
26
30
 
31
+ /** Pending get_state request awaiting its NDJSON response. */
32
+ interface PiPendingRequest {
33
+ resolve: (state: ConnectionState) => void;
34
+ reject: (err: Error) => void;
35
+ timer: ReturnType<typeof setTimeout>;
36
+ }
37
+
38
+ /**
39
+ * RPC connection to a running `pi --mode rpc` process.
40
+ *
41
+ * Pi's RPC mode is a long-lived loop that reads newline-delimited JSON commands
42
+ * on stdin and emits NDJSON events + responses on stdout (see Pi's
43
+ * `src/modes/rpc/rpc-mode.ts`). This maps the agentplate RuntimeConnection
44
+ * contract onto Pi's command vocabulary:
45
+ *
46
+ * - sendPrompt → `{ type: "prompt", message }`
47
+ * - followUp → `{ type: "follow_up", message }`
48
+ * - abort → `{ type: "abort" }`
49
+ * - getState → `{ type: "get_state", id }` → `{ type: "response", id, data }`
50
+ *
51
+ * A background reader drains stdout, routing `type: "response"` lines with a
52
+ * matching numeric `id` to pending getState() waiters and discarding the
53
+ * agent event stream (observability for Pi events is a follow-up).
54
+ *
55
+ * EXPERIMENTAL: validated against Pi's documented RPC protocol but not yet
56
+ * exercised end-to-end. Not exported — constructed only by PiRuntime.connect().
57
+ */
58
+ class PiConnection implements RuntimeConnection {
59
+ private nextId = 0;
60
+ private readonly pending = new Map<number, PiPendingRequest>();
61
+ private closed = false;
62
+ private readonly proc: RpcProcessHandle;
63
+ private readonly timeoutMs: number;
64
+
65
+ constructor(proc: RpcProcessHandle, timeoutMs = 5000) {
66
+ this.proc = proc;
67
+ this.timeoutMs = timeoutMs;
68
+ this.drainStdout();
69
+ }
70
+
71
+ /** Background reader: route `type:"response"` lines to pending getState waiters. */
72
+ private drainStdout(): void {
73
+ const reader = this.proc.stdout.getReader();
74
+ const decoder = new TextDecoder();
75
+ let buffer = "";
76
+
77
+ const processLine = (line: string): void => {
78
+ const trimmed = line.trim();
79
+ if (!trimmed) return;
80
+ let parsed: Record<string, unknown>;
81
+ try {
82
+ parsed = JSON.parse(trimmed) as Record<string, unknown>;
83
+ } catch {
84
+ return; // partial write or non-JSON debug line
85
+ }
86
+ // Pi responses: { type: "response", id, command, success, data }
87
+ if (parsed.type === "response" && typeof parsed.id === "number") {
88
+ const waiter = this.pending.get(parsed.id);
89
+ if (waiter) {
90
+ clearTimeout(waiter.timer);
91
+ this.pending.delete(parsed.id);
92
+ waiter.resolve(mapPiState(parsed.data));
93
+ }
94
+ }
95
+ // Agent event lines are discarded here (Pi event observability is TODO).
96
+ };
97
+
98
+ const read = async (): Promise<void> => {
99
+ try {
100
+ while (true) {
101
+ const { done, value } = await reader.read();
102
+ if (done) break;
103
+ buffer += decoder.decode(value, { stream: true });
104
+ const lines = buffer.split("\n");
105
+ buffer = lines.pop() ?? "";
106
+ for (const line of lines) processLine(line);
107
+ }
108
+ if (buffer.trim()) processLine(buffer);
109
+ } catch {
110
+ // stream error — reject all pending below
111
+ } finally {
112
+ reader.releaseLock();
113
+ for (const [, waiter] of this.pending) {
114
+ clearTimeout(waiter.timer);
115
+ waiter.reject(new Error("connection closed"));
116
+ }
117
+ this.pending.clear();
118
+ }
119
+ };
120
+
121
+ read().catch(() => {
122
+ // handled in finally
123
+ });
124
+ }
125
+
126
+ /** Write one NDJSON command line to stdin (fire-and-forget). */
127
+ private writeMsg(msg: Record<string, unknown>): void {
128
+ const result = this.proc.stdin.write(`${JSON.stringify(msg)}\n`);
129
+ if (result instanceof Promise) {
130
+ result.catch(() => {
131
+ // non-fatal for fire-and-forget control messages
132
+ });
133
+ }
134
+ }
135
+
136
+ async sendPrompt(text: string): Promise<void> {
137
+ this.writeMsg({ type: "prompt", message: text });
138
+ }
139
+
140
+ async followUp(text: string): Promise<void> {
141
+ this.writeMsg({ type: "follow_up", message: text });
142
+ }
143
+
144
+ async abort(): Promise<void> {
145
+ this.writeMsg({ type: "abort" });
146
+ }
147
+
148
+ getState(): Promise<ConnectionState> {
149
+ if (this.closed) return Promise.reject(new Error("connection closed"));
150
+ const id = this.nextId++;
151
+ return new Promise<ConnectionState>((resolve, reject) => {
152
+ const timer = setTimeout(() => {
153
+ this.pending.delete(id);
154
+ reject(new Error("getState timed out"));
155
+ }, this.timeoutMs);
156
+ this.pending.set(id, { resolve, reject, timer });
157
+ const result = this.proc.stdin.write(`${JSON.stringify({ type: "get_state", id })}\n`);
158
+ if (result instanceof Promise) {
159
+ result.catch(() => {
160
+ clearTimeout(timer);
161
+ this.pending.delete(id);
162
+ reject(new Error("write failed"));
163
+ });
164
+ }
165
+ });
166
+ }
167
+
168
+ close(): void {
169
+ this.closed = true;
170
+ for (const [, waiter] of this.pending) {
171
+ clearTimeout(waiter.timer);
172
+ waiter.reject(new Error("connection closed"));
173
+ }
174
+ this.pending.clear();
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Map a Pi get_state response payload to the agentplate ConnectionState.
180
+ * Pi reports `isStreaming` (a turn is in flight); everything else is idle.
181
+ */
182
+ function mapPiState(data: unknown): ConnectionState {
183
+ const streaming =
184
+ typeof data === "object" && data !== null && (data as { isStreaming?: unknown }).isStreaming;
185
+ return { status: streaming ? "working" : "idle" };
186
+ }
187
+
27
188
  /**
28
189
  * Pi runtime adapter.
29
190
  *
30
191
  * Implements AgentRuntime for the `pi` CLI (Mario Zechner's Pi coding agent).
31
192
  * Security is enforced via Pi guard extensions rather than permission-mode flags —
32
193
  * Pi has no --permission-mode equivalent.
194
+ *
195
+ * Pi runs in two modes here: an interactive TUI in a tmux pane
196
+ * (buildSpawnCommand) and a headless `--mode rpc` subprocess (buildDirectSpawn +
197
+ * connect) for tmux-less hosts such as native Windows. The RPC path is
198
+ * experimental.
33
199
  */
34
200
  export class PiRuntime implements AgentRuntime {
35
201
  /** Unique identifier for this runtime. */
@@ -111,6 +277,42 @@ export class PiRuntime implements AgentRuntime {
111
277
  return cmd;
112
278
  }
113
279
 
280
+ /**
281
+ * Build the argv for a long-lived headless Pi process in RPC mode.
282
+ *
283
+ * `pi --mode rpc` reads newline-delimited JSON commands on stdin and emits
284
+ * NDJSON events/responses on stdout, staying alive across turns — the shape a
285
+ * tmux-less coordinator needs (e.g. native Windows). Pi reads its task from
286
+ * AGENTS.md (instructionPath) in the cwd; prompts/mail are delivered as RPC
287
+ * commands via connect(), not argv.
288
+ *
289
+ * EXPERIMENTAL: the RPC headless path is not yet validated end-to-end.
290
+ *
291
+ * @param opts - Direct spawn options (cwd, env, model handled by the caller)
292
+ * @returns Argv array for Bun.spawn — do not shell-interpolate
293
+ */
294
+ buildDirectSpawn(opts: DirectSpawnOpts): string[] {
295
+ const argv = ["pi", "--mode", "rpc"];
296
+ if (opts.model !== undefined) {
297
+ argv.push("--model", this.expandModel(opts.model));
298
+ }
299
+ return argv;
300
+ }
301
+
302
+ /**
303
+ * Establish a JSON-command RPC connection to a running `pi --mode rpc` process.
304
+ *
305
+ * Returns a PiConnection that frames sendPrompt/followUp/abort as NDJSON
306
+ * commands on stdin and routes get_state responses from stdout. Used by the
307
+ * headless coordinator path in place of tmux send-keys.
308
+ *
309
+ * @param process - Stdin/stdout handles from the spawned Pi subprocess
310
+ * @returns RuntimeConnection for framed control + health checks
311
+ */
312
+ connect(process: RpcProcessHandle): RuntimeConnection {
313
+ return new PiConnection(process);
314
+ }
315
+
114
316
  /**
115
317
  * Deploy per-agent instructions and guards to a worktree.
116
318
  *
@@ -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,24 @@ describe("getRuntimeNames", () => {
209
209
  }
210
210
  });
211
211
  });
212
+
213
+ describe("getHeadlessRuntimeNames", () => {
214
+ it("includes claude (buildDirectSpawn), sapling (static headless), and pi (rpc)", () => {
215
+ const names = getHeadlessRuntimeNames();
216
+ expect(names).toContain("claude");
217
+ expect(names).toContain("sapling");
218
+ // Pi gained a headless RPC path (buildDirectSpawn + connect via `pi --mode rpc`).
219
+ expect(names).toContain("pi");
220
+ });
221
+
222
+ it("excludes tmux-only runtimes like codex", () => {
223
+ expect(getHeadlessRuntimeNames()).not.toContain("codex");
224
+ });
225
+
226
+ it("every headless name actually resolves to a headless-capable runtime", () => {
227
+ for (const name of getHeadlessRuntimeNames()) {
228
+ const rt = getRuntime(name);
229
+ expect(typeof rt.buildDirectSpawn === "function" || rt.headless === true).toBe(true);
230
+ }
231
+ });
232
+ });
@@ -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.15.0";
@@ -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
  }