logicsrc/packages/openfleet/src/commands.test.ts
Anthony Ettinger 9ae7ad8962
OpenFleet reference implementation: @logicsrc/openfleet, logicsrc fleet, and Claude Code hooks (#185)
* OpenFleet reference implementation: @logicsrc/openfleet 0.1.0 and logicsrc fleet

Ship what docs/openfleet.md describes. The new workspace package holds the
record (write once, never overwrite, 0600), the ledger (append-only JSON
Lines, merged across ledger*.jsonl by at), the ceiling rules (whole fleet
ceiling, narrowed swarm keys, a merge that never widens, refusals by key),
claiming and deriving exactly as the spec's "Claiming and deriving" and
rule 13, and fold(), which turns any $OPENFLEET_HOME plus the engine
rosters into the tree the landing page shows.

logicsrc fleet open|cap|tree|stop|log are the sysop's verbs, every one with
--json. open and cap exit 4 when OPENFLEET_MEMBER is set; stop exits 4
outside the caller's subtree, ends nested swarms first, goes through each
member's own engine (claude stop, moshcode herd kill, tmux kill-pane, a
signal for claude-p) and writes one swarm.end per swarm. tree reads claude
agents --json --all and ~/.moshcode/herd/sessions.json when it can, draws
recordless sessions as roster roots of the implicit fleet, and writes
member.end lost for a recorded member its engine no longer lists.

Claude Code takes part through hooks: logicsrc fleet hooks install merges
SessionStart, UserPromptSubmit, PreToolUse, Stop and SessionEnd into
~/.claude/settings.json without clobbering it, and logicsrc fleet hook
<Event> runs each one. SessionStart claims, derives or writes a root record
and hands the member its variables through CLAUDE_ENV_FILE; UserPromptSubmit
checks the ceiling with the permission mode the engine reports and writes
member.start, or refuses the first prompt with exit 2 and ceiling.refuse;
PreToolUse denies an edit outside piece.owns; Stop and SessionEnd write
member.end. A hand-started root takes the engine's reported approvals
before member.start, since the command line only guesses them. Hooks
never fail the engine: everything is caught and logged to hooks.log.

The spec and the landing page now say what ships, keep Status 0.1, and
record the two verified Claude Code limits: a background job dispatched from
claude agents gets no launcher environment, and OPENFLEET_* exported at
SessionStart reach the member's tools but not later hooks, so hooks key on
session_id through $OPENFLEET_HOME/sessions/<session_id>.json. PRD 0008
covers the work. CLI 0.2.1 -> 0.3.0; build and build:cli chains build the
package before the CLI; README and docs/cli.md list the group.

Tests: 95 in the package (record, ledger merge, every narrower case, the
worked example's claim and derive, the folded tree, hook install
idempotence, each hook handler including the exit-2 refusal and the
PreToolUse deny, every verb with fake deps) and 4 in the CLI.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RZV4zJ2pDZLNN3kE5jFCmV

* OpenFleet fix round: rebuild the ceiling from the ledger, once-markers, rule 6 in tree, lost only for what a roster can hold

The review of the reference implementation against moshcode found the two
readers disagreeing on the same files. This round applies the shared
rulings so both sides read a ledger the same way.

Ceiling (R-A, R-B, R-C, R1, R6, R10, R15, R17): memberCeiling rebuilds the
effective ceiling from the ledger on every read. The latest fleet-target
fleet.cap (else fleet.open, else the implicit fleet's) replaces the copy in
a record, so a sysop's widening cap reaches running members; then each
swarm.spawn narrowing down the path, then swarm caps last. In the implicit
fleet a parentless record's own approvals enters at the root; a ceiling a
writer left without the key is never read as native, and startMember fills
it with the engine's word while the record is unclaimed. A fleet.open or
cap with no hosts means the host it was written on (R23).

Once-markers (R-G, R28): member.start, member.end and swarm.end each take
an exclusive create under fleets/<fleet>/marks/<event>.<id> before the
append; a lost end takes <id>.lost so a real end can still supersede it.
The hooks let a real end follow a lost line (R9).

tree (R-F, R20): run by the sysop it enforces rule 6, stopping a member
past its effective until with state timeout and the members of a swarm or
fleet at its budget with state budget, then writes swarm.end for each swarm
touched once it is complete. An agent's tree stops nothing. lost is written
only for a member its engine's roster can hold: a claude-code background job
(8-hex member or session) or a moshcode pane, never an interactive session
claude agents does not list (R-E, R3, R14). A nested swarm is drawn under
the member that spawned it and its row shows the effective ceiling (R25).

stop and cap (R-D, R-H, R22, R27): swarm.end is written only once every
member and every nested swarm has an end line that counts; an engine that
will not end a member leaves it without an end line and the verb exits
non-zero. claude stop takes the job id: the member of a background job,
else the first eight characters of a session UUID; an interactive session
with no job id cannot be stopped and the tool says so. cap on a swarm
refuses a key that would widen. A derived claude-code job is named by its
job id and carries no pid.

Also: R-I (endMember ends only the engine-minted swarm of one), R35 (a
derived record's guessed approvals corrected at UserPromptSubmit), R32
(the UserPromptSubmit hook passes only exit 2 through), R31 (package
README), R36 (rule 13 says the launcher test is unimplemented in 0.1),
docs and PRD 0008 updated for lost, rule 6 and the markers. 113 openfleet
tests, 93 CLI tests, contract green.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RZV4zJ2pDZLNN3kE5jFCmV

* openfleet hooks: no member.end for a member that never started

A first prompt refused by the ceiling still lets the session wind down through Stop and SessionEnd; those handlers now write nothing when the ledger holds no member.start for the member, so a refused member is never drawn as done.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01RZV4zJ2pDZLNN3kE5jFCmV

* logicsrc-mcp test: the next free PRD id is 0009 now that PRD 0008 exists

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01RZV4zJ2pDZLNN3kE5jFCmV

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 10:58:55 +00:00

485 lines
29 KiB
TypeScript

import { readFileSync, existsSync } from "node:fs";
import { join } from "node:path";
import { Command } from "commander";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { EXIT, registerOpenFleetCommands, type Deps } from "./commands.js";
import { startMember } from "./context.js";
import { append, findEvents, readCurrent, readLedger, recordPath, writeRecord } from "./store.js";
import { DEV, FLEET, PIECE_1, PIECE_2, ROOT, cleanup, envFor, seedWorkedExample, seedWorkedExampleToEnd, tempHome } from "./test-helpers.js";
import type { RosterRow } from "./types.js";
const NOW = new Date("2026-09-13T06:00:00Z");
interface Harness {
run: (...argv: string[]) => Promise<number>;
out: string[];
err: string[];
raw: string[];
execs: string[][];
kills: number[];
deps: Deps;
}
function harness(home: string, extra: Partial<Deps> = {}, env: Record<string, string> = {}): Harness {
const out: string[] = [];
const err: string[] = [];
const raw: string[] = [];
const execs: string[][] = [];
const kills: number[] = [];
const deps: Deps = {
env: envFor(home, env),
now: () => NOW,
exec: async (file, args) => {
execs.push([file, ...args]);
return { code: 0, stdout: "", stderr: "" };
},
kill: (pid) => {
kills.push(pid);
},
rosters: {},
stdin: async () => "",
write: (line) => out.push(line),
error: (line) => err.push(line),
stdout: (text) => raw.push(text),
stderr: (text) => raw.push(text),
host: "dev",
user: "anthony",
...extra,
};
const run = async (...argv: string[]): Promise<number> => {
const program = new Command();
program.name("logicsrc").enablePositionalOptions().exitOverride();
const fleet = program.command("fleet");
registerOpenFleetCommands(fleet, deps);
process.exitCode = 0;
await program.parseAsync(["node", "logicsrc", "fleet", ...argv]);
const code = Number(process.exitCode ?? 0);
process.exitCode = 0;
return code;
};
return { run, out, err, raw, execs, kills, deps };
}
describe("open", () => {
let home: string;
beforeEach(() => {
home = tempHome();
});
afterEach(() => {
cleanup(home);
process.exitCode = 0;
});
it("mints <name>-<yyyymmdd>, writes fleet.open by sysop with the ceiling from flags, writes current, prints the id", async () => {
const h = harness(home);
expect(await h.run("open", "team", "--approvals", "bypass", "--budget", "20 USD", "--depth", "2", "--fan-out", "4", "--hosts", "dev,netcup", "--until", "2h")).toBe(0);
expect(h.out).toEqual(["team-20260913"]);
expect(readCurrent(home)).toBe("team-20260913");
const lines = readLedger(home, "team-20260913");
expect(lines).toEqual([
{ at: "2026-09-13T06:00:00Z", event: "fleet.open", fleet: "team-20260913", host: "dev", by: "sysop", sysop: "anthony@dev", ceiling: { approvals: "bypass", budget: "20 USD", depth: 2, fan_out: 4, hosts: ["dev", "netcup"], until: "2026-09-13T08:00:00Z" } },
]);
// A second fleet the same day gets a suffix; a bare open uses "fleet" and this host.
expect(await h.run("open", "team", "--json")).toBe(0);
expect(JSON.parse(h.out[1])).toMatchObject({ fleet: "team-20260913-2", sysop: "anthony@dev", ceiling: { hosts: ["dev"] } });
expect(await h.run("open", "--sysop", "https://anthony.example/profile.md")).toBe(0);
expect(h.out[2]).toBe("fleet-20260913");
expect(readLedger(home, "fleet-20260913")[0].sysop).toBe("https://anthony.example/profile.md");
});
it("refuses with exit 4 when the caller carries OPENFLEET_MEMBER, and rejects bad flags with exit 2", async () => {
const agent = harness(home, {}, { OPENFLEET_MEMBER: "create-two-0541-1" });
expect(await agent.run("open")).toBe(EXIT.REFUSED);
expect(agent.err[0]).toMatch(/sysop's.*OPENFLEET_MEMBER=create-two-0541-1/);
expect(existsSync(join(home, "fleets"))).toBe(false);
const h = harness(home);
expect(await h.run("open", "--approvals", "sometimes")).toBe(EXIT.INVALID);
expect(await h.run("open", "--budget", "20")).toBe(EXIT.INVALID);
expect(await h.run("open", "--depth", "-1")).toBe(EXIT.INVALID);
expect(await h.run("open", "--until", "later")).toBe(EXIT.INVALID);
expect(existsSync(join(home, "fleets"))).toBe(false);
});
});
describe("cap", () => {
let home: string;
beforeEach(() => {
home = tempHome();
seedWorkedExampleToEnd(home);
});
afterEach(() => {
cleanup(home);
process.exitCode = 0;
});
it("narrows a running swarm and stops the bypass member now above the native ceiling, through its engine", async () => {
const h = harness(home);
expect(await h.run("cap", "create-two-0541", "--approvals", "native")).toBe(0);
const lines = readLedger(home, FLEET);
expect(findEvents(lines, "fleet.cap")[0]).toMatchObject({ by: "sysop", target: "create-two-0541", ceiling: { approvals: "native" } });
expect(h.execs).toEqual([["moshcode", "herd", "kill", "create-two-0541-2"]]);
expect(findEvents(lines, "member.end", { member: "create-two-0541-2" })[0]).toMatchObject({ by: "sysop", state: "stopped" });
expect(h.out[0]).toBe('capped swarm create-two-0541: {"approvals":"native"}');
expect(h.out[1]).toBe("stopped create-two-0541-2 moshcode/claude stopped");
// The already-done member was left alone, and the root, outside the swarm, was not touched.
expect(findEvents(lines, "member.end", { member: "create-two-0541-1" }).length).toBe(1);
expect(findEvents(lines, "member.end", { member: "460a4502" })).toEqual([]);
});
it("sets a fleet's whole ceiling, refuses an agent, and says when the target does not exist", async () => {
const h = harness(home);
expect(await h.run("cap", FLEET, "--approvals", "native", "--json")).toBe(0);
const result = JSON.parse(h.out[0]);
expect(result).toMatchObject({ target: FLEET, kind: "fleet", ceiling: { approvals: "native", hosts: ["dev"] } });
// The root ran with bypass; under a native fleet ceiling it is stopped through claude.
expect(h.execs).toContainEqual(["claude", "stop", "460a4502"]);
expect(result.stopped.map((row: { member: string }) => row.member)).toContain("460a4502");
expect(await h.run("cap", "nope", "--depth", "1")).toBe(EXIT.NOT_FOUND);
expect(await h.run("cap", "create-two-0541")).toBe(EXIT.INVALID);
const agent = harness(home, {}, { OPENFLEET_MEMBER: "460a4502" });
expect(await agent.run("cap", "create-two-0541", "--depth", "1")).toBe(EXIT.REFUSED);
});
it("refuses a cap that would widen a swarm before writing anything: a cap on a swarm only narrows", async () => {
const h = harness(home);
expect(await h.run("cap", "create-two-0541", "--fan-out", "8")).toBe(EXIT.INVALID);
expect(h.err[0]).toBe("cap on swarm create-two-0541 never widens: fan_out 8 is not within 4");
expect(await h.run("cap", "create-two-0541", "--depth", "2")).toBe(EXIT.INVALID);
expect(await h.run("cap", "create-two-0541", "--until", "2026-09-13T07:00:00Z")).toBe(EXIT.INVALID);
expect(await h.run("cap", "create-two-0541", "--hosts", "dev,netcup")).toBe(EXIT.INVALID);
expect(findEvents(readLedger(home, FLEET), "fleet.cap")).toEqual([]);
expect(h.execs).toEqual([]);
// Equal or narrower lands.
expect(await h.run("cap", "create-two-0541", "--fan-out", "2", "--until", "2026-09-13T06:05:00Z")).toBe(0);
expect(findEvents(readLedger(home, FLEET), "fleet.cap")[0]).toMatchObject({ target: "create-two-0541", ceiling: { fan_out: 2, until: "2026-09-13T06:05:00Z" } });
});
});
describe("tree", () => {
let home: string;
beforeEach(() => {
home = tempHome();
seedWorkedExampleToEnd(home);
});
afterEach(() => {
cleanup(home);
process.exitCode = 0;
});
it("prints the tree, or JSON, and writes member.end lost for a recorded member its engine no longer lists", async () => {
const h = harness(home);
expect(await h.run("tree")).toBe(0);
expect(h.out[0].split("\n")[0]).toBe("anthony@dev (implicit fleet, sysop anthony@dev, depth 1, hosts dev)");
expect(h.out[0]).toContain("swarm create-two-0541");
expect(await h.run("tree", "--json")).toBe(0);
expect(JSON.parse(h.out[1]).fleets[0].roots[0].member).toBe("460a4502");
expect(await h.run("tree", "nope")).toBe(EXIT.NOT_FOUND);
const moshcode = async (): Promise<RosterRow[]> => [];
const rostered = harness(home, { rosters: { moshcode } });
expect(await rostered.run("tree")).toBe(0);
expect(rostered.out[0]).toMatch(/create-two-0541-2 +create bye.sh bash +moshcode\/claude +lost/);
expect(findEvents(readLedger(home, FLEET), "member.end", { member: "create-two-0541-2" })[0]).toMatchObject({ by: "sysop", state: "lost" });
// As an agent, the lost line is by that agent.
const asAgent = harness(home, { rosters: { claude: async () => [] } }, { OPENFLEET_MEMBER: "create-two-0541-1" });
expect(await asAgent.run("tree")).toBe(0);
expect(findEvents(readLedger(home, FLEET), "member.end", { member: "460a4502" })[0]).toMatchObject({ by: "create-two-0541-1", state: "lost" });
// --no-roster reads nothing and writes nothing.
const quiet = harness(home, { rosters: { claude: async () => { throw new Error("should not be read"); } } });
expect(await quiet.run("tree", "--no-roster")).toBe(0);
});
it("never marks an interactive claude session lost: the roster cannot hold it", async () => {
const interactive = "68aca9c1-1111-4222-8333-444455556666";
append(home, FLEET, { at: "2026-09-13T05:50:00Z", event: "member.start", by: interactive, member: interactive, session: interactive, depth: 0, engine: "claude-code", cwd: "/x", approvals: "native" }, { host: "dev" });
const h = harness(home, { rosters: { claude: async () => [] } });
expect(await h.run("tree")).toBe(0);
expect(findEvents(readLedger(home, FLEET), "member.end", { member: interactive })).toEqual([]);
expect(h.out[0]).toMatch(new RegExp(`${interactive} +claude-code +working$`, "m"));
// The background job beside it, same roster, is lost.
expect(findEvents(readLedger(home, FLEET), "member.end", { member: "460a4502" })[0]).toMatchObject({ state: "lost" });
});
it("enforces a deadline: a working member past its effective until is stopped through its engine and ends timeout, then its swarm ends", async () => {
// The worked example's swarm runs until 06:11:01Z; the clock reads 06:30.
const late = harness(home, { now: () => new Date("2026-09-13T06:30:00Z") });
expect(await late.run("tree")).toBe(0);
expect(late.execs).toEqual([["moshcode", "herd", "kill", "create-two-0541-2"]]);
const lines = readLedger(home, FLEET);
expect(findEvents(lines, "member.end", { member: "create-two-0541-2" })[0]).toMatchObject({ by: "sysop", state: "timeout", at: "2026-09-13T06:30:00Z" });
expect(findEvents(lines, "swarm.end", { swarm: "create-two-0541" })[0]).toMatchObject({ by: "sysop", state: "timeout" });
// The root has no deadline and is left alone.
expect(findEvents(lines, "member.end", { member: "460a4502" })).toEqual([]);
expect(late.out[0]).toMatch(/create-two-0541-2 +create bye.sh bash +moshcode\/claude +timeout/);
expect(late.out[0]).toMatch(/swarm create-two-0541 +"create two \.\.\." +2\/4 members +timeout/);
expect(late.out.slice(1)).toEqual(["stopped create-two-0541-2 moshcode/claude timeout", "ended swarm create-two-0541 timeout"]);
// A second run finds everything ended and writes nothing more.
const again = harness(home, { now: () => new Date("2026-09-13T06:31:00Z") });
expect(await again.run("tree", "--json")).toBe(0);
expect(again.execs).toEqual([]);
expect(JSON.parse(again.out[0]).enforced).toEqual({ members: [], swarms: [] });
expect(findEvents(readLedger(home, FLEET), "swarm.end", { swarm: "create-two-0541" }).length).toBe(1);
});
it("leaves enforcement to the sysop: an agent's tree stops nothing", async () => {
const agent = harness(home, { now: () => new Date("2026-09-13T06:30:00Z") }, { OPENFLEET_MEMBER: "460a4502" });
expect(await agent.run("tree")).toBe(0);
expect(agent.execs).toEqual([]);
expect(findEvents(readLedger(home, FLEET), "member.end", { member: "create-two-0541-2" })).toEqual([]);
});
it("enforces a budget: a swarm whose spend has reached its budget has its members stopped with budget, siblings outside it untouched", async () => {
const fleet = "team-20260913";
append(home, fleet, { at: "2026-09-13T05:00:00Z", event: "fleet.open", by: "sysop", fleet, sysop: "anthony@dev", ceiling: { approvals: "bypass", depth: 2, hosts: ["dev"], budget: "20 USD" } }, { host: "dev" });
append(home, fleet, { at: "2026-09-13T05:01:00Z", event: "swarm.spawn", by: "sysop", swarm: "hand-0501", task: "by hand", ceiling: { budget: "5 USD" }, pieces: [{ member: "hand-0501-1" }, { member: "hand-0501-2" }] }, { host: "dev" });
append(home, fleet, { at: "2026-09-13T05:02:00Z", event: "member.start", by: "hand-0501-1", member: "hand-0501-1", session: "hand-0501-1", swarm: "hand-0501", depth: 0, engine: "moshcode/codex", approvals: "native" }, { host: "dev" });
append(home, fleet, { at: "2026-09-13T05:02:01Z", event: "member.start", by: "hand-0501-2", member: "hand-0501-2", session: "hand-0501-2", swarm: "hand-0501", depth: 0, engine: "moshcode/kimi", approvals: "native" }, { host: "dev" });
append(home, fleet, { at: "2026-09-13T05:03:00Z", event: "member.start", by: "aaaa0001", member: "aaaa0001", session: "aaaa0001", depth: 0, engine: "claude-code", approvals: "native" }, { host: "dev" });
append(home, fleet, { at: "2026-09-13T05:05:00Z", event: "member.spend", by: "hand-0501-1", member: "hand-0501-1", amount: "3 USD", total: "3 USD" }, { host: "dev" });
append(home, fleet, { at: "2026-09-13T05:06:00Z", event: "member.spend", by: "hand-0501-2", member: "hand-0501-2", amount: "2 USD", total: "2 USD" }, { host: "dev" });
const h = harness(home);
expect(await h.run("tree", fleet)).toBe(0);
expect(h.execs).toEqual([
["moshcode", "herd", "kill", "hand-0501-1"],
["moshcode", "herd", "kill", "hand-0501-2"],
]);
const lines = readLedger(home, fleet);
expect(findEvents(lines, "member.end").map((line) => [line.member, line.state, line.by])).toEqual([
["hand-0501-1", "budget", "sysop"],
["hand-0501-2", "budget", "sysop"],
]);
expect(findEvents(lines, "swarm.end", { swarm: "hand-0501" })[0]).toMatchObject({ state: "budget", by: "sysop" });
expect(h.out[0]).toMatch(/swarm hand-0501 +"by hand" +2 members +budget {2}5\/5 USD/);
expect(h.out[0]).toMatch(/aaaa0001 +claude-code +working$/m);
// The fleet's own budget: raise the spend past 20 USD and the root goes too.
append(home, fleet, { at: "2026-09-13T06:00:01Z", event: "member.spend", by: "aaaa0001", member: "aaaa0001", amount: "15 USD", total: "15 USD" }, { host: "dev" });
const over = harness(home, { now: () => new Date("2026-09-13T06:01:00Z") });
expect(await over.run("tree", fleet, "--json")).toBe(0);
expect(over.execs).toEqual([["claude", "stop", "aaaa0001"]]);
expect(findEvents(readLedger(home, fleet), "member.end", { member: "aaaa0001" })[0]).toMatchObject({ state: "budget" });
expect(JSON.parse(over.out[0]).enforced.members).toEqual([{ member: "aaaa0001", engine: "claude-code", stopped: true, state: "budget" }]);
});
});
describe("stop", () => {
let home: string;
beforeEach(() => {
home = tempHome();
seedWorkedExampleToEnd(home);
});
afterEach(() => {
cleanup(home);
process.exitCode = 0;
});
it("ends a swarm as one unit: nested swarms first, members through their engines, then one swarm.end", async () => {
// A nested swarm spawned by the second piece, with a claude-p member and a tmux member.
append(home, FLEET, { at: "2026-09-13T05:50:00Z", event: "swarm.spawn", by: "create-two-0541-2", swarm: "nested-0550", parent_swarm: "create-two-0541", task: "nested", ceiling: {}, pieces: [{ member: "nested-0550-1" }, { member: "nested-0550-2" }] }, { host: "dev" });
append(home, FLEET, { at: "2026-09-13T05:50:01Z", event: "member.start", by: "nested-0550-1", member: "nested-0550-1", session: "31337", swarm: "nested-0550", parent: "create-two-0541-2", depth: 2, engine: "claude-p", approvals: "native" }, { host: "dev" });
append(home, FLEET, { at: "2026-09-13T05:50:02Z", event: "member.start", by: "nested-0550-2", member: "nested-0550-2", session: "%7", swarm: "nested-0550", parent: "create-two-0541-2", depth: 2, engine: "tmux", approvals: "native" }, { host: "dev" });
const h = harness(home);
expect(await h.run("stop", "create-two-0541")).toBe(0);
expect(h.kills).toEqual([31337]);
expect(h.execs).toEqual([
["tmux", "-L", "moshcode", "kill-pane", "-t", "%7"],
["moshcode", "herd", "kill", "create-two-0541-2"],
]);
const lines = readLedger(home, FLEET);
const events = lines.filter((line) => Date.parse(line.at) >= NOW.getTime()).map((line) => `${line.event} ${line.member ?? line.swarm} ${line.state} ${line.by}`);
expect(events).toEqual([
"member.end nested-0550-1 stopped sysop",
"member.end nested-0550-2 stopped sysop",
"swarm.end nested-0550 stopped sysop",
"member.end create-two-0541-2 stopped sysop",
"swarm.end create-two-0541 stopped sysop",
]);
expect(h.out).toEqual([
"stopped nested-0550-1 claude-p stopped",
"stopped nested-0550-2 tmux stopped",
"ended swarm nested-0550 stopped",
"skipped create-two-0541-1 claude-code done (already ended)",
"stopped create-two-0541-2 moshcode/claude stopped",
"ended swarm create-two-0541 stopped",
]);
// Stopping again touches nothing: one swarm.end per swarm, no end line for an ended member.
const again = harness(home);
expect(await again.run("stop", "create-two-0541", "--json")).toBe(0);
expect(again.execs).toEqual([]);
expect(findEvents(readLedger(home, FLEET), "swarm.end", { swarm: "create-two-0541" }).length).toBe(1);
expect(JSON.parse(again.out[0]).swarms).toEqual([{ swarm: "nested-0550", state: "stopped", ended: false, note: "already ended" }, { swarm: "create-two-0541", state: "stopped", ended: false, note: "already ended" }]);
});
it("stops one member through claude, by the caller, and reports an engine that says no", async () => {
const h = harness(home);
expect(await h.run("stop", "460a4502")).toBe(0);
expect(h.execs).toEqual([["claude", "stop", "460a4502"]]);
expect(findEvents(readLedger(home, FLEET), "member.end", { member: "460a4502" })[0]).toMatchObject({ by: "sysop", state: "stopped" });
const failing = harness(home, { exec: async () => ({ code: 1, stdout: "", stderr: "no such session" }) });
expect(await failing.run("stop", "create-two-0541-2")).toBe(EXIT.NOT_FOUND);
expect(failing.out[0]).toContain("no such session");
expect(findEvents(readLedger(home, FLEET), "member.end", { member: "create-two-0541-2" })).toEqual([]);
expect(await h.run("stop", "nobody")).toBe(EXIT.NOT_FOUND);
});
it("stops a claude-code member by its job id: the member of a background job, else the first eight characters of a session UUID, and says when there is none", async () => {
const uuid = "68aca9c1-1111-4222-8333-444455556666";
append(home, FLEET, { at: "2026-09-13T05:50:00Z", event: "member.start", by: "piece-x", member: "piece-x", session: uuid, depth: 0, engine: "claude-code", approvals: "native" }, { host: "dev" });
append(home, FLEET, { at: "2026-09-13T05:51:00Z", event: "member.start", by: uuid, member: uuid, session: uuid, depth: 0, engine: "claude-code", approvals: "native" }, { host: "dev" });
const h = harness(home);
expect(await h.run("stop", "piece-x")).toBe(0);
expect(h.execs).toEqual([["claude", "stop", "68aca9c1"]]);
const interactive = harness(home);
expect(await interactive.run("stop", uuid, "--json")).toBe(EXIT.NOT_FOUND);
expect(interactive.execs).toEqual([]);
expect(JSON.parse(interactive.out[0]).members[0]).toMatchObject({ member: uuid, stopped: false, error: expect.stringContaining("claude stop cannot end it") });
expect(findEvents(readLedger(home, FLEET), "member.end", { member: uuid })).toEqual([]);
});
it("writes no swarm.end while a member's engine would not stop, or a nested swarm has no swarm.end, and exits non-zero", async () => {
append(home, FLEET, { at: "2026-09-13T05:50:00Z", event: "swarm.spawn", by: "create-two-0541-2", swarm: "nested-0550", parent_swarm: "create-two-0541", task: "nested", ceiling: {}, pieces: [{ member: "nested-0550-1" }] }, { host: "dev" });
append(home, FLEET, { at: "2026-09-13T05:50:02Z", event: "member.start", by: "nested-0550-1", member: "nested-0550-1", session: "%7", swarm: "nested-0550", parent: "create-two-0541-2", depth: 2, engine: "tmux", approvals: "native" }, { host: "dev" });
// tmux says no; moshcode says yes.
const h = harness(home, { exec: async (file) => (file === "tmux" ? { code: 1, stdout: "", stderr: "can't find pane: %7" } : { code: 0, stdout: "", stderr: "" }) });
expect(await h.run("stop", "create-two-0541")).toBe(EXIT.NOT_FOUND);
const lines = readLedger(home, FLEET);
expect(findEvents(lines, "member.end", { member: "nested-0550-1" })).toEqual([]);
expect(findEvents(lines, "swarm.end", { swarm: "nested-0550" })).toEqual([]);
expect(findEvents(lines, "swarm.end", { swarm: "create-two-0541" })).toEqual([]);
expect(findEvents(lines, "member.end", { member: "create-two-0541-2" })[0]).toMatchObject({ state: "stopped" });
expect(h.out).toEqual([
"skipped nested-0550-1 tmux (tmux kill-pane -t %7 exited 1: can't find pane: %7)",
"left swarm nested-0550 open (no end line yet for nested-0550-1)",
"skipped create-two-0541-1 claude-code done (already ended)",
"stopped create-two-0541-2 moshcode/claude stopped",
"left swarm create-two-0541 open (no swarm.end yet for nested nested-0550)",
]);
// Once the pane can be ended, the nested swarm and then the target end, each once.
const later = harness(home);
expect(await later.run("stop", "create-two-0541", "--json")).toBe(0);
expect(later.execs).toEqual([["tmux", "-L", "moshcode", "kill-pane", "-t", "%7"]]);
expect(JSON.parse(later.out[0]).swarms).toEqual([{ swarm: "nested-0550", state: "stopped", ended: true }, { swarm: "create-two-0541", state: "stopped", ended: true }]);
});
it("lets an agent stop only the swarm it spawned and what sits under it", async () => {
const spawner = harness(home, {}, { OPENFLEET_MEMBER: "460a4502" });
expect(await spawner.run("stop", "create-two-0541-2")).toBe(0);
expect(findEvents(readLedger(home, FLEET), "member.end", { member: "create-two-0541-2" })[0].by).toBe("460a4502");
const sibling = harness(home, {}, { OPENFLEET_MEMBER: "create-two-0541-1" });
expect(await sibling.run("stop", "create-two-0541")).toBe(EXIT.REFUSED);
expect(sibling.err[0]).toMatch(/outside the subtree create-two-0541-1 spawned/);
expect(await sibling.run("stop", "460a4502")).toBe(EXIT.REFUSED);
expect(await sibling.run("stop", FLEET, "--fleet")).toBe(EXIT.REFUSED);
expect(sibling.execs).toEqual([]);
});
it("--fleet stops every swarm and every root", async () => {
const h = harness(home);
expect(await h.run("stop", FLEET, "--fleet")).toBe(0);
expect(h.execs).toEqual([
["moshcode", "herd", "kill", "create-two-0541-2"],
["claude", "stop", "460a4502"],
]);
const lines = readLedger(home, FLEET);
expect(findEvents(lines, "swarm.end", { swarm: "create-two-0541" })[0]).toMatchObject({ state: "stopped" });
expect(findEvents(lines, "member.end", { member: "460a4502" })[0]).toMatchObject({ state: "stopped", by: "sysop" });
});
});
describe("log", () => {
let home: string;
beforeEach(() => {
home = tempHome();
seedWorkedExampleToEnd(home);
});
afterEach(() => {
cleanup(home);
process.exitCode = 0;
});
it("prints one line per event, or JSON Lines, filtered by member, swarm and since", async () => {
const h = harness(home);
expect(await h.run("log")).toBe(0);
expect(h.out.length).toBe(9);
expect(h.out[0]).toMatch(/^2026-09-13T04:55:01Z {2}member.start {4}460a4502 +member 460a4502 engine claude-code approvals bypass$/);
expect(h.out[8]).toMatch(/^2026-09-13T05:41:36Z {2}member.end {6}create-two-0541-1 +member create-two-0541-1 done Created hello.sh, mode -rwxrwxr-x, prints hello.$/);
expect(h.out[5]).toContain('swarm create-two-0541 task create two ... pieces 2 narrowed {"fan_out":4,"until":"2026-09-13T06:11:01Z"}');
const json = harness(home);
expect(await json.run("log", FLEET, "--json", "--member", "create-two-0541-1")).toBe(0);
expect(json.out.map((line) => JSON.parse(line).event)).toEqual(["member.start", "member.end"]);
const swarm = harness(home);
expect(await swarm.run("log", "--swarm", "create-two-0541")).toBe(0);
expect(swarm.out.map((line) => line.split(/ {2}/)[1].trim())).toEqual(["swarm.spawn", "member.start", "member.start", "member.end"]);
const since = harness(home);
expect(await since.run("log", "--since", "2026-09-13T05:41:30Z")).toBe(0);
expect(since.out.length).toBe(1);
expect(await since.run("log", "--since", "1h")).toBe(0);
expect(since.out.length).toBe(1 + 8);
expect(await since.run("log", "--since", "whenever")).toBe(EXIT.INVALID);
expect(await since.run("log", "nope")).toBe(EXIT.NOT_FOUND);
const empty = harness(tempHome());
expect(await empty.run("log")).toBe(0);
expect(empty.out).toEqual(["(no events)"]);
});
});
describe("hooks and hook", () => {
let home: string;
beforeEach(() => {
home = tempHome();
});
afterEach(() => {
cleanup(home);
process.exitCode = 0;
});
it("installs, reports and removes over --settings-file, never the real one", async () => {
const file = join(home, "settings.json");
const h = harness(home);
expect(await h.run("hooks", "status", "--settings-file", file)).toBe(0);
expect(h.out[0]).toBe(`${file}: not installed`);
expect(await h.run("hooks", "install", "--settings-file", file)).toBe(0);
expect(h.out[6]).toBe(`wrote 5 hooks in ${file}`);
expect(JSON.parse(readFileSync(file, "utf8")).hooks.SessionStart[0].hooks[0].command).toBe("command -v logicsrc >/dev/null 2>&1 && logicsrc fleet hook SessionStart; exit 0");
expect(await h.run("hooks", "install", "--settings-file", file, "--json")).toBe(0);
expect(JSON.parse(h.out[12]).written).toBe(0);
expect(await h.run("hooks", "status", "--settings-file", file)).toBe(0);
expect(h.out[13]).toBe(`${file}: installed`);
expect(await h.run("hooks", "remove", "--settings-file", file)).toBe(0);
expect(h.out[19]).toBe(`removed 5 hooks from ${file}`);
// With HOME pointed at the temp dir, the default file lands there too.
expect(await h.run("hooks", "install", "--dry-run")).toBe(0);
expect(h.out[20]).toBe(`would write 5 hooks in ${join(home, ".claude", "settings.json")}`);
expect(existsSync(join(home, ".claude", "settings.json"))).toBe(false);
});
it("runs a hook over stdin and writes its verdict raw", async () => {
seedWorkedExample(home);
const sessionId = "68aca9c1-1111-4222-8333-444455556666";
const start = harness(home, {
stdin: async () => JSON.stringify({ session_id: sessionId, cwd: "/x", source: "startup" }),
hookIo: { readProcCmdline: () => ["claude"], readProcEnviron: () => ({}), appendFile: () => undefined },
}, { OPENFLEET_RECORD: recordPath(home, FLEET, "create-two-0541-1"), CLAUDE_PID: "1" });
expect(await start.run("hook", "SessionStart")).toBe(0);
expect(start.raw).toEqual(["OpenFleet: you are member create-two-0541-1 of fleet anthony@dev, piece create hello.sh bash, owns hello.sh\n"]);
const deny = harness(home, { stdin: async () => JSON.stringify({ session_id: sessionId, cwd: "/x", tool_name: "Write", tool_input: { file_path: "bye.sh" } }) });
expect(await deny.run("hook", "PreToolUse")).toBe(0);
expect(JSON.parse(deny.raw[0]).hookSpecificOutput.permissionDecision).toBe("deny");
// A refused start comes back as exit 2 with the reason on stderr.
writeRecord(home, { ...ROOT, member: "native-root", approvals: "native", ceiling: { approvals: "native", depth: 1, hosts: ["dev"] } });
startMember(home, { ...ROOT, member: "native-root", approvals: "native", ceiling: { approvals: "native", depth: 1, hosts: ["dev"] } }, { sessionId: "native-root", approvals: "native", now: NOW, host: "dev", implicit: DEV });
const child = "cafe0000-1111-4222-8333-444455556666";
const refused = harness(home, {
stdin: async () => JSON.stringify({ session_id: child, cwd: "/x", source: "startup" }),
hookIo: { readProcCmdline: () => ["claude", "--dangerously-skip-permissions"], readProcEnviron: () => ({}), appendFile: () => undefined },
}, { OPENFLEET_RECORD: recordPath(home, FLEET, "native-root"), CLAUDE_PID: "1" });
expect(await refused.run("hook", "SessionStart")).toBe(0);
expect(refused.raw[0]).toBe("OpenFleet: start refused, ceiling refuses approvals: wanted bypass, allowed native\n");
const prompt = harness(home, { stdin: async () => JSON.stringify({ session_id: child, cwd: "/x", permission_mode: "bypassPermissions", prompt: "go" }) });
expect(await prompt.run("hook", "UserPromptSubmit")).toBe(2);
expect(prompt.raw[0]).toBe("OpenFleet refused the start: ceiling refuses approvals: wanted bypass, allowed native\n");
void PIECE_2;
});
});