@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 +1 -1
- package/src/commands/coordinator.test.ts +101 -0
- package/src/commands/coordinator.ts +81 -14
- package/src/runtimes/pi.test.ts +81 -0
- package/src/runtimes/pi.ts +202 -0
- package/src/runtimes/registry.test.ts +22 -1
- package/src/runtimes/registry.ts +15 -0
- package/src/version.ts +1 -1
- package/src/worktree/tmux.ts +17 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ag-eco/agentplate-cli",
|
|
3
|
-
"version": "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 {
|
|
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 (
|
|
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
|
-
`
|
|
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
|
-
|
|
559
|
-
|
|
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
|
-
|
|
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
|
);
|
package/src/runtimes/pi.test.ts
CHANGED
|
@@ -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
|
+
});
|
package/src/runtimes/pi.ts
CHANGED
|
@@ -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
|
+
});
|
package/src/runtimes/registry.ts
CHANGED
|
@@ -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
package/src/worktree/tmux.ts
CHANGED
|
@@ -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
|
-
|
|
602
|
-
|
|
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
|
-
|
|
618
|
+
`tmux is not installed or not on PATH. Install tmux to use agentplate agent orchestration.${winHint}`,
|
|
605
619
|
);
|
|
606
620
|
}
|
|
607
621
|
}
|