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/); +});