@ag-eco/agentplate-cli 0.14.1 → 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 +50 -2
- package/src/commands/coordinator.ts +44 -12
- package/src/runtimes/pi.test.ts +81 -0
- package/src/runtimes/pi.ts +202 -0
- package/src/runtimes/registry.test.ts +5 -3
- package/src/version.ts +1 -1
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",
|
|
@@ -738,13 +738,61 @@ describe("startCoordinator", () => {
|
|
|
738
738
|
expect(sessions[0]?.pid).toBe(4321);
|
|
739
739
|
});
|
|
740
740
|
|
|
741
|
-
test("--headless with a tmux-only runtime (
|
|
741
|
+
test("--headless with a tmux-only runtime (codex) is rejected with ValidationError", async () => {
|
|
742
742
|
const { deps } = makeDeps();
|
|
743
743
|
await expect(
|
|
744
|
-
coordinatorCommand(["start", "--no-attach", "--headless", "--runtime", "
|
|
744
|
+
coordinatorCommand(["start", "--no-attach", "--headless", "--runtime", "codex"], deps),
|
|
745
745
|
).rejects.toThrow(ValidationError);
|
|
746
746
|
});
|
|
747
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
|
+
|
|
748
796
|
test("--json outputs JSON with expected fields", async () => {
|
|
749
797
|
const { deps } = makeDeps();
|
|
750
798
|
const originalSleep = Bun.sleep;
|
|
@@ -24,6 +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 { setConnection } from "../runtimes/connections.ts";
|
|
27
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";
|
|
@@ -581,16 +582,8 @@ export async function startCoordinatorSession(
|
|
|
581
582
|
const headlessLogDir = join(agentplateDir, "logs", "coordinator", logTimestamp);
|
|
582
583
|
await mkdir(headlessLogDir, { recursive: true });
|
|
583
584
|
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
env: { ...(process.env as Record<string, string>), ...directEnv },
|
|
587
|
-
stdoutFile: join(headlessLogDir, "stdout.log"),
|
|
588
|
-
stderrFile: join(headlessLogDir, "stderr.log"),
|
|
589
|
-
agentName: coordinatorName,
|
|
590
|
-
});
|
|
591
|
-
|
|
592
|
-
// Build the initial stdin prompt from agent definition + pending dispatch
|
|
593
|
-
// 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).
|
|
594
587
|
const agentDefPath = join(projectRoot, ".agentplate", "agent-defs", agentDefFile);
|
|
595
588
|
const agentDefHandle = Bun.file(agentDefPath);
|
|
596
589
|
const primeContext = (await agentDefHandle.exists()) ? await agentDefHandle.text() : "";
|
|
@@ -614,7 +607,46 @@ export async function startCoordinatorSession(
|
|
|
614
607
|
mailSection || undefined,
|
|
615
608
|
beacon,
|
|
616
609
|
);
|
|
617
|
-
|
|
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
|
+
}
|
|
618
650
|
|
|
619
651
|
// Create run record + current-run.txt + session row.
|
|
620
652
|
const sessionId = `session-${Date.now()}-${coordinatorName}`;
|
|
@@ -1707,7 +1739,7 @@ export function createPersistentAgentCommand(
|
|
|
1707
1739
|
)
|
|
1708
1740
|
.option(
|
|
1709
1741
|
"--headless",
|
|
1710
|
-
"Spawn without tmux (direct subprocess). Required on native Windows; runtime must be headless-capable (claude)",
|
|
1742
|
+
"Spawn without tmux (direct subprocess). Required on native Windows; runtime must be headless-capable (claude, or pi via experimental --mode rpc)",
|
|
1711
1743
|
)
|
|
1712
1744
|
.option("--no-headless", "Force the tmux spawn path (overrides the Windows headless default)")
|
|
1713
1745
|
.option("--json", "Output as JSON")
|
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
|
*
|
|
@@ -211,14 +211,16 @@ describe("getRuntimeNames", () => {
|
|
|
211
211
|
});
|
|
212
212
|
|
|
213
213
|
describe("getHeadlessRuntimeNames", () => {
|
|
214
|
-
it("includes claude (buildDirectSpawn)
|
|
214
|
+
it("includes claude (buildDirectSpawn), sapling (static headless), and pi (rpc)", () => {
|
|
215
215
|
const names = getHeadlessRuntimeNames();
|
|
216
216
|
expect(names).toContain("claude");
|
|
217
217
|
expect(names).toContain("sapling");
|
|
218
|
+
// Pi gained a headless RPC path (buildDirectSpawn + connect via `pi --mode rpc`).
|
|
219
|
+
expect(names).toContain("pi");
|
|
218
220
|
});
|
|
219
221
|
|
|
220
|
-
it("excludes tmux-only runtimes like
|
|
221
|
-
expect(getHeadlessRuntimeNames()).not.toContain("
|
|
222
|
+
it("excludes tmux-only runtimes like codex", () => {
|
|
223
|
+
expect(getHeadlessRuntimeNames()).not.toContain("codex");
|
|
222
224
|
});
|
|
223
225
|
|
|
224
226
|
it("every headless name actually resolves to a headless-capable runtime", () => {
|
package/src/version.ts
CHANGED