mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-10-03 13:17:07 +00:00
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>
This commit is contained in:
parent
519d13c3d6
commit
9ae7ad8962
36 changed files with 6355 additions and 20 deletions
224
packages/openfleet/src/rosters.ts
Normal file
224
packages/openfleet/src/rosters.ts
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
/**
|
||||
* The engine rosters a sysop tool can read (rule 14): Claude Code's
|
||||
* `claude agents --json --all` and moshcode's `~/.moshcode/herd/sessions.json`.
|
||||
* They add liveness to recorded members and are the only place a member with
|
||||
* no record exists. Neither is required; a roster that cannot be read is null
|
||||
* and the tree says nothing about liveness for that engine.
|
||||
*/
|
||||
|
||||
import { execFile } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { Env } from "./store.js";
|
||||
import { readJson } from "./store.js";
|
||||
import type { Approvals, RosterRow, Rosters } from "./types.js";
|
||||
|
||||
export interface ExecResult {
|
||||
code: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
/** Run a program with an argv array, never a shell string. */
|
||||
export type Exec = (file: string, args: string[], opts?: { timeoutMs?: number }) => Promise<ExecResult>;
|
||||
|
||||
export const realExec: Exec = (file, args, opts = {}) =>
|
||||
new Promise((resolve) => {
|
||||
execFile(file, args, { timeout: opts.timeoutMs ?? 15_000, maxBuffer: 16 * 1024 * 1024 }, (error, stdout, stderr) => {
|
||||
const code = error ? ((error as NodeJS.ErrnoException & { code?: unknown }).code as number | string | undefined) : 0;
|
||||
resolve({
|
||||
code: typeof code === "number" ? code : error ? 1 : 0,
|
||||
stdout: String(stdout ?? ""),
|
||||
stderr: String(stderr ?? "") + (error && typeof code !== "number" ? `\n${error.message}` : ""),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function homeDirOf(env: Env): string {
|
||||
return env.HOME && env.HOME.trim() !== "" ? env.HOME : homedir();
|
||||
}
|
||||
|
||||
/** Where Claude Code keeps its jobs: `$CLAUDE_CONFIG_DIR`, else `~/.claude`. */
|
||||
export function claudeHome(env: Env = process.env): string {
|
||||
return env.CLAUDE_CONFIG_DIR && env.CLAUDE_CONFIG_DIR.trim() !== "" ? env.CLAUDE_CONFIG_DIR : join(homeDirOf(env), ".claude");
|
||||
}
|
||||
|
||||
/** Claude Code's own flags for a job, kept through respawn: the approvals a recordless root ran with (rule 12). */
|
||||
export function approvalsFromFlags(flags: unknown): Approvals {
|
||||
if (!Array.isArray(flags)) return "native";
|
||||
const list = flags.map(String);
|
||||
if (list.includes("--dangerously-skip-permissions")) return "bypass";
|
||||
const at = list.indexOf("--permission-mode");
|
||||
if (at >= 0 && list[at + 1] === "bypassPermissions") return "bypass";
|
||||
if (list.some((flag) => flag === "--permission-mode=bypassPermissions")) return "bypass";
|
||||
return "native";
|
||||
}
|
||||
|
||||
function isoFromMs(value: unknown): string | undefined {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
|
||||
return new Date(value).toISOString().replace(/\.\d{3}Z$/, "Z");
|
||||
}
|
||||
|
||||
interface ClaudeRow {
|
||||
pid?: number;
|
||||
id?: string;
|
||||
cwd?: string;
|
||||
kind?: string;
|
||||
startedAt?: number;
|
||||
sessionId?: string;
|
||||
name?: string;
|
||||
status?: string;
|
||||
state?: string;
|
||||
}
|
||||
|
||||
/** `claude agents --json --all`, or null when the CLI is missing or says no. */
|
||||
export async function claudeRoster(exec: Exec, env: Env = process.env): Promise<RosterRow[] | null> {
|
||||
let result: ExecResult;
|
||||
try {
|
||||
result = await exec("claude", ["agents", "--json", "--all"], { timeoutMs: 10_000 });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (result.code !== 0) return null;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(result.stdout);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const rows: unknown[] = Array.isArray(parsed)
|
||||
? parsed
|
||||
: parsed && typeof parsed === "object"
|
||||
? (Object.values(parsed as Record<string, unknown>).find(Array.isArray) as unknown[] | undefined) ?? []
|
||||
: [];
|
||||
const out: RosterRow[] = [];
|
||||
for (const raw of rows) {
|
||||
const row = raw as ClaudeRow;
|
||||
if (!row || typeof row.id !== "string") continue;
|
||||
const state = readJson<{ respawnFlags?: unknown }>(join(claudeHome(env), "jobs", row.id, "state.json"));
|
||||
out.push({
|
||||
engine: "claude-code",
|
||||
id: row.id,
|
||||
...(row.sessionId ? { sessionId: row.sessionId } : {}),
|
||||
...(row.name ? { name: row.name } : {}),
|
||||
...(row.cwd ? { cwd: row.cwd } : {}),
|
||||
...(row.state ? { state: row.state } : {}),
|
||||
approvals: approvalsFromFlags(state?.respawnFlags),
|
||||
...(isoFromMs(row.startedAt) ? { startedAt: isoFromMs(row.startedAt) } : {}),
|
||||
...(typeof row.pid === "number" ? { pid: row.pid } : {}),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The flags moshcode's engines use to skip their own approval prompts. */
|
||||
const BYPASS_FLAGS = ["--dangerously-skip-permissions", "--dangerously-bypass-approvals-and-sandbox", "--yolo", "--turbo"];
|
||||
|
||||
export function carriesBypass(args: unknown): boolean {
|
||||
return Array.isArray(args) && args.some((arg) => BYPASS_FLAGS.includes(String(arg)));
|
||||
}
|
||||
|
||||
/** `~/.moshcode/herd/sessions.json` (or `$MOSHCODE_HERD_DIR/sessions.json`). */
|
||||
export function moshcodeManifestPath(env: Env = process.env): string {
|
||||
const dir = env.MOSHCODE_HERD_DIR && env.MOSHCODE_HERD_DIR.trim() !== "" ? env.MOSHCODE_HERD_DIR : join(homeDirOf(env), ".moshcode", "herd");
|
||||
return join(dir, "sessions.json");
|
||||
}
|
||||
|
||||
interface MoshcodeMeta {
|
||||
engine?: string;
|
||||
args?: unknown;
|
||||
cwd?: string;
|
||||
created?: number;
|
||||
agent?: boolean;
|
||||
herd?: string;
|
||||
fleet?: string;
|
||||
swarm?: string;
|
||||
member?: string;
|
||||
approvals?: string;
|
||||
}
|
||||
|
||||
export async function moshcodeRoster(env: Env = process.env): Promise<RosterRow[] | null> {
|
||||
let text: string;
|
||||
try {
|
||||
text = readFileSync(moshcodeManifestPath(env), "utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
let parsed: { sessions?: Record<string, MoshcodeMeta> };
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const sessions = parsed && typeof parsed === "object" && parsed.sessions && typeof parsed.sessions === "object" ? parsed.sessions : {};
|
||||
return Object.entries(sessions).map(([name, meta]) => {
|
||||
const approvals: Approvals =
|
||||
meta.approvals === "bypass" || meta.approvals === "native"
|
||||
? meta.approvals
|
||||
: meta.agent === true || carriesBypass(meta.args)
|
||||
? "bypass"
|
||||
: "native";
|
||||
return {
|
||||
engine: `moshcode/${meta.engine ?? "unknown"}`,
|
||||
id: name,
|
||||
name,
|
||||
...(meta.cwd ? { cwd: meta.cwd } : {}),
|
||||
approvals,
|
||||
...(isoFromMs(meta.created) ? { startedAt: isoFromMs(meta.created) } : {}),
|
||||
...(meta.fleet ? { fleet: meta.fleet } : {}),
|
||||
...(meta.swarm ? { swarm: meta.swarm } : {}),
|
||||
...(meta.member ? { member: meta.member } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Both rosters, each reading the real thing. */
|
||||
export function defaultRosters(exec: Exec = realExec, env: Env = process.env): Rosters {
|
||||
return {
|
||||
claude: () => claudeRoster(exec, env),
|
||||
moshcode: () => moshcodeRoster(env),
|
||||
};
|
||||
}
|
||||
|
||||
/** Which roster answers for an engine string, so "not listed" can mean "gone" rather than "unknown". */
|
||||
export function rosterFor(engine: string | undefined): keyof Rosters | null {
|
||||
if (engine === "claude-code") return "claude";
|
||||
// moshcode names every pane it starts, tmux ones included, in its herd manifest.
|
||||
if (engine?.startsWith("moshcode/") || engine === "tmux") return "moshcode";
|
||||
return null;
|
||||
}
|
||||
|
||||
/** A Claude Code job id: the first eight hex characters of its session id. */
|
||||
export const JOB_ID_RE = /^[0-9a-f]{8}$/i;
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
/**
|
||||
* Can this engine's roster hold the member at all? `claude agents` lists
|
||||
* background jobs only, so an interactive or `-p` session (a UUID member with
|
||||
* no job id) is never in it and its absence says nothing. moshcode's manifest
|
||||
* holds every pane it started. Only a member the roster can hold is "gone"
|
||||
* when the roster no longer lists it.
|
||||
*/
|
||||
export function rosterHolds(engine: string | undefined, member: string, session: string | undefined): boolean {
|
||||
const roster = rosterFor(engine);
|
||||
if (roster === null) return false;
|
||||
if (roster === "moshcode") return true;
|
||||
return JOB_ID_RE.test(member) || (typeof session === "string" && JOB_ID_RE.test(session));
|
||||
}
|
||||
|
||||
/**
|
||||
* The job id `claude stop` takes for a claude-code member: the member id of a
|
||||
* background job, else the first eight characters of the record's session
|
||||
* when that is a session UUID. Null for an interactive session with no job
|
||||
* id, which the tool cannot stop. A session equal to the member is the
|
||||
* engine's own id echoed back in `member.start`, not a job handle.
|
||||
*/
|
||||
export function claudeJobId(member: string, session: string | undefined): string | null {
|
||||
if (JOB_ID_RE.test(member)) return member;
|
||||
if (typeof session !== "string" || session === member) return null;
|
||||
if (JOB_ID_RE.test(session)) return session;
|
||||
if (UUID_RE.test(session)) return session.slice(0, 8);
|
||||
return null;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue