diff --git a/README.md b/README.md
index b8320b69..441918cf 100644
--- a/README.md
+++ b/README.md
@@ -29,6 +29,7 @@ or miss one that does. A test fails the build when it drifts.
| `moshcode agents` | engines | list engines, open their agent view, or launch autonomously |
| `moshcode start` | engines | launch an engine with its native defaults |
| `moshcode herd` | runtime | run agent sessions that outlive this terminal |
+| `moshcode swarm` | runtime | one task, a herd of agents, one answer — plan, fan out, verify, synthesise (PRD 0015) |
| `moshcode ps` | runtime | list herd sessions and what each one is doing |
| `moshcode cost`
`usage` | runtime | what each session is spending, read from the engines' own logs |
| `moshcode attach` | runtime | attach this terminal to a herd session |
@@ -472,6 +473,35 @@ const first = await herdWait(["api", "web", "docs"], { any: true });
await herdWait(["api", "web"], { states: ["done"] });
```
+### Swarm — one task, a herd of agents, one answer
+
+Claude Code calls it ultracode: a prompt that becomes a workflow of agents. The
+herd already had every part of that, so this is the verb that composes them,
+on any engine moshcode can start:
+
+```sh
+moshcode swarm "port the auth routes and the dashboard to the new API"
+· plan — claude is splitting the task into up to 4 pieces
+ 1 auth routes
+ 2 dashboard
+ 3 shared API client
+· swarm — 3 sessions, 3 at a time (claude, herd swarm)
+ ✓ swarm-port-the-auth-1 idle · t-01
+ ✓ swarm-port-the-auth-2 idle · t-02
+ ✓ swarm-port-the-auth-3 idle · t-03
+· synthesis — claude is folding 3 pieces into one answer
+```
+
+One headless call splits the task into pieces that do not touch the same
+files. Each piece runs in its own herd session, `--agents` of them at a time
+(default 4, the same cap the claude engine's defaults put on Claude's own
+workflows), prompted and waited on exactly as `herd prompt --wait` is, so every
+piece is a task in the ledger. One more call folds the outputs into the answer
+you read. `--verify` adds a skeptic per piece whose verdict the synthesis sees;
+`--plan-only` shows the split and starts nothing; `--keep` leaves the sessions
+in `moshcode ps`. A plan that does not parse runs the task as one piece rather
+than not at all.
+
### Let the engine say what it is doing
Reading a screen works and it rots — engines change their wording between
diff --git a/bin/moshcode.mjs b/bin/moshcode.mjs
index bcc604f1..2c3d1df7 100755
--- a/bin/moshcode.mjs
+++ b/bin/moshcode.mjs
@@ -356,6 +356,13 @@ async function main() {
process.exitCode = (await herdCommand(rest)) || 0;
return;
}
+ // A swarm (PRD 0015): the herd's parts, composed. Imported here because a
+ // plain launch never needs it.
+ if (cmd === "swarm") {
+ const { swarmCommand } = await import("../src/swarm.mjs");
+ process.exitCode = (await swarmCommand(rest)) || 0;
+ return;
+ }
if (["ps", "attach", "kill", "wait", "restore", "cost", "usage"].includes(cmd)) {
process.exitCode = (await herdCommand([cmd === "usage" ? "cost" : cmd, ...rest])) || 0;
return;
diff --git a/package.json b/package.json
index acddf577..17659196 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "moshcode",
- "version": "0.96.0",
+ "version": "0.97.0",
"type": "module",
"description": "moshcode — a metal wrapper for coding engines and native UGig/CoinPay workflow CLIs, with OpenPRD and moshscript",
"repository": {
diff --git a/prd/0015-swarm-one-task-a-herd-of-agents.md b/prd/0015-swarm-one-task-a-herd-of-agents.md
new file mode 100644
index 00000000..189d8c2d
--- /dev/null
+++ b/prd/0015-swarm-one-task-a-herd-of-agents.md
@@ -0,0 +1,88 @@
+---
+openprd: "0.3"
+id: "0015"
+title: "Swarm — one task, a herd of agents, one answer"
+status: Draft
+authors:
+ - anthony@profullstack.com
+created: 2026-09-13
+updated: 2026-09-13
+repo: https://github.com/moshcoder/moshcode
+discussion:
+implementation: src/swarm.mjs
+tags:
+ - herd
+ - agents
+ - workflow
+ - ultracode
+supersedes:
+superseded-by:
+---
+
+## Problem
+
+Claude Code has ultracode: put the keyword in a prompt and the prompt becomes a workflow of agents that is planned, fanned out, verified and synthesised. It is the single most useful thing that CLI does at scale, and it is one vendor's.
+
+moshcode's herd is the same idea with the vendor removed. Sessions outlive the terminal, `herd prompt --wait` hands an agent work and blocks until it lands, the task ledger keeps what every session did, and moshscript can already wire a fan-out by hand: `herdStart` three times, `herdPrompt` three times, `herdWait`. Nobody does, because it is a script to write every time, and the pieces that make a swarm worth running, splitting the task so the agents do not collide and folding what they did back into one answer, are the pieces the script does not give you.
+
+So agents at scale exist in moshcode as parts. This PRD is the verb.
+
+## Goals
+
+- One command turns a task into a swarm: `moshcode swarm ""`.
+- Any engine moshcode can start can be the swarm, not one vendor. Whatever `ai()` can run headlessly can plan and synthesise; whatever `herd start` can run can be a member.
+- The operator sees each phase as it happens, and every piece of work is a task in the ledger they can open afterwards.
+- A swarm is bounded by default. Four agents at a time, the same number the claude engine's settings defaults cap Claude's own workflows at, so one swarm cannot eat the box the rest of the herd runs on.
+- A swarm degrades rather than fails. A plan that does not parse becomes one piece; a piece whose session never became ready is reported as failed and the synthesis says so; a synthesis that fails still leaves the pieces in the ledger.
+- The orchestration is testable without tmux or a model on the box.
+
+## Non-Goals
+
+- A general workflow language. Ultracode's script API (pipeline, barriers, budgets) is out of scope; moshscript already exists for anyone who wants to compose the herd by hand.
+- Cross-piece coordination while the swarm runs. Pieces are planned not to touch the same files; if the plan gets that wrong, the synthesis reports the contradiction and the operator resolves it.
+- Remote members. A swarm runs on this box's substrate in v1; PRD 0011's remote members are the obvious next step and this design does not preclude them.
+- Cost accounting beyond what `moshcode cost` already does per session.
+
+## Users
+
+- An operator with a task too wide for one session who does not want to write the moshscript.
+- A herd already running that wants a burst of parallel work without losing track of what each burst did.
+- moshcode itself: the swarm is what the pit reaches for when a task is an "all of these at once" task.
+
+## Requirements
+
+R1. `moshcode swarm ""` plans the task, fans it out, and prints one synthesised answer. It works from the CLI, the pit (`/swarm`) and moshscript (`swarm(...)`).
+
+R2. Planning is one headless engine call (`ai()`'s path, `aiExecArgs`). The engine is asked for a JSON array of at most `--agents` pieces, each a self-contained prompt that names the files it may touch and ends with a SUMMARY section. The first JSON array in the reply is the plan. A reply that does not parse, or a call that fails, degrades to one piece holding the whole task, and says so.
+
+R3. Fan-out starts one herd session per piece with `herd start --agent`, named `swarm--`, in the herd `swarm` (`--herd` overrides), at most `--agents` at a time (default 4, maximum 16). Each session is waited on until it draws its prompt, then prompted exactly as `herd prompt --wait` does, so the ledger holds the piece's output.
+
+R4. `--verify` runs one headless skeptic per piece, prompted to refute it and to default to refuted when unsure. Its verdict is attached to the piece and shown to the synthesis; it never drops a piece on its own.
+
+R5. Synthesis is one headless call over the pieces' outputs, truncated per piece, that writes the answer the operator should read: what was done, found, unfinished or contradicted, and what to do next.
+
+R6. Sessions are ended when the swarm is done. `--keep` leaves them for inspection and names them. The ledger is never pruned by a swarm.
+
+R7. `--plan-only` prints the plan and starts nothing. `--json` prints the whole run as data: engine, plan, one row per piece with session, task id, state, outcome, output and verdict, and the synthesis.
+
+R8. Exit codes follow the herd's: 0 when every piece finished and the synthesis was written, 1 otherwise.
+
+R9. The engine, the substrate and the ledger reach the orchestration only through an injectable dependency object, so `test/swarm.test.mjs` exercises planning, throttling, degradation, verification and synthesis with fakes.
+
+R10. `moshcode help swarm`, the README and the pit's `/help` document the verb, and the README command table is regenerated from the schema.
+
+## Design
+
+`src/swarm.mjs`. `parseSwarmArgs` is the flag grammar. `planPrompt`, `verifyPrompt` and `synthesisPrompt` are the three prompts, exported so their wording is testable. `parsePlan` and `parseVerdict` read the model's replies leniently (first array, first object). `throttled` is the concurrency gate. `runHeadless` is the engine call, with the engine's `stripEnv` applied so a swarm started from inside a Claude session does not inherit nested-session markers. `liveDeps` builds the real dependency object out of `herdStart`, `waitFor`, `herdPrompt` and `herdKill` with a capturing writer, and `findTask` for the ledger artifact. `runSwarm` is the four phases as data; `swarmCommand` is the CLI face.
+
+## Open questions
+
+- Whether the pit should offer a keyword trigger the way Claude Code does (a leading `swarm:` on a prompt line). The verb is enough to start with.
+- Whether a remote member should be eligible for a piece. Nothing in the design stops it once `herd start` can target one.
+
+## Acceptance
+
+- `moshcode swarm "…" --plan-only` prints a numbered plan and starts nothing.
+- `moshcode swarm "…" --agents 2` on a box with tmux and one installed engine ends with a synthesis, two closed tasks in the ledger, and no swarm sessions in `moshcode ps`.
+- `moshcode swarm "…" --keep` ends with the sessions still in `moshcode ps`.
+- `node --test test/swarm.test.mjs` passes without tmux or an engine.
diff --git a/prd/README.md b/prd/README.md
index e8785539..fb13831e 100644
--- a/prd/README.md
+++ b/prd/README.md
@@ -29,4 +29,6 @@ Start one with `moshcode prd ""` (TUI: `/prd`).
| [0011](0011-herd-agent-protocol.md) | Teach the herd the agent protocol — hooks-first state, a task ledger, and an A2A surface for local and remote agents | Draft |
| [0012](0012-billing-baked-into-the-agent-cli.md) | Bake billing into the agent CLI — timer, clients, teams, rates, invoices, rails | Draft |
| [0013](0013-persistent-ssh-workspaces.md) | Add persistent SSH workspaces for humans and agents | Draft |
+| [0014](0014-remote-mcp-session-gateway.md) | Expose live Moshcode sessions over remote MCP | Draft |
+| [0015](0015-swarm-one-task-a-herd-of-agents.md) | Swarm — one task, a herd of agents, one answer | Draft |
diff --git a/src/cli-schema.mjs b/src/cli-schema.mjs
index 8d42146e..a61d04c9 100644
--- a/src/cli-schema.mjs
+++ b/src/cli-schema.mjs
@@ -109,6 +109,32 @@ export const CORE_CLI_COMMANDS = [
+ "sessions live in a tmux server moshcode owns, or under script(1) when there is no tmux. "
+ "with neither, launches stay in the foreground and say so.",
},
+ {
+ name: "swarm",
+ group: "runtime",
+ description: "one task, a herd of agents, one answer — plan, fan out, verify, synthesise (PRD 0015)",
+ synopsis: [["moshcode swarm \"\" [--agents 4] [--engine claude] [--verify] [--plan-only] [--keep]", ""]],
+ flags: [
+ ["--agents ", "sessions at a time, and the most pieces the plan may have", "4"],
+ ["--engine ", "which engine plans, works and synthesises", "the first installed"],
+ ["--cwd ", "where every session works", "."],
+ ["--herd ", "the herd the sessions join", "swarm"],
+ ["--verify", "one skeptic per piece, prompted to refute it; the synthesis sees the verdicts", ""],
+ ["--plan-only", "print the plan and start nothing", ""],
+ ["--keep", "leave the sessions running afterwards", ""],
+ ["--timeout ", "how long one piece may take", "30m"],
+ ["--json", "the whole run as data: plan, one row per piece, synthesis", ""],
+ ],
+ examples: [
+ ["moshcode swarm \"port the auth routes and the dashboard to the new API\"", "planned, run 4 at a time, one answer"],
+ ["moshcode swarm \"audit src/ for unhandled promise rejections\" --agents 8 --verify", "wider, and reviewed"],
+ ["moshcode swarm \"…\" --plan-only", "see how it would split first"],
+ ],
+ seeAlso: ["herd", "ps", "wait", "run"],
+ note: "the same thing claude code calls ultracode, on any engine moshcode can start: one headless call splits the task into pieces that "
+ + "do not touch the same files, each piece runs in its own herd session (`moshcode herd task ` afterwards), and one more call "
+ + "folds the outputs into the answer. sessions are ended when it is done unless --keep. a plan that does not parse runs the task as one piece.",
+ },
{
name: "ps",
group: "runtime",
@@ -1559,6 +1585,8 @@ export const PIT_COMMANDS = [
description: "sessions that keep running when you leave" },
{ name: "ps", cli: "ps",
description: "what the herd is running, and which one wants you" },
+ { name: "swarm", args: " [--agents 4] [--verify]", cli: "swarm",
+ description: "one task, a herd of agents, one answer" },
{ name: "cost", aliases: ["usage"], args: "[name] [--all]", cli: "cost",
description: "what the herd is spending, from the engines' own logs" },
{ name: "attach", args: "", cli: "attach",
diff --git a/src/commands.mjs b/src/commands.mjs
index e495145a..456bd641 100644
--- a/src/commands.mjs
+++ b/src/commands.mjs
@@ -829,6 +829,7 @@ const COMMANDS = [
cliVerb("agents", "open the native agent view or launch autonomously (moshcode agents )"),
cliVerb("herd", "drive the herd (moshcode herd ) — see herdStart/herdWait for values"),
cliVerb("ps", "print the herd roster"),
+ cliVerb("swarm", "one task, a herd of agents, one answer (moshcode swarm \"\" [--agents 4])"),
cliVerb("cost", "print what the herd is spending (moshcode cost [name] [--all])"),
cliVerb("start", "raw-launch an engine (moshcode start )"),
cliVerb("install", "install an engine or workflow tool"),
diff --git a/src/engines.mjs b/src/engines.mjs
index 80d3974b..b7d97121 100644
--- a/src/engines.mjs
+++ b/src/engines.mjs
@@ -141,15 +141,37 @@ export const ENGINES = {
"env.CLAUDE_CODE_WORKFLOW_MAX_CONCURRENT_AGENTS": "4 agents at once, hard cap",
},
},
+ // Dialogs the engine puts up BEFORE any work, and the keys that answer them
+ // the way an unattended session wants (PRD 0015). The workspace trust
+ // check is the one that matters: its default is "No, exit", so a bare
+ // Enter — the reflex answer to "something is blocking" — ends the engine.
+ // Down, then Enter, picks "Yes, I trust this folder". Matched against the
+ // screen with ANSI stripped; a swarm answers each once and then waits for
+ // the prompt.
+ boot: [
+ { pattern: /\bIs this a project you created or one you trust\b/i, keys: ["Down", "Enter"], label: "trust this folder" },
+ ],
state: {
// The permission dialog's own heading, and the selector on its first
// option — the generic numbered-menu pattern would catch the second only
- // if the cursor happened to be resting there.
- blocked: [/\bdo you want to (?:proceed|make this edit|create)\b/i, /^\s*❯\s*1\.\s*yes/im],
+ // if the cursor happened to be resting there. The trust check is blocked
+ // too: it waits on a human exactly as a permission prompt does, and the
+ // roster read it as "unknown" until it was listed here.
+ blocked: [
+ /\bdo you want to (?:proceed|make this edit|create)\b/i,
+ /^\s*❯\s*1\.\s*yes/im,
+ /\bIs this a project you created or one you trust\b/i,
+ ],
// Claude Code parks "? for shortcuts" under the composer when it is
// waiting on you and nothing else, which is as close to an explicit
- // "idle" as it publishes.
- idle: [/\?\s+for shortcuts/i],
+ // "idle" as it publishes. Matched on its stem: on a narrow pane the
+ // status line is cut to "? for shortc…" and the member read as unknown.
+ // 2.1.x with permissions bypassed prints its mode footer there instead
+ // ("bypass permissions on (shift+tab to cycle)"), and an empty composer
+ // shows a placeholder ('❯ Try "refactor …"'). Both are checked after the
+ // shared working rules, so a footer that stays up while the engine
+ // works cannot outrank "esc to interrupt".
+ idle: [/\?\s+for shortc/i, /shift\+tab to cycle/i, /^\s*❯\s+Try\s+"/m],
},
},
codex: {
diff --git a/src/herd.mjs b/src/herd.mjs
index 8e490a82..66344048 100644
--- a/src/herd.mjs
+++ b/src/herd.mjs
@@ -263,6 +263,22 @@ export function tmux(args, { runner = spawnSync, env = process.env, encoding = "
}
}
+let pinTitleSupport;
+/**
+ * Can this tmux stop an application from renaming its pane? `allow-set-title`
+ * arrived in 3.4. Asked once per process: the answer is a property of the
+ * binary, and a start plan is built for every member.
+ */
+export function tmuxCanPinTitle({ runner = spawnSync, force = false } = {}) {
+ if (pinTitleSupport !== undefined && !force) return pinTitleSupport;
+ let version = "";
+ try { version = String(runner("tmux", ["-V"], { encoding: "utf8" })?.stdout || ""); }
+ catch { version = ""; }
+ const m = /tmux\s+(?:next-)?(\d+)\.(\d+)/.exec(version);
+ pinTitleSupport = Boolean(m) && (Number(m[1]) > 3 || (Number(m[1]) === 3 && Number(m[2]) >= 4));
+ return pinTitleSupport;
+}
+
/**
* The shell-command tmux runs for a session.
*
@@ -300,7 +316,7 @@ export function sessionCommand({ bin, args = [], stripEnv = [], setEnv = {}, exe
* moshcode's, and the detach key we print has to be the one that works even
* when the user's own tmux.conf rebinds prefix.
*/
-export function tmuxStartPlan({ name, cwd, command }) {
+export function tmuxStartPlan({ name, cwd, command, pinTitle = true }) {
// ONE tmux invocation, not two. A finished agent must stay readable — "which
// one is done?" is half the reason the roster exists, and a session that
// evaporates on exit can only ever answer "gone". But a short-lived command
@@ -310,7 +326,12 @@ export function tmuxStartPlan({ name, cwd, command }) {
// the session exist without the option.
return [
"-f", "/dev/null",
- "new-session", "-d", "-s", name, "-c", cwd, command,
+ // A detached session is 80x24 unless told otherwise, and an engine's
+ // status line truncates at 80 — Claude's "? for shortcuts", which the
+ // idle rule reads, arrived as "? for shortc…". A client that attaches
+ // resizes it to the real terminal; until then it is sized for a screen
+ // the classifier can read.
+ "new-session", "-d", "-s", name, "-c", cwd, "-x", "200", "-y", "50", command,
";", "set-option", "-t", name, "remain-on-exit", "on",
// Mouse on, so a click selects a pane and the status line's window list is
// clickable once you are inside. This server is moshcode's and starts from
@@ -321,6 +342,14 @@ export function tmuxStartPlan({ name, cwd, command }) {
// Set in the same invocation as the rest so a fast-exiting command cannot
// finish before it lands.
";", "select-pane", "-t", name, "-T", name,
+ // And keep it. An engine that sets its own terminal title (Claude Code
+ // writes "1 awaiting input · claude agents" the moment it is up) would
+ // otherwise overwrite the handle through OSC 0/2, and a member whose pane
+ // no longer answers to its name reads as `gone` on the roster while it is
+ // sitting there waiting for you. tmux 3.4+; on an older tmux the option is
+ // unknown and the whole invocation would fail, so startSession asks
+ // tmuxCanPinTitle first and an old tmux keeps today's behaviour.
+ ...(pinTitle ? [";", "set-option", "-w", "-t", name, "allow-set-title", "off"] : []),
];
}
@@ -639,7 +668,7 @@ export function startSession({
if (substrate === "tmux") {
const command = sessionCommand({ bin, args, stripEnv, setEnv: sessionEnv(name) });
- const started = tmux(tmuxStartPlan({ name, cwd, command }), { runner, env });
+ const started = tmux(tmuxStartPlan({ name, cwd, command, pinTitle: tmuxCanPinTitle({ runner }) }), { runner, env });
if (!started.ok) {
return { ok: false, error: new Error(started.stderr.trim() || started.error?.message || "tmux could not start the session") };
}
@@ -685,11 +714,29 @@ export function sendKeys(name, keys, { substrate = detectSubstrate(), runner = s
* text and regularly contains `;`, `$` or a bare `Enter`, all of which tmux
* would otherwise read as key names rather than characters.
*/
-export function sendPrompt(name, text, { substrate = detectSubstrate(), runner = spawnSync } = {}) {
+/**
+ * How long to let a typed prompt settle before Enter. An engine's composer
+ * treats keys that arrive within one tick as a paste, and the Enter on the
+ * heels of a long prompt lands *inside* the paste as its "+1 lines" — the
+ * screen shows "[Pasted text #1 +1 lines]" and nothing is ever submitted.
+ * Seen live with Claude Code 2.1 and a one-line prompt of a few hundred
+ * characters. A quarter second is longer than any paste window and shorter
+ * than anyone notices.
+ */
+export const PROMPT_SETTLE_MS = 250;
+
+function settle(ms) {
+ // Synchronous on purpose: sendPrompt is sync, and its callers are too.
+ try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); }
+ catch { /* no SharedArrayBuffer here — send without the pause */ }
+}
+
+export function sendPrompt(name, text, { substrate = detectSubstrate(), runner = spawnSync, settleMs = PROMPT_SETTLE_MS } = {}) {
if (substrate === "tmux") {
const pane = target(name, { runner });
const typed = tmux(["send-keys", "-t", pane, "-l", String(text)], { runner });
if (!typed.ok) return { ok: false, error: new Error(typed.stderr.trim() || "send-keys failed") };
+ if (settleMs > 0) settle(settleMs);
const entered = tmux(["send-keys", "-t", pane, "Enter"], { runner });
return entered.ok ? { ok: true } : { ok: false, error: new Error(entered.stderr.trim() || "send-keys failed") };
}
diff --git a/src/swarm.mjs b/src/swarm.mjs
new file mode 100644
index 00000000..4e0ee3d1
--- /dev/null
+++ b/src/swarm.mjs
@@ -0,0 +1,424 @@
+// Swarm — one task, a herd of agents, one answer (PRD 0015).
+//
+// Claude Code calls it ultracode: a prompt that becomes a workflow of agents,
+// planned, fanned out, verified and synthesised. The herd already has every
+// piece of that — sessions that outlive the terminal, `prompt --wait`, a task
+// ledger with each session's output — and `moshscript` could already wire them
+// together by hand. This is the verb that does it for you, with any engine
+// moshcode can start, not one vendor's.
+//
+// FOUR PHASES, and the operator sees each one:
+//
+// plan one headless engine call splits the task into independent
+// pieces that do not touch the same files. JSON in, JSON out;
+// a plan that does not parse degrades to one piece (the whole
+// task) rather than to nothing.
+// fan out one herd session per piece, `--agents` of them at a time
+// (default 4 — the same number the claude engine's settings
+// defaults cap Claude's own workflows at). Each is prompted and
+// waited on exactly the way `moshcode herd prompt --wait` does,
+// so every piece is a task in the ledger with its output.
+// verify optional: a skeptic per piece, prompted to refute it. What it
+// says is attached to the piece, never used to drop it — the
+// synthesis sees both and the operator decides.
+// synthesise one more headless call folds the pieces into an answer.
+//
+// The sessions are ended when the swarm is done unless `--keep` says
+// otherwise: four idle engines per swarm would fill the roster by lunchtime,
+// and the ledger keeps what they did either way (`moshcode herd task `).
+//
+// Everything that talks to an engine or a pty goes through `deps`, so the
+// orchestration is testable without tmux or a model on the box.
+import { spawnSync } from "node:child_process";
+import path from "node:path";
+
+import { ENGINES, aiExecArgs, pickAiEngine, resolveEngine, resolveExecutable } from "./engines.mjs";
+import { EXIT, herdKill, herdStart, ledgerRecorder, roster, waitFor } from "./herd-cli.mjs";
+import { stripAnsi } from "./herd-state.mjs";
+import { endTask, screenDelta, startTask } from "./herd-tasks.mjs";
+import { capture, sendKeys, sendPrompt, slugifyName } from "./herd.mjs";
+import { acid, amber, ash, bone, err, info, ok, warn } from "./ui.mjs";
+
+export const DEFAULT_AGENTS = 4;
+export const MAX_AGENTS = 16;
+const DEFAULT_TIMEOUT_MS = 30 * 60 * 1000;
+const BOOT_TIMEOUT_MS = 90 * 1000;
+const PIECE_CHARS = 6000;
+
+const USAGE = 'usage: moshcode swarm "" [--agents 4] [--engine claude] [--cwd .] [--verify] [--plan-only] [--keep] [--timeout 30m] [--json]';
+
+function parseDuration(raw, fallback) {
+ const m = /^(\d+)(ms|s|m|h)?$/.exec(String(raw || "").trim());
+ if (!m) return fallback;
+ const n = Number(m[1]);
+ return { ms: n, s: n * 1000, m: n * 60000, h: n * 3600000 }[m[2] || "s"];
+}
+
+/** The flags, and the task — every positional word that is not one. */
+export function parseSwarmArgs(argv = []) {
+ const flags = {
+ agents: DEFAULT_AGENTS, engine: null, cwd: process.cwd(), herd: "swarm", name: null,
+ verify: false, planOnly: false, keep: false, timeoutMs: DEFAULT_TIMEOUT_MS, json: false,
+ };
+ const words = [];
+ const errors = [];
+ for (let i = 0; i < argv.length; i++) {
+ const a = String(argv[i]);
+ const eq = a.indexOf("=");
+ const [key, inline] = a.startsWith("--") && eq > 0 ? [a.slice(0, eq), a.slice(eq + 1)] : [a, undefined];
+ const value = () => (inline !== undefined ? inline : argv[++i]);
+ if (key === "--agents") {
+ const n = Number(value());
+ if (!Number.isInteger(n) || n < 1 || n > MAX_AGENTS) errors.push(`--agents must be a whole number from 1 to ${MAX_AGENTS}`);
+ else flags.agents = n;
+ } else if (key === "--engine") flags.engine = value();
+ else if (key === "--cwd") flags.cwd = path.resolve(value() || ".");
+ else if (key === "--herd") flags.herd = slugifyName(value());
+ else if (key === "--name") flags.name = slugifyName(value());
+ else if (key === "--timeout") flags.timeoutMs = parseDuration(value(), flags.timeoutMs);
+ else if (key === "--verify") flags.verify = true;
+ else if (key === "--plan-only") flags.planOnly = true;
+ else if (key === "--keep") flags.keep = true;
+ else if (key === "--json") flags.json = true;
+ else if (a.startsWith("-") && a.length > 1) errors.push(`unknown flag ${a}`);
+ else words.push(a);
+ }
+ return { ...flags, task: words.join(" ").trim(), errors };
+}
+
+/* ------------------------------------------------------------- the prompts */
+
+// The headless calls run in the operator's working directory, and an engine
+// in print mode still has its tools. Seen live: a synthesis asked to fold two
+// failed pieces into an answer went and did the task itself instead. The
+// planner, the skeptic and the synthesis are asked to think, not act — the
+// agents in the herd are the ones that act.
+const NO_TOOLS = "Do not run commands, read or write files, or use any tool for this: answer from the text you are given, and nothing else.";
+
+export function planPrompt({ task, agents, cwd }) {
+ return [
+ `You are planning a swarm of up to ${agents} autonomous coding agents. Each will work IN PARALLEL in its own session, in the directory ${cwd}, and cannot see the others.`,
+ `Split the task below into at most ${agents} independent pieces that do not edit the same files. Fewer pieces is better than pieces that overlap; one piece is fine when the task does not split.`,
+ 'Reply with ONLY a JSON array and nothing else — no prose, no code fence: [{"title": "short name", "prompt": "the full instructions for that agent"}].',
+ "Each prompt must be self-contained, name the files it may touch, and tell the agent to end its work with a section headed SUMMARY: saying what it did and what it found.",
+ NO_TOOLS,
+ "",
+ "TASK:",
+ task,
+ ].join("\n");
+}
+
+export function verifyPrompt({ task, piece, output }) {
+ return [
+ "You are a skeptical reviewer. Another agent was given one piece of a larger task and reports the output below. Try to refute it: look for claims that are not backed by what it shows, work it says it did but did not, and anything that would break the larger task.",
+ 'Reply with ONLY a JSON object and nothing else: {"refuted": true|false, "reason": "one or two sentences"}. Default to refuted=true when you are not sure.',
+ NO_TOOLS,
+ "",
+ `LARGER TASK: ${task}`,
+ `PIECE: ${piece.title}`,
+ `INSTRUCTIONS GIVEN: ${piece.prompt}`,
+ "",
+ "OUTPUT:",
+ output || "(the agent produced no output)",
+ ].join("\n");
+}
+
+export function synthesisPrompt({ task, results }) {
+ const parts = results.map((r, i) => [
+ `--- piece ${i + 1}: ${r.title} (${r.state}${r.verified ? `, review: ${r.verified.refuted ? "REFUTED" : "stands"} — ${r.verified.reason}` : ""}) ---`,
+ r.artifact || "(no output captured)",
+ ].join("\n"));
+ return [
+ `A swarm of ${results.length} agents worked in parallel on the task below, one piece each. Their outputs follow. Write the single answer the operator should read: what was done, what was found, what is unfinished or contradicted, and what to do next. Plain prose, no preamble, no restating the task.`,
+ NO_TOOLS,
+ "",
+ `TASK: ${task}`,
+ "",
+ ...parts,
+ ].join("\n");
+}
+
+/* --------------------------------------------------------------- the plan */
+
+/** The first JSON array in a model's reply, validated into pieces. */
+export function parsePlan(text, { agents = DEFAULT_AGENTS } = {}) {
+ const s = String(text || "");
+ const from = s.indexOf("[");
+ const to = s.lastIndexOf("]");
+ if (from < 0 || to <= from) return null;
+ let parsed;
+ try { parsed = JSON.parse(s.slice(from, to + 1)); }
+ catch { return null; }
+ if (!Array.isArray(parsed)) return null;
+ const pieces = parsed
+ .filter((p) => p && typeof p === "object" && typeof p.prompt === "string" && p.prompt.trim())
+ .map((p, i) => ({ title: String(p.title || `piece ${i + 1}`).trim().slice(0, 80), prompt: p.prompt.trim() }));
+ return pieces.length ? pieces.slice(0, agents) : null;
+}
+
+export function parseVerdict(text) {
+ const s = String(text || "");
+ const from = s.indexOf("{");
+ const to = s.lastIndexOf("}");
+ if (from < 0 || to <= from) return { refuted: null, reason: "the reviewer did not answer in the expected form" };
+ try {
+ const v = JSON.parse(s.slice(from, to + 1));
+ return { refuted: typeof v.refuted === "boolean" ? v.refuted : null, reason: String(v.reason || "").slice(0, 400) };
+ } catch { return { refuted: null, reason: "the reviewer did not answer in the expected form" }; }
+}
+
+/** `swarm-` — what the sessions are named after, within NAME_RE. */
+export function swarmPrefix(task, { name = null } = {}) {
+ const base = name || slugifyName(task).slice(0, 14).replace(/-+$/, "") || "task";
+ return `swarm-${base}`.slice(0, 28);
+}
+
+/* ----------------------------------------------------------- the engines */
+
+/** Run an engine headlessly and return what it printed. Throws on failure. */
+export function runHeadless(engine, prompt, { cwd = process.cwd(), runner = spawnSync, env = process.env } = {}) {
+ const spec = ENGINES[engine];
+ if (!spec) throw new Error(`no engine named ${JSON.stringify(engine)}`);
+ const bin = resolveExecutable(spec.bin, spec.binDirs || []) || spec.bin;
+ const args = aiExecArgs(engine, prompt);
+ const clean = { ...env };
+ for (const k of spec.stripEnv || []) delete clean[k];
+ const res = runner(bin, args, { cwd, encoding: "utf8", env: clean, maxBuffer: 16 * 1024 * 1024 });
+ if (res.error) throw res.error;
+ if (res.status !== 0) {
+ throw new Error(`${engine} exited with ${res.signal || res.status}${res.stderr ? `: ${String(res.stderr).trim().slice(0, 200)}` : ""}`);
+ }
+ return String(res.stdout || "").trim();
+}
+
+/** The boot-spec entry this screen matches, or null. Answered once each. */
+export function bootAnswer(engine, screen) {
+ const text = stripAnsi(String(screen || ""));
+ return (ENGINES[engine]?.boot || []).find((entry) => entry.pattern.test(text)) || null;
+}
+
+const sleep = (ms) => new Promise((r) => { setTimeout(r, ms); });
+
+/**
+ * Wait for an engine to draw its prompt, answering the dialogs its spec
+ * names on the way. Same shape of result as herd-cli's waitFor.
+ */
+export async function waitForPrompt(name, {
+ engine, timeoutMs = BOOT_TIMEOUT_MS, intervalMs = 500, now = () => Date.now(),
+ look = (n) => roster().find((s) => s.name === n) || null,
+ screen = (n) => capture(n, { lines: 40 }),
+ answer = (n, keys) => sendKeys(n, keys),
+} = {}) {
+ const deadline = now() + timeoutMs;
+ const answered = new Set();
+ let state = "unknown";
+ for (;;) {
+ const session = look(name);
+ if (!session) return { outcome: "gone", state: "gone" };
+ state = session.state;
+ if (state === "idle") return { outcome: "matched", state };
+ const dialog = bootAnswer(engine, screen(name));
+ if (dialog && !answered.has(dialog.pattern.source)) {
+ answered.add(dialog.pattern.source);
+ answer(name, dialog.keys);
+ await sleep(intervalMs * 3);
+ continue;
+ }
+ if (!session.alive || state === "done") return { outcome: "ended", state };
+ if (now() >= deadline) return { outcome: "timeout", state };
+ await sleep(intervalMs);
+ }
+}
+
+/** What a swarm needs from the outside world. Tests hand in fakes. */
+export function liveDeps() {
+ const quiet = () => {};
+ const look = (name) => roster().find((s) => s.name === name) || null;
+ return {
+ ai: (engine, prompt, { cwd }) => runHeadless(engine, prompt, { cwd }),
+ // The engine's autonomous-session flags, spelled out. NOT `--agent`: for
+ // an engine with an `agentsView` that opens its agents *overview* — the
+ // right screen for `/agents claude`, and a screen where a typed prompt
+ // starts a background job somewhere else instead of working here. Seen
+ // live: two pieces "finished" in 8s with a roster for output.
+ start: (name, { engine, cwd, herd }) => {
+ const lines = [];
+ const argv = [engine, "--name", name, "--cwd", cwd, "--herd", herd, "--json", ...(ENGINES[engine]?.agentArgs || [])];
+ const code = herdStart(argv, { write: (l) => lines.push(l) });
+ return code === EXIT.matched ? { ok: true } : { ok: false, error: lines.join(" ") || "could not start the session" };
+ },
+ // An engine takes a moment to draw its prompt; keystrokes typed before
+ // that are lost. Idle is "ready". A dialog before any work — "trust this
+ // folder?" on a directory the engine has not seen — is answered from the
+ // engine's own boot spec, each one once, and then the wait resumes.
+ // Anything the spec does not name is left alone and reported: guessing
+ // at a dialog is how an agent ends up saying yes to something it should
+ // not have — or, with Claude's trust check, "No, exit".
+ boot: (name, { engine }) => waitForPrompt(name, { engine }),
+ // `herd prompt --wait`, with one difference: it will not take an idle
+ // screen as "finished" until it has seen the engine work. herd prompt
+ // gives an engine eight seconds to notice its input; a member that is
+ // still settling when the text lands needs longer, and an idle screen
+ // seen before the engine has read a word is not an answer. Same ledger,
+ // same task ids, same `moshcode herd task ` afterwards.
+ prompt: async (name, text, { timeoutMs }) => {
+ const session = look(name);
+ if (!session?.alive) return { ok: false, task: null, outcome: "gone", state: "gone", artifact: "", error: `no live session named ${JSON.stringify(name)}` };
+ const at = Date.now();
+ const baseline = capture(name, { lines: 60 });
+ const task = startTask(name, text, { screen: baseline, now: at, state: session.state });
+ const sent = sendPrompt(name, text);
+ if (!sent.ok) {
+ const error = `moshcode could not type into ${name}: ${sent.error?.message || sent.error}`;
+ endTask(name, task, { state: "done", artifact: error });
+ return { ok: false, task, outcome: "failed", state: session.state, artifact: "", error };
+ }
+ const record = ledgerRecorder(name);
+ const began = await waitFor(name, ["working", "blocked", "done"], { timeoutMs: 30 * 1000, intervalMs: 500, onState: record });
+ const result = began.outcome === "gone" || (began.outcome === "matched" && began.state !== "working")
+ ? began
+ : await waitFor(name, ["blocked", "done", "idle"], { timeoutMs, onState: record });
+ const artifact = result.outcome === "gone" ? "" : screenDelta(baseline, capture(name, { lines: 400 }));
+ endTask(name, task, { state: result.state, artifact });
+ return { ok: true, task, outcome: result.outcome, state: result.state, artifact, error: null };
+ },
+ kill: async (name) => { await herdKill([name], { write: quiet }); },
+ };
+}
+
+/* ----------------------------------------------------------- the swarm */
+
+/** Run `fn` over `items`, at most `limit` at a time, preserving order. */
+export async function throttled(items, limit, fn) {
+ const results = new Array(items.length);
+ let next = 0;
+ const workers = Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, async () => {
+ for (;;) {
+ const i = next++;
+ if (i >= items.length) return;
+ results[i] = await fn(items[i], i);
+ }
+ });
+ await Promise.all(workers);
+ return results;
+}
+
+/**
+ * The whole thing, as data. `write` gets the narration; the return value is
+ * what `--json` prints. Never throws for an engine's failure — a piece that
+ * failed is a piece with `state: "failed"` and the synthesis says so.
+ */
+export async function runSwarm(options, { write = () => {}, deps = liveDeps(), engineOf = pickAiEngine } = {}) {
+ const { task, agents, cwd, herd, verify, planOnly, keep, timeoutMs } = options;
+ const engine = engineOf(options.engine);
+ if (!engine) {
+ return { ok: false, error: options.engine ? `no installed engine named ${JSON.stringify(options.engine)}` : "no engine installed — moshcode install claude" };
+ }
+ if (!Object.hasOwn(ENGINES, engine) || !ENGINES[engine].bin) return { ok: false, error: `no engine named ${JSON.stringify(engine)}` };
+
+ // 1. plan
+ write(info(`plan — ${engine} is splitting the task into up to ${agents} pieces`));
+ let plan = null, planNote = null;
+ try {
+ const reply = deps.ai(engine, planPrompt({ task, agents, cwd }), { cwd });
+ plan = parsePlan(reply, { agents });
+ if (!plan) planNote = "the plan did not parse — running the task as one piece";
+ } catch (error) {
+ planNote = `planning failed (${error.message || error}) — running the task as one piece`;
+ }
+ if (!plan) plan = [{ title: "the whole task", prompt: `${task}\n\nEnd your work with a section headed SUMMARY: saying what you did and what you found.` }];
+ if (planNote) write(warn(planNote));
+ for (const [i, piece] of plan.entries()) write(` ${acid(String(i + 1).padStart(2))} ${bone(piece.title)}`);
+ if (planOnly) return { ok: true, engine, task, agents, plan, results: [], synthesis: null, planOnly: true };
+
+ // 2. fan out
+ const prefix = swarmPrefix(task, { name: options.name });
+ write(info(`swarm — ${plan.length} session${plan.length === 1 ? "" : "s"}, ${Math.min(agents, plan.length)} at a time (${engine}, herd ${herd})`));
+ const results = await throttled(plan, agents, async (piece, i) => {
+ const name = `${prefix}-${i + 1}`;
+ const started = deps.start(name, { engine, cwd, herd });
+ if (!started.ok) {
+ write(err(`${name} — could not start: ${started.error}`));
+ return { ...piece, session: name, task: null, state: "failed", outcome: "failed", artifact: "", error: String(started.error) };
+ }
+ const boot = await deps.boot(name, { engine });
+ if (boot.outcome !== "matched") {
+ write(err(`${name} — never became ready (${boot.outcome}, ${boot.state})`));
+ if (!keep) await deps.kill(name);
+ return { ...piece, session: name, task: null, state: "failed", outcome: boot.outcome, artifact: "", error: "the engine never became ready" };
+ }
+ write(` ${ash("→")} ${bone(name)} ${ash(piece.title)}`);
+ // The prompt is typed into a terminal; a newline there submits early.
+ const text = piece.prompt.replace(/\s*\n+\s*/g, " ").trim();
+ const done = await deps.prompt(name, text, { timeoutMs });
+ if (!done.ok) {
+ write(err(`${name} — ${done.error || "the prompt was not delivered"}`));
+ if (!keep) await deps.kill(name);
+ return { ...piece, session: name, task: done.task, state: "failed", outcome: done.outcome, artifact: done.artifact || "", error: done.error };
+ }
+ const mark = done.outcome === "matched" ? acid("✓") : amber("~");
+ write(` ${mark} ${bone(name)} ${ash(`${done.state} · ${done.task || ""}`)}`);
+ if (!keep) await deps.kill(name);
+ return { ...piece, session: name, task: done.task, state: done.state || "unknown", outcome: done.outcome, artifact: done.artifact || "", error: null };
+ });
+
+ // 3. verify
+ if (verify) {
+ write(info(`verify — one skeptic per piece (${engine})`));
+ for (const r of results) {
+ if (r.state === "failed") continue;
+ try {
+ r.verified = parseVerdict(deps.ai(engine, verifyPrompt({ task, piece: r, output: r.artifact.slice(-PIECE_CHARS) }), { cwd }));
+ } catch (error) {
+ r.verified = { refuted: null, reason: `the reviewer failed: ${error.message || error}` };
+ }
+ const mark = r.verified.refuted === false ? acid("✓") : r.verified.refuted ? amber("✗") : ash("?");
+ write(` ${mark} ${bone(r.session)} ${ash(r.verified.reason)}`);
+ }
+ }
+
+ // 4. synthesise
+ write(info(`synthesis — ${engine} is folding ${results.length} piece${results.length === 1 ? "" : "s"} into one answer`));
+ let synthesis = null, synthesisError = null;
+ try {
+ synthesis = deps.ai(engine, synthesisPrompt({
+ task, results: results.map((r) => ({ ...r, artifact: (r.artifact || r.error || "").slice(-PIECE_CHARS) })),
+ }), { cwd });
+ } catch (error) {
+ synthesisError = `synthesis failed: ${error.message || error}`;
+ write(err(synthesisError));
+ }
+ return { ok: !synthesisError, engine, task, agents, plan, results, synthesis, error: synthesisError, kept: keep };
+}
+
+/* ------------------------------------------------------------ the command */
+
+export async function swarmCommand(argv = [], { write = console.log, deps, engineOf } = {}) {
+ const options = parseSwarmArgs(argv);
+ if (options.errors.length) { for (const e of options.errors) write(err(e)); write(err(USAGE)); return EXIT.usage; }
+ if (!options.task) { write(err(USAGE)); return EXIT.usage; }
+ if (options.engine && !resolveEngine(options.engine)) {
+ write(err(`no engine named ${JSON.stringify(options.engine)} — one of ${Object.keys(ENGINES).join(", ")}`));
+ return EXIT.usage;
+ }
+
+ const narrate = options.json ? () => {} : write;
+ const result = await runSwarm(options, { write: narrate, ...(deps ? { deps } : {}), ...(engineOf ? { engineOf } : {}) });
+
+ if (options.json) { write(JSON.stringify(result, null, 2)); return result.ok ? EXIT.matched : EXIT.usage; }
+ if (!result.ok && !result.results?.length) { write(err(result.error)); return EXIT.usage; }
+ if (result.planOnly) { write(info("plan only — nothing was started.")); return EXIT.matched; }
+
+ const failed = result.results.filter((r) => r.state === "failed").length;
+ write("");
+ if (result.synthesis) write(result.synthesis);
+ if (result.error) write(err(result.error));
+ write("");
+ const tasks = result.results.filter((r) => r.task).map((r) => r.task);
+ write(failed
+ ? warn(`${result.results.length - failed} of ${result.results.length} pieces finished; ${failed} failed.`)
+ : ok(`${result.results.length} piece${result.results.length === 1 ? "" : "s"} finished.`));
+ if (tasks.length) write(ash(` ledger: ${tasks.map((t) => `moshcode herd task ${t}`).join(" · ")}`));
+ if (result.kept) write(info(`sessions kept: ${result.results.map((r) => r.session).join(", ")} — moshcode ps`));
+ return result.ok && !failed ? EXIT.matched : EXIT.usage;
+}
diff --git a/src/tui.mjs b/src/tui.mjs
index f571eb5e..6ee26292 100644
--- a/src/tui.mjs
+++ b/src/tui.mjs
@@ -1121,6 +1121,11 @@ export async function tui() {
// sessions run somewhere that outlives it.
if (cmd === "herd") { await herdCommand(rest); continue; }
if (cmd === "ps") { await herdCommand(["ps", ...rest]); continue; }
+ if (cmd === "swarm") {
+ const { swarmCommand } = await import("./swarm.mjs");
+ await swarmCommand(rest, { write: (l) => console.log(` ${l}`) });
+ continue;
+ }
if (cmd === "cost" || cmd === "usage") { await herdCommand(["cost", ...rest]); continue; }
if (cmd === "kill") { await herdCommand(["kill", ...rest]); continue; }
if (cmd === "wait") { await herdCommand(["wait", ...rest]); continue; }
diff --git a/test/herd-pinned-title.test.mjs b/test/herd-pinned-title.test.mjs
new file mode 100644
index 00000000..57d7f741
--- /dev/null
+++ b/test/herd-pinned-title.test.mjs
@@ -0,0 +1,35 @@
+// A member's pane title is its handle on the roster, and an engine that sets
+// its own terminal title would take it away. Seen live: Claude Code writes
+// "1 awaiting input · claude agents" the moment it is up, and the member read
+// as `gone` while it sat there waiting for a prompt.
+import test from "node:test";
+import assert from "node:assert/strict";
+
+import { tmuxCanPinTitle, tmuxStartPlan } from "../src/herd.mjs";
+
+test("the start plan pins the pane title so an engine cannot rename itself off the roster", () => {
+ const plan = tmuxStartPlan({ name: "api", cwd: "/x", command: "exec claude" });
+ const at = plan.indexOf("allow-set-title");
+ assert.ok(at > 0, "allow-set-title is not in the plan");
+ assert.equal(plan[at + 1], "off");
+ assert.ok(plan.slice(0, at).includes("-T"), "the title must be set before it is pinned");
+ assert.equal(plan[at - 2], "-t");
+ assert.equal(plan[at - 1], "api");
+ assert.equal(plan[at - 3], "-w", "allow-set-title is a window option");
+});
+
+test("an old tmux gets the plan without the option it does not know", () => {
+ const plan = tmuxStartPlan({ name: "api", cwd: "/x", command: "exec claude", pinTitle: false });
+ assert.ok(!plan.includes("allow-set-title"));
+ assert.ok(plan.includes("-T"), "the title is still set — it is the handle for the pane on every tmux");
+});
+
+test("tmuxCanPinTitle reads the version: 3.4 or newer", () => {
+ const at = (v) => tmuxCanPinTitle({ force: true, runner: () => ({ stdout: `tmux ${v}\n` }) });
+ assert.equal(at("3.6"), true);
+ assert.equal(at("3.4"), true);
+ assert.equal(at("3.3a"), false);
+ assert.equal(at("2.9"), false);
+ assert.equal(at("next-3.5"), true);
+ assert.equal(tmuxCanPinTitle({ force: true, runner: () => { throw new Error("no tmux"); } }), false);
+});
diff --git a/test/herd-send-prompt.test.mjs b/test/herd-send-prompt.test.mjs
new file mode 100644
index 00000000..824f9cae
--- /dev/null
+++ b/test/herd-send-prompt.test.mjs
@@ -0,0 +1,52 @@
+// Typing a prompt into a member: the text, a beat, then Enter — as two
+// keystroke batches, because an Enter that arrives inside the same tick as a
+// long prompt is swallowed into the paste and nothing is submitted.
+import test from "node:test";
+import assert from "node:assert/strict";
+
+import { PROMPT_SETTLE_MS, sendPrompt } from "../src/herd.mjs";
+
+function fakeTmux() {
+ const calls = [];
+ const runner = (bin, args) => {
+ calls.push({ bin, args, at: process.hrtime.bigint() });
+ if (args.includes("list-panes")) return { status: 0, stdout: "api\t%3\tapi\t@1\t0\n", stderr: "" };
+ return { status: 0, stdout: "", stderr: "" };
+ };
+ return { calls, runner };
+}
+
+test("the prompt is typed literally, then Enter is sent on its own", () => {
+ const { calls, runner } = fakeTmux();
+ const result = sendPrompt("api", "port the auth routes", { substrate: "tmux", runner, settleMs: 0 });
+ assert.equal(result.ok, true);
+ const sends = calls.filter((c) => c.args.includes("send-keys")).map((c) => c.args);
+ assert.equal(sends.length, 2);
+ assert.deepEqual(sends[0].slice(-3), ["%3", "-l", "port the auth routes"], "the text goes to the pane id, literally");
+ assert.deepEqual(sends[1].slice(-2), ["%3", "Enter"]);
+});
+
+test("there is a beat between the text and the Enter", () => {
+ // Seen live: without it Claude Code showed "[Pasted text #1 +1 lines]" and
+ // sat there. The default is a quarter second; the test uses a shorter one so
+ // it is measurable without being slow.
+ assert.ok(PROMPT_SETTLE_MS >= 100, "the default settle must be longer than a paste window");
+ const { calls, runner } = fakeTmux();
+ sendPrompt("api", "x".repeat(400), { substrate: "tmux", runner, settleMs: 40 });
+ const sends = calls.filter((c) => c.args.includes("send-keys"));
+ const gapMs = Number(sends[1].at - sends[0].at) / 1e6;
+ assert.ok(gapMs >= 35, `Enter followed the text after ${gapMs.toFixed(1)}ms — no settle`);
+});
+
+test("a failed send reports the error and never presses Enter", () => {
+ const calls = [];
+ const runner = (bin, args) => {
+ calls.push(args);
+ if (args.includes("list-panes")) return { status: 0, stdout: "", stderr: "" };
+ return { status: 1, stdout: "", stderr: "can't find pane" };
+ };
+ const result = sendPrompt("api", "hi", { substrate: "tmux", runner, settleMs: 0 });
+ assert.equal(result.ok, false);
+ assert.match(result.error.message, /can't find pane/);
+ assert.equal(calls.filter((a) => a.includes("Enter")).length, 0);
+});
diff --git a/test/swarm.test.mjs b/test/swarm.test.mjs
new file mode 100644
index 00000000..6b744d30
--- /dev/null
+++ b/test/swarm.test.mjs
@@ -0,0 +1,347 @@
+// Swarm (PRD 0015): the flag grammar, the lenient readers of what a model
+// says, the gate that keeps a swarm to N at a time, and the four phases run
+// against fakes — no tmux, no model.
+import test from "node:test";
+import assert from "node:assert/strict";
+
+import {
+ DEFAULT_AGENTS, MAX_AGENTS, bootAnswer, parsePlan, parseSwarmArgs, parseVerdict, planPrompt, runSwarm,
+ swarmCommand, swarmPrefix, synthesisPrompt, throttled, verifyPrompt, waitForPrompt,
+} from "../src/swarm.mjs";
+import { ENGINES } from "../src/engines.mjs";
+
+/* -------------------------------------------------------------- the flags */
+
+test("the task is every positional word, so the pit and the CLI agree", () => {
+ const cli = parseSwarmArgs(["port the auth routes", "--agents", "3"]);
+ const pit = parseSwarmArgs(["port", "the", "auth", "routes", "--agents=3"]);
+ assert.equal(cli.task, "port the auth routes");
+ assert.equal(pit.task, "port the auth routes");
+ assert.equal(cli.agents, 3);
+ assert.equal(pit.agents, 3);
+});
+
+test("four at a time is the default, and the cap is the herd's", () => {
+ assert.equal(DEFAULT_AGENTS, 4);
+ assert.equal(parseSwarmArgs(["x"]).agents, 4);
+ assert.match(parseSwarmArgs(["x", "--agents", "0"]).errors[0], /1 to/);
+ assert.match(parseSwarmArgs(["x", "--agents", String(MAX_AGENTS + 1)]).errors[0], /1 to/);
+ assert.match(parseSwarmArgs(["x", "--agents", "lots"]).errors[0], /whole number/);
+});
+
+test("the four defaults match the claude engine's settings cap", () => {
+ // The whole point of the number: a swarm may not run more agents at once
+ // than moshcode tells Claude's own workflows to.
+ assert.equal(String(DEFAULT_AGENTS), ENGINES.claude.settings.defaults.env.CLAUDE_CODE_WORKFLOW_MAX_CONCURRENT_AGENTS);
+});
+
+test("an unknown flag is an error, not part of the task", () => {
+ const parsed = parseSwarmArgs(["fix it", "--fast"]);
+ assert.deepEqual(parsed.errors, ["unknown flag --fast"]);
+ assert.equal(parsed.task, "fix it");
+});
+
+test("--timeout takes the herd's durations", () => {
+ assert.equal(parseSwarmArgs(["x", "--timeout", "5m"]).timeoutMs, 300000);
+ assert.equal(parseSwarmArgs(["x", "--timeout=90s"]).timeoutMs, 90000);
+});
+
+test("session names come from the task and fit the herd's name rule", () => {
+ const prefix = swarmPrefix("Port the auth routes and the dashboard to the new API");
+ assert.match(prefix, /^swarm-[a-z0-9-]+$/);
+ assert.ok(prefix.length + 3 <= 32, "prefix plus -NN must fit NAME_RE");
+ assert.equal(swarmPrefix("anything", { name: "api" }), "swarm-api");
+ assert.equal(swarmPrefix("!!!"), "swarm-agent", "the herd's own fallback name for a task with no letters");
+});
+
+test("claude's trust dialog is answered with Down then Enter, never a bare Enter", () => {
+ // Seen live: the dialog's default is "No, exit". A swarm that pressed Enter
+ // on "something is blocking at boot" ended its own engine.
+ const screen = " Quick safety check: Is this a project you created or one you trust? \n ❯ No, exit\n Yes, I trust this folder\n";
+ const answer = bootAnswer("claude", screen);
+ assert.ok(answer, "the trust dialog was not recognised");
+ assert.deepEqual(answer.keys, ["Down", "Enter"]);
+ assert.equal(bootAnswer("claude", "? for shortcuts"), null);
+ assert.equal(bootAnswer("codex", screen), null, "an engine with no boot spec answers nothing");
+});
+
+test("waiting for the prompt answers a boot dialog once and then sees idle", async () => {
+ const trust = " Is this a project you created or one you trust?\n ❯ No, exit\n Yes, I trust this folder";
+ let screens = [trust, trust, "? for shortcuts"];
+ let states = ["unknown", "unknown", "idle"];
+ const answers = [];
+ let t = 0;
+ const result = await waitForPrompt("s", {
+ engine: "claude", timeoutMs: 10000, intervalMs: 1, now: () => (t += 1),
+ look: () => ({ name: "s", alive: true, state: states.shift() ?? "idle" }),
+ screen: () => screens.shift() ?? "",
+ answer: (name, keys) => answers.push(keys),
+ });
+ assert.deepEqual(result, { outcome: "matched", state: "idle" });
+ assert.deepEqual(answers, [["Down", "Enter"]], "the same dialog is never answered twice");
+});
+
+test("waiting for the prompt gives up on a session that ends or times out", async () => {
+ const ended = await waitForPrompt("s", {
+ engine: "claude", timeoutMs: 10000, intervalMs: 1,
+ look: () => ({ name: "s", alive: false, state: "done" }), screen: () => "", answer: () => {},
+ });
+ assert.equal(ended.outcome, "ended");
+ let t = 0;
+ const late = await waitForPrompt("s", {
+ engine: "claude", timeoutMs: 5, intervalMs: 1, now: () => (t += 3),
+ look: () => ({ name: "s", alive: true, state: "unknown" }), screen: () => "", answer: () => {},
+ });
+ assert.equal(late.outcome, "timeout");
+ const gone = await waitForPrompt("s", { engine: "claude", look: () => null, screen: () => "", answer: () => {} });
+ assert.equal(gone.outcome, "gone");
+});
+
+/* ------------------------------------------------------------ the readers */
+
+test("the plan is the first JSON array in the reply, whatever surrounds it", () => {
+ const reply = 'Sure! Here is the split:\n```json\n[{"title":"a","prompt":"do a"},{"title":"b","prompt":"do b"}]\n```\nGood luck.';
+ assert.deepEqual(parsePlan(reply), [{ title: "a", prompt: "do a" }, { title: "b", prompt: "do b" }]);
+});
+
+test("a plan is capped at --agents and drops entries with no prompt", () => {
+ const reply = JSON.stringify([{ title: "a", prompt: "x" }, { title: "b" }, { prompt: "y" }, { title: "d", prompt: "z" }]);
+ const plan = parsePlan(reply, { agents: 2 });
+ assert.deepEqual(plan.map((p) => p.prompt), ["x", "y"]);
+ assert.equal(plan[1].title, "piece 2", "an untitled piece still gets a name");
+});
+
+test("a plan that does not parse is null, not a throw", () => {
+ assert.equal(parsePlan("I cannot split this."), null);
+ assert.equal(parsePlan("[not json"), null);
+ assert.equal(parsePlan("[]"), null);
+ assert.equal(parsePlan('{"title":"a","prompt":"b"}'), null);
+});
+
+test("a verdict is read leniently and defaults to unknown", () => {
+ assert.deepEqual(parseVerdict('{"refuted": false, "reason": "it shows the diff"}'), { refuted: false, reason: "it shows the diff" });
+ assert.equal(parseVerdict("no").refuted, null);
+ assert.equal(parseVerdict('{"reason":"hmm"}').refuted, null);
+});
+
+/* ------------------------------------------------------------ the prompts */
+
+test("the planning prompt asks for pieces that do not collide, as bare JSON", () => {
+ const p = planPrompt({ task: "T", agents: 3, cwd: "/x" });
+ assert.match(p, /at most 3/);
+ assert.match(p, /do not edit the same files/);
+ assert.match(p, /ONLY a JSON array/);
+ assert.match(p, /SUMMARY:/);
+ assert.match(p, /\/x/);
+ assert.ok(p.endsWith("T"));
+});
+
+test("the headless calls are told to think, not act", () => {
+ // Seen live: a synthesis run in the working directory did the task itself
+ // instead of summarising what the agents had done.
+ const guard = /Do not run commands, read or write files, or use any tool/;
+ assert.match(planPrompt({ task: "T", agents: 2, cwd: "/x" }), guard);
+ assert.match(verifyPrompt({ task: "T", piece: { title: "a", prompt: "a" }, output: "o" }), guard);
+ assert.match(synthesisPrompt({ task: "T", results: [] }), guard);
+});
+
+test("the verifier is told to refute and to default to refuted", () => {
+ const p = verifyPrompt({ task: "T", piece: { title: "a", prompt: "do a" }, output: "did a" });
+ assert.match(p, /Try to refute/);
+ assert.match(p, /Default to refuted=true/);
+ assert.match(p, /did a$/);
+});
+
+test("the synthesis carries every piece, its state, and its verdict", () => {
+ const p = synthesisPrompt({ task: "T", results: [
+ { title: "a", state: "done", artifact: "A!" },
+ { title: "b", state: "failed", artifact: "", verified: { refuted: true, reason: "no diff" } },
+ ] });
+ assert.match(p, /piece 1: a \(done\)/);
+ assert.match(p, /piece 2: b \(failed, review: REFUTED — no diff\)/);
+ assert.match(p, /A!/);
+ assert.match(p, /\(no output captured\)/);
+});
+
+/* -------------------------------------------------------------- the gate */
+
+test("throttled runs at most N at a time and keeps order", async () => {
+ let running = 0, peak = 0;
+ const out = await throttled([1, 2, 3, 4, 5, 6], 2, async (n) => {
+ running++; peak = Math.max(peak, running);
+ await new Promise((r) => setTimeout(r, 5 * (7 - n)));
+ running--;
+ return n * 10;
+ });
+ assert.equal(peak, 2);
+ assert.deepEqual(out, [10, 20, 30, 40, 50, 60]);
+});
+
+/* ------------------------------------------------------------- the phases */
+
+function fakes({ plan, verdict = { refuted: false, reason: "fine" }, synthesis = "THE ANSWER", failStart = [], neverReady = [] } = {}) {
+ const calls = { ai: [], start: [], boot: [], prompt: [], kill: [] };
+ let running = 0, peak = 0;
+ const deps = {
+ ai: (engine, prompt) => {
+ calls.ai.push({ engine, prompt });
+ if (prompt.startsWith("You are planning")) return typeof plan === "string" ? plan : JSON.stringify(plan);
+ if (prompt.startsWith("You are a skeptical")) return JSON.stringify(verdict);
+ return synthesis;
+ },
+ start: (name, opts) => { calls.start.push({ name, ...opts }); return failStart.includes(name) ? { ok: false, error: "tmux said no" } : { ok: true }; },
+ boot: async (name) => { calls.boot.push(name); return neverReady.includes(name) ? { outcome: "timeout", state: "working" } : { outcome: "matched", state: "idle" }; },
+ prompt: async (name, text) => {
+ running++; peak = Math.max(peak, running);
+ await new Promise((r) => setTimeout(r, 5));
+ running--;
+ calls.prompt.push({ name, text });
+ return { ok: true, task: `t-${name}`, outcome: "matched", state: "done", artifact: `output of ${name}`, error: null };
+ },
+ kill: async (name) => { calls.kill.push(name); },
+ };
+ return { deps, calls, peak: () => peak };
+}
+
+const engineOf = () => "claude";
+
+test("a swarm plans, fans out, kills its sessions, and synthesises", async () => {
+ const { deps, calls } = fakes({ plan: [{ title: "a", prompt: "do a\nthen b" }, { title: "b", prompt: "do b" }] });
+ const lines = [];
+ const result = await runSwarm({ task: "T", agents: 4, cwd: "/x", herd: "swarm", timeoutMs: 1000 }, { write: (l) => lines.push(l), deps, engineOf });
+ assert.equal(result.ok, true);
+ assert.equal(result.engine, "claude");
+ assert.deepEqual(result.plan.map((p) => p.title), ["a", "b"]);
+ assert.deepEqual(calls.start.map((s) => s.name), ["swarm-t-1", "swarm-t-2"]);
+ assert.equal(calls.start[0].herd, "swarm");
+ assert.equal(calls.start[0].cwd, "/x");
+ assert.equal(calls.prompt[0].text, "do a then b", "a newline in a prompt would submit it early");
+ assert.deepEqual(calls.kill, ["swarm-t-1", "swarm-t-2"], "sessions are ended when the swarm is done");
+ assert.equal(result.results[1].task, "t-swarm-t-2");
+ assert.equal(result.results[1].artifact, "output of swarm-t-2");
+ assert.equal(result.synthesis, "THE ANSWER");
+ const synth = calls.ai.at(-1).prompt;
+ assert.match(synth, /output of swarm-t-1/);
+ assert.match(synth, /output of swarm-t-2/);
+ assert.equal(calls.ai.length, 2, "plan, synthesis — and no verifier unless asked");
+});
+
+test("--agents caps the plan as well as gating the sessions", async () => {
+ // A model that answers with more pieces than it was asked for does not get
+ // to run more agents than the operator allowed.
+ const plan = Array.from({ length: 6 }, (_, i) => ({ title: `p${i}`, prompt: `do ${i}` }));
+ const f = fakes({ plan });
+ const result = await runSwarm({ task: "T", agents: 2, cwd: "/x", herd: "swarm", timeoutMs: 1000 }, { deps: f.deps, engineOf });
+ assert.equal(result.results.length, 2);
+ assert.ok(f.peak() <= 2);
+});
+
+test("a plan the model fluffs becomes one piece holding the whole task, and says so", async () => {
+ const { deps, calls } = fakes({ plan: "I would rather not." });
+ const lines = [];
+ const result = await runSwarm({ task: "T", agents: 4, cwd: "/x", herd: "swarm", timeoutMs: 1000 }, { write: (l) => lines.push(l), deps, engineOf });
+ assert.equal(result.plan.length, 1);
+ assert.match(result.plan[0].prompt, /^T\b/);
+ assert.match(result.plan[0].prompt, /SUMMARY:/);
+ assert.ok(lines.some((l) => /did not parse/.test(l)));
+ assert.equal(calls.start.length, 1);
+});
+
+test("a planning call that throws degrades the same way", async () => {
+ const f = fakes({ plan: [] });
+ f.deps.ai = (engine, prompt) => { if (prompt.startsWith("You are planning")) throw new Error("boom"); return "S"; };
+ const lines = [];
+ const result = await runSwarm({ task: "T", agents: 4, cwd: "/x", herd: "swarm", timeoutMs: 1000 }, { write: (l) => lines.push(l), deps: f.deps, engineOf });
+ assert.equal(result.ok, true);
+ assert.equal(result.plan.length, 1);
+ assert.ok(lines.some((l) => /planning failed \(boom\)/.test(l)));
+});
+
+test("a piece whose session never starts or never boots is failed, not fatal", async () => {
+ const { deps, calls } = fakes({
+ plan: [{ title: "a", prompt: "a" }, { title: "b", prompt: "b" }, { title: "c", prompt: "c" }],
+ failStart: ["swarm-t-1"], neverReady: ["swarm-t-2"],
+ });
+ const result = await runSwarm({ task: "T", agents: 4, cwd: "/x", herd: "swarm", timeoutMs: 1000 }, { deps, engineOf });
+ assert.equal(result.ok, true, "the synthesis still ran");
+ assert.deepEqual(result.results.map((r) => r.state), ["failed", "failed", "done"]);
+ assert.match(result.results[0].error, /tmux said no/);
+ assert.match(result.results[1].error, /never became ready/);
+ assert.deepEqual(calls.kill, ["swarm-t-2", "swarm-t-3"], "a session that booted but never answered is still ended; one that never started is not");
+ assert.match(calls.ai.at(-1).prompt, /piece 1: a \(failed\)/);
+});
+
+test("--verify attaches a verdict to every piece and shows it to the synthesis", async () => {
+ const { deps, calls } = fakes({ plan: [{ title: "a", prompt: "a" }], verdict: { refuted: true, reason: "claims a test it never ran" } });
+ const result = await runSwarm({ task: "T", agents: 4, cwd: "/x", herd: "swarm", timeoutMs: 1000, verify: true }, { deps, engineOf });
+ assert.deepEqual(result.results[0].verified, { refuted: true, reason: "claims a test it never ran" });
+ assert.equal(calls.ai.length, 3, "plan, verify, synthesis");
+ assert.match(calls.ai[1].prompt, /output of swarm-t-1/);
+ assert.match(calls.ai.at(-1).prompt, /REFUTED — claims a test it never ran/);
+});
+
+test("--keep leaves the sessions running", async () => {
+ const { deps, calls } = fakes({ plan: [{ title: "a", prompt: "a" }] });
+ const result = await runSwarm({ task: "T", agents: 4, cwd: "/x", herd: "swarm", timeoutMs: 1000, keep: true }, { deps, engineOf });
+ assert.deepEqual(calls.kill, []);
+ assert.equal(result.kept, true);
+});
+
+test("--plan-only starts nothing", async () => {
+ const { deps, calls } = fakes({ plan: [{ title: "a", prompt: "a" }, { title: "b", prompt: "b" }] });
+ const result = await runSwarm({ task: "T", agents: 4, cwd: "/x", herd: "swarm", timeoutMs: 1000, planOnly: true }, { deps, engineOf });
+ assert.equal(result.planOnly, true);
+ assert.equal(result.plan.length, 2);
+ assert.equal(calls.start.length, 0);
+ assert.equal(calls.ai.length, 1);
+});
+
+test("no installed engine is a clear error", async () => {
+ const result = await runSwarm({ task: "T", agents: 4, cwd: "/x", herd: "swarm", timeoutMs: 1000 }, { deps: fakes().deps, engineOf: () => null });
+ assert.equal(result.ok, false);
+ assert.match(result.error, /install claude/);
+});
+
+/* ------------------------------------------------------------- the command */
+
+test("the command needs a task", async () => {
+ const lines = [];
+ assert.equal(await swarmCommand([], { write: (l) => lines.push(l) }), 1);
+ assert.match(lines.join("\n"), /usage: moshcode swarm/);
+});
+
+test("the command refuses an engine nobody has heard of before touching anything", async () => {
+ const lines = [];
+ assert.equal(await swarmCommand(["do it", "--engine", "hal9000"], { write: (l) => lines.push(l) }), 1);
+ assert.match(lines.join("\n"), /no engine named "hal9000"/);
+});
+
+test("--json prints the run as data and narrates nothing", async () => {
+ const { deps } = fakes({ plan: [{ title: "a", prompt: "a" }] });
+ const lines = [];
+ const code = await swarmCommand(["T", "--json"], { write: (l) => lines.push(l), deps, engineOf });
+ assert.equal(code, 0);
+ assert.equal(lines.length, 1, "one JSON document, no narration");
+ const data = JSON.parse(lines[0]);
+ assert.equal(data.synthesis, "THE ANSWER");
+ assert.equal(data.results[0].session, "swarm-t-1");
+});
+
+test("the human form ends with the answer, the ledger, and the count", async () => {
+ const { deps } = fakes({ plan: [{ title: "a", prompt: "a" }, { title: "b", prompt: "b" }] });
+ const lines = [];
+ const code = await swarmCommand(["T"], { write: (l) => lines.push(l), deps, engineOf });
+ assert.equal(code, 0);
+ const text = lines.join("\n");
+ assert.match(text, /THE ANSWER/);
+ assert.match(text, /2 pieces finished/);
+ assert.match(text, /moshcode herd task t-swarm-t-1/);
+});
+
+test("a failed piece is a non-zero exit even though the answer was written", async () => {
+ const { deps } = fakes({ plan: [{ title: "a", prompt: "a" }], failStart: ["swarm-t-1"] });
+ const lines = [];
+ const code = await swarmCommand(["T"], { write: (l) => lines.push(l), deps, engineOf });
+ assert.equal(code, 1);
+ assert.match(lines.join("\n"), /0 of 1 pieces finished; 1 failed/);
+});