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