loom

package module
v0.0.0-...-1c410c5 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 21, 2026 License: MIT Imports: 44 Imported by: 0

README

loom

A harness around the harnesses. loom drives multiple coding-agent CLIs — Claude Code, Codex, GitHub Copilot CLI, opencode, Google Antigravity's agy (Gemini), and others — as long-lived workers behind one Go interface, so a backend (or a human) can hand work to whichever agent fits, keep a session alive across turns, or fan one prompt across several at once.

A weaving harness is literally a loom component, and a loom holds many harnesses at once. That's the idea: each agent CLI is its own harness; loom weaves them.

MIT licensed.

Why

Modern coding agents (Claude Code, agy, Codex, …) are themselves harnesses — they have their own tools and loops. Driving them from a backend usually means one-shot re-spawns that reload context every time. loom gives them a common Session interface with context that persists across turns, plus a panel mode to run several agents on the same task and compare.

The backends are deliberately heterogeneous, because the real CLIs are:

backend transport multi-turn notes
claude persistent --input-format stream-json over stdin/stdout (NDJSON) one process, many turns, holds context the good path — verified (see below)
codex one-shot codex exec --json per turn (JSONL events on stdout, prompt over stdin) codex exec resume <thread_id> (fresh process/turn; codex checkpoints every session to disk) claude-grade parityverified (codex-cli 0.141.0, 2026-07-02): resume recalls context, tool events stream, interrupt = signal the turn's process (the on-disk session survives → steer via the next Send). Trust ladder maps to codex's native kernel sandbox: --consultread-only (instruction AND enforcement), --isolateworkspace-write (no docker image needed), skip-permissions → --dangerously-bypass-approvals-and-sandbox. MCP config → translated to per-invocation -c mcp_servers.<name>.… overrides (stdio command/args/env + streamable-HTTP url/http_headers; shape + live tool-call round-trip verified codex-cli 0.144.6, 2026-07-20 — local sessions only, a remote box rides its own config.toml). Remaining claude-only opts (allowed-tools, permission-mode) ride codex config.toml/profiles instead.
local stateless POST /v1/chat/completions (OpenAI-compat: llama-chip :8090, LM Studio, vLLM) loom keeps the message history the simplest backend — no process/stdio; verified (single + multi-turn) against the live rig; free. Makes panel a cloud+local council.
agy one-shot agy -p per turn --conversation <id> resume (fresh process/turn) works (single-turn verified in a live panel, 2026-06-29) — two headless bugs worked around: stdin-EOF hang (feed empty stdin) + stdout-drop (recover the answer from the transcript file)
opencode one-shot opencode run --format json per turn (JSON events on stdout, prompt as argv) run -s <sessionID> (fresh process/turn; opencode persists sessions) verified (opencode-ai 1.17.15, 2026-07-08): resume recalls context, tool events stream, and step_finish reports real USD cost — the only backend that fills Reply.CostUSD. Model via -m provider/model (the opencode-go/zen zoo: glm, kimi, deepseek, qwen…). Skip-permissions → --auto. No native filesystem sandbox — wall it externally when it matters. MCP config → translated to a temp opencode.json handed via OPENCODE_CONFIG (local + remote server forms; live tool-call round-trip verified opencode-ai 1.17.15, 2026-07-20 — local sessions only; headless permission gating may still need --skip-permissions for the tools to run).
copilot one-shot copilot -p … --output-format json per turn (JSONL on stdout) --resume <sessionId> (fresh process/turn; the id is stable) verified (GitHub Copilot CLI 1.0.69, 2026-07-08): resume recalls context, tool events stream, the final result line carries the session id + usage. Trust ladder rides copilot's native permissions: skip-permissions → --allow-all, --isolate--allow-all-tools with file paths still walled to the workdir (copilot's path verification), consult → directive with unapproved tools failing closed. Real hinge mappings: MCP config → --additional-mcp-config @file, allowed-tools → --allow-tool=…. Always --no-auto-update. NOTE: VS Code's copilot-chat ships its own copilot that can shadow npm's on PATH and lag versions — pin with LOOM_COPILOT_BIN.
MCP + skills across backends (verified 2026-07-20, on-box CLIs)

One --mcp-config (claude-format JSON, {"mcpServers":{…}}) now reaches every backend that can take it, and the OpenAI shim routes by model name (gpt*/codex*/sol/terra/luna → codex, copilot-* → copilot, <backend>:<model> pins, everything else → claude) instead of hardwiring claude:

backend MCP carrier skills
claude --mcp-config <file> natively; remote: the local file is planted on the target (base64 + EXIT-trap temp file) and the flag repointed — the local file is the source of truth (remote+isolate mounts it into the sandbox) ~/.claude/skills/ (ships in the mounted --claude-home) + project .claude/skills/
codex translated → -c mcp_servers.<name>.… overrides per invocation (mcpbridge.go; live tool-call proven, codex-cli 0.144.6); remote: the same overrides now survive bash -lc — every argv element is quoted (the raw join used to mangle the TOML; UNVERIFIED on a real remote — no codex on the practice box) $CODEX_HOME/skills/ + project .codex/skills/ + project .agents/skills/ (discovery verified via codex debug prompt-input; does NOT read .claude/skills/)
copilot --additional-mcp-config @<file> natively (same JSON shape); remote: local file planted on the target, @ repointed (argv-fixture tested; UNVERIFIED on a real remote) project .claude/skills/ / .github/skills/ / .agents/skills/ + personal ~/.copilot/skills/ / ~/.agents/skills/ (verified via copilot skill list; no per-invocation flag — discovery is directory-driven)
opencode translated → temp opencode.json via OPENCODE_CONFIG env (mcpbridge.go; live tool-call proven, opencode-ai 1.17.15 — headless permission gating may need --skip-permissions); remote: the translated doc is planted on the target each turn and OPENCODE_CONFIG exported in the same script (argv-fixture tested; UNVERIFIED on a real remote) native skill tool, on-demand: .opencode/skills/ + .claude/skills/ + ~/.agents/skills/ + ~/.config/opencode/skills/ (verified via opencode debug skill, opencode-ai 1.17.15 — it DOES read Claude-format skills)
agy not carried (structurally limited, below) none

Remote MCP, the contract: when --mcp-config names a file that exists locally, that file is the single source of truth — its bytes ride inside the remote script as base64, land in a private /tmp file, and an EXIT trap removes them when the CLI ends. A path with no local file keeps the legacy meaning: it names a file already on the remote box. Either way the servers the config names must resolve on whichever box the agent runs. Live status (2026-07-21, NOCIX): the claude materialization is proven to the exact startup gate — a planted config passes claude's MCP validation (a missing one exits at startup with "MCP config file not found"); a full remote MCP tool call is UNVERIFIED there (the box's claude auth had expired, and loom does not run login flows).

Serve-side skills (loom serve --openai-home-root <root>): a shim seat named <model>#<role> gets its role's skills too. A claude seat already reads them from the mounted role home — author into <root>/<role>-claude-home/skills/. A host-run seat (codex/copilot/opencode) reads from its session workdir instead, so author into <root>/<role>-skills/ and loom mirrors them into <root>/<role>-workdir at open (the exact --skills path). The role workdir now resolves independently of any claude home — a codex seat needs no <role>-claude-home marker. Live-proven 2026-07-21: a real codex seat driven through a scratch serve (model: codex#capcom) read its mirrored .agents/skills/greeter/SKILL.md and returned the planted magic word.

The cross-provider skills lever is a directory, not a protocol — the harnesses split on which they read: .claude/skills/ reaches claude + copilot + opencode; .agents/skills/ reaches codex + copilot + opencode. Neither alone hits all four, but between them every backend is covered. loom automates this with **`--skills

`**: a source folder of `/SKILL.md` skills, mirrored at `Open` into the skill dir the **backend being run** reads — `claude → .claude/skills/`, `codex → .agents/skills/`, `copilot`/`opencode → .claude/skills/` (they read both, so one copy suffices). loom always knows its backend at `Open`, so it writes exactly one place, no redundant copies. Author once, the harness sees it. Local only (a remote box owns its filesystem); a same-named target skill is replaced by the authored source, other skills left untouched.

Why agy is the awkward one: agy has no working stdio/stream-json mode — it's an open, Google-acknowledged gap (antigravity-cli issues #76 stdout-drop, #119 stream-json parity, #31 --acp); --output-format json is currently rejected. The two real workarounds are transcript-scrape (what loom does — the right path on Windows) or a pseudo-TTY wrap (script -qec '…' /dev/null, Unix-only). When agy ships stream-json, the agy backend swaps to the clean path and drops the scrape.

Verified (2026-06-29, Claude Code v2.1.196)

The claude backend's premise is proven on the real path, not assumed:

  • Context across turns in one process — turn 1 "remember 42" → turn 2 recalled "42", same session_id. (The LOOM_SMOKE=1 test reproduces this.)
  • Cost amortization is real, measured — a cold one-shot pays ~27K cache-creation tokens every spawn ($0.055 for a trivial turn); in a live session, turn 2 cache-READ ~24K (incl. turn 1's context) and created only ~7K. So loom chat's persistent session is materially cheaper than re-spawning per turn.
  • Gotcha baked in: --print + --output-format stream-json requires --verbose; the ~27K cold context loads the working dir's CLAUDE.md/MCP/memory, so run loom in the repo you want that context from.

Use

go build -o loom.exe ./cmd/loom

loom run   --agent claude "summarize the files in this dir"      # one-shot
loom run   --agent claude --events "count the .go files"         # + stream tool calls/thinking to stderr
loom run   --agent local  --model gemma-4-12b "..."              # a free local model on llama-chip's :8090
loom chat  --agent claude --dir /path/to/repo                    # multi-turn: one msg per stdin line
loom run   --agent claude --clone https://github.com/org/repo.git "..." # clone into a fresh temp workdir
loom panel  --agents local,claude "is this function correct?"    # cloud+local council: fan + compare
loom review --agents claude,local [--dir R] [--diff HEAD] [files...]   # review a git diff or files
loom duo    --agent claude --critic-agent codex --dir /repo "build X"  # worker builds, critic (read-only) judges each build point
loom run    --agent claude --isolate --dir /path/to/repo "..."   # claude in a docker sandbox (host walled off)
loom run    --agent claude --remote cpuchip@box --dir /repo "..." # claude on another machine over ssh
loom run    --agent claude --remote cpuchip@box --isolate --dir /repo "..." # sandboxed claude ON the remote box
loom run    --agent claude --resume <session-id> "..."           # reattach to a prior session (survives process/pipe death)
loom run    --agent claude --json "..."                          # emit the Reply as one JSON line on stdout (subprocess callers)
loom agents                                                      # list backends

loom review loads a git diff (default: the working-tree diff vs HEAD; --diff HEAD/main...HEAD) or named files, and fans a reviewer prompt across the agent(s) — a one-shot code review, or a cloud+local council. loom found and fixed real bugs in its own code this way (history-poisoning, a data race, and an incomplete <think>-stripper — the orphan </think> case a self-review caught).

--events makes loom observable — the agent's tool calls (→ Glob), tool results, and thinking stream to stderr as they happen, while the final answer comes back on stdout. Backends emit what they can: claude the full stream, local/agy a coarse one.

Duo — worker + critic build loop (loom duo)

loom duo binds two agents to one working directory: a worker that builds and a critic that evaluates the worker's trajectory at every build point, with loom running the loop between them. It's trajectory eval made operational — a second, opposed seat checking the work as it's built, not after.

loom duo --agent claude --model sonnet \
         --critic-agent codex --critic-model gpt-5.6-terra \
         --dir /path/to/repo [--rounds 6] [--json] [--events] \
         "build the thing"

The build point is the turn boundary — loom's per-turn exec model already gives a natural checkpoint after each reply. Each round is worker turn → critic turn → route:

  • The worker opens with your trust flags (exactly as run), works in coherent increments, and ends each reply with a build-point report (what it did, what it verified, what's next). When the whole task is done it begins a reply with BUILD COMPLETE.
  • The critic opens read-only (--consult) on the same --dir, so it inspects the real tree — files, git diff, the tests — not just the worker's report (where the two disagree, the tree wins). It ends every reply with VERDICT: CONTINUE | REVISE | DONE. Its session is resumed each round, so it accumulates the whole trajectory — that memory is the point.
  • loom routes on the verdict: REVISE sends the critic's feedback back into the worker; CONTINUE tells the worker to proceed; DONE ends the loop. BUILD COMPLETE does not end the loop by itself — nothing outranks the check; only a critic DONE (or the --rounds cap) ends it. A garbled verdict fails open (treated as CONTINUE with a warning to stderr) — the loop never wedges on the critic, only on the tree.

The two seats carry opposed mandates — the worker is driven to finish, the critic to distrust the finish — which is exactly why the pair catches the gap between "I built it" and "it works" that a single agent grading its own work cannot. The critic defaults to the worker's backend/model; point it at a different one (--critic-agent/--critic-model) for genuine second-eyes.

--json emits one object: {worker_session, critic_session, rounds:[{verdict, feedback_summary}…], status, text, cost_usd}. Both session ids surface so either seat can be resumed or inspected afterward (loom run --resume <id>, --consult for the critic). --events streams both seats' tool calls to stderr, tagged [worker]/[critic].

Flow — a step DAG with cached resume (loom flow)

loom flow runs a JSON-declared DAG of steps — each one loom session with its own working dir, prompt, dependency edges, and a deterministic oracle (shell cmd in the step's dir; exit 0 = green) — with bounded parallelism and a journal under $LOOM_HOME/flows/<flow-id>/. The killer feature is resume-from-journal: loom flow resume <flow-id> SKIPS every step whose latest record is green (green = cached, served instantly) and re-runs only the red ones, with cached greens satisfying their dependents. A foreman crash costs nothing but the re-issue of the command. Design doc: docs/proposals/loom-flow.md.

loom flow run --skip-permissions my-flow.json     # run the DAG (flags BEFORE the file)
loom flow resume --skip-permissions my-flow       # skip greens, re-run reds
{"flow":"svc-refactor","concurrency":3,"steps":[
  {"id":"brief","prompt":"Write refactor-brief.md …","oracle":"test -s refactor-brief.md"},
  {"id":"build","agent":"codex","dir":"svc","prompt_file":"prompts/build.md","needs":["brief"],
   "oracle":"go build ./... && go test ./..."},
  {"id":"review","needs":["build"],"prompt":"Review; write REVIEW.md","oracle":"test -s REVIEW.md"}]}

Semantics: a failed step marks its transitive dependents dependency_failed (recorded, never silent) while independent branches keep running; --budget gates DISPATCH (in-flight steps finish; refused steps record budget_refused); relative dir/prompt_file resolve against the flow file; exit 0 only when every step is green. Trust/plumbing flags (--isolate --skip-permissions --mcp-config --skills) are flow-wide and per-invocation — re-pass them on resume: the saved copy never carries trust, and the first live smoke showed why you'll notice — without them, a claude step's edits fail closed while the model claims the write happened; the oracle stays red until the flags return.

Live-proven (2026-07-21, haiku): a two-step flow went 1/2 green (the oracle caught the model writing beta - alpha for beta-alpha — the reply claimed success, the tree disagreed, the tree won), then flow resume served step one cached with zero model turns and re-ran only step two to all-green.

Structured output — --output-schema

loom run --output-schema contract.json "..." forces the worker's final answer through a JSON Schema, so a foreman parses a contract instead of regex-mining prose. The schema is appended to the prompt (a model can't hit a contract it never saw), the reply's JSON is extracted tolerantly (whole reply, ```json fences, or the first decodable value in prose), and validated. On failure loom re-prompts once with the violations + the schema; still invalid → non-zero exit with the errors on stderr. With --json, the validated object rides the Reply as a parsed field. Works on send too (not --detach — there's no session at hand to re-prompt).

The validator is a deliberate hand-rolled subset (loom stays zero-dependency): type (incl. integer, multi-type arrays), properties/required (unknown keys allowed), items, enum, minimum/maximum, minLength/maxLength, minItems/maxItems. Anything else ($ref, oneOf, pattern, format, …) is ignored, not rejected — keep contracts inside the subset.

Usage + budget — what a turn cost, and a ceiling on the loop

Every Reply (and every run manifest) now carries a normalized usage block — tokens, USD, and a cost_source marker that keeps the fidelity honest. Each backend reports exactly what its CLI emits (all four shapes live-captured 2026-07-20), never an invented number:

backend tokens USD cost_source
claude in / out / cache-read / cache-write (per turn) real (per-turn delta) real
opencode in / out(+reasoning) / cache-read / cache-write (per step) real (summed steps) real
codex in (fresh, cached split out) / out / cache-read none reported none
copilot output tokens only + premium_requests (its billing unit) none reported none
local in / out (standard OpenAI usage block) none reported (locally free) none
agy nothing machine-readable none none

loom runs sums today per backend under its table (claude 3 run(s) $0.41, codex 2 run(s) 84,120 tokens). cost_source: "estimated" is reserved for a future price-table experiment — nothing sets it today.

--budget <n> (on run, send, chat, duo) is a spend ceiling with one number, two meters: a turn with real USD counts its dollars against the limit; a token-only turn counts its total tokens against the same limit; either meter crossing refuses further turns (the turn in flight always completes). duo ends with status budget_exceeded; chat refuses the next line; on run/send the budget gates the --output-schema retry turn. A backend reporting neither (agy) is invisible to the budget — pair it with --rounds-style caps instead.

Sessions — carry & resume

Two different guarantees, both verified on the real path:

  • Carry context across turns (one live process): the claude backend is a single persistent claude -p --input-format stream-json … --verbose process; each turn writes to the same stdin and reads to that turn's result, so claude holds full context. That's loom chat. ✅ (LOOM_SMOKE oracle: turn 1 "remember 42" → turn 2 "42", one process.)
  • Resume across a process restart / dropped pipe (--resume <id>): the session persists to disk on whichever box runs claude. loom run/loom chat print the session_id; reopen it later — even from a fresh process on another day — with --resume <id>, and the context is restored. ✅ verified 2026-06-30 by a two-process oracle (process A remembers 73 and exits → a brand-new process B --resume recalls 73) and the CLI end-to-end. This is what makes a remote session durable: a broken ssh pipe doesn't lose the session — loom just reattaches by id.
loom run --agent claude "remember the number 88, reply OK"  # prints: [session <id> — resume: loom run --resume <id> ...]
loom run --agent claude --resume <id> "what number?"         # → 88, from a brand-new process

Under the hood: claude --resume <id> (the real CLI also has --session-id <uuid> to pre-assign an id, --fork-session to branch, -c for most-recent — natural follow-ons).

  • Interrupt a turn in flight & steer (Interrupt()): stop the agent while it's working and redirect it, without losing the session. loom writes a stream-json control_request/subtype:interrupt to stdin (the real wire format, probe-verified 2026-06-30 — claude acks with a control_response success, then ends the turn with a result subtype:error_during_execution); the subprocess stays alive, so a following Send steers with full context. ✅ verified by a live oracle: interrupt a running turn (~0s to stop) → Send "reply ALIVE" → ALIVE, context intact. Race-checked (go test -race) on the concurrent read-vs-interrupt path. In the CLI, the first Ctrl-C during a turn interrupts the agent (not loom) — type your next line to steer; a second Ctrl-C at the prompt exits. Programmatically it's the optional loom.Interruptible interface (if it, ok := sess.(loom.Interruptible); ok { it.Interrupt() }).

The session-lifecycle triad is complete on the real path: carry (across turns) · resume (across process death) · interrupt+steer (mid-turn). The only remaining nuance is concurrent mid-turn injection without interrupting (queue-while-working) — undocumented upstream and not needed: interrupt-then-instruct covers the steering case cleanly.

Isolation — the wall (--isolate)

A Claude Code session runs in a real directory with full host access (it has Bash, Read, Write). That's the asset — loom can hand the substrate reach into a repo or corpus. It's also the risk — a full-filesystem agent commanded by a backend could touch the host. --isolate is the wall: it runs claude inside a docker container (docker/Dockerfile.claudeloom-claude) that sees only:

  • /work — the repo (--dir), the one host path the agent can read/write, and
  • ~/.claude/.credentials.json — the subscription auth, mounted read-only (claude writes its own session state into an ephemeral in-container ~/.claude, gone on --rm — pass --claude-home to persist it and to inject skills/instructions; see Configuring the agent below).
docker build -t loom-claude -f docker/Dockerfile.claude .
loom run --agent claude --isolate --dir /path/to/repo "review this repo"

Verified: the container's / is a stock Linux fs + /workno host C:\Users, no host system. The agent can't reach anything you didn't hand it. (Honest scope: the container still holds the OAuth token and has network, so it isn't zero-trust — a tighter version would use a scoped/short-lived token or egress limits. But the host filesystem is walled.) agy --isolate is not yet wired (its Antigravity auth is gnarlier). This is the presiding covenant made literal — delegation needs a lawful wall (D&C 121).

Remote (--remote)

The third point on the trust axis: run the agent on another machine over ssh — the same transport-wrapping pattern as --isolate. stream-json flows over the ssh pipe unchanged. This is how a backend (pg-ai-stewards) commands a Claude Code session on a remote box — the substrate's reach, at distance.

loom run --agent claude --remote cpuchip@workchip --dir /home/cpuchip/repo "review this repo"

Verified 2026-06-30: a Windows loom.exessh cpuchip@<box> → a Claude Code agent listing/summarizing a repo on the remote Ubuntu machine, its → Bash tool-events streaming back live (~$0.12/turn). The pipe worked first try; the far-side PATH (below) was the only catch.

The whole trust axis is one transport tree in the claude backend (claudeCmd):

direct                claude …                                                     (full host access)
--isolate             docker run -i … loom-claude claude …                          (host walled)
--remote H            ssh -T H bash -lc 'cd <dir> && claude …'                       (another machine)
--remote H --isolate  ssh -T H bash -lc 'docker run -i … loom-claude claude …'       (sandboxed ON the remote)

--remote --isolate composes the two: a sandboxed claude on the remote box — the exact shape "manage remote claude sessions safely" wants (reach + wall). The docker command runs over ssh, so its volume paths resolve on the remote ($HOME expanded there, --dir is a remote path). Pass --dir to scope the sandbox; without it, it falls back to the remote $HOME.

Requirements: the remote box has claude installed + authed (it uses its own ~/.claude), and your ssh key reaches it. loom runs the remote command in a login shell (bash -lc) so the box's full PATH loads — a plain non-interactive ssh host "claude …" uses a shell that misses nvm / npm-global / ~/.local/bin installs and dies with claude: command not found even when claude works fine in your interactive ssh session there (a real gotcha, hit + fixed 2026-06-30). For --remote --isolate, the remote also needs docker + the loom-claude image (build it there: docker build -t loom-claude -f docker/Dockerfile.claude .). Verify from your own agent-loaded shell — a passphrase-locked key with no agent can't authenticate from an automated context.

--model overrides the model (e.g. --model haiku); --dir sets the agent's cwd.

Configuring the agent — the substrate hinge

For a backend (pg-ai-stewards) to drive claude as a worker — not just ask it a question — loom forwards claude's configuration flags, and under --isolate controls the container's ~/.claude. Two walls, set independently:

  • Filesystem wall--isolate / --remote: where the agent can touch (container / remote / host).
  • Capability wall--mcp-config + --allowed-tools: what it can call.
loom flag claude flag for
--mcp-config <file> --mcp-config the hinge — wire in an MCP server (e.g. pg-ai-stewards) so the agent reads/writes the substrate back
--allowed-tools <list> --allowed-tools scope which tools (incl. MCP) it may call — the capability wall
--permission-mode <mode> --permission-mode e.g. acceptEdits, plan
--skip-permissions --dangerously-skip-permissions headless autonomy — safe only inside --isolate (the container is the wall)
--system-prompt-file <f> --append-system-prompt-file inject instructions
--claude-home <dir> (mount) --isolate only — the injection point, below

--claude-home <dir> is the injection point for everything under --isolate. It mounts a host directory as the container's writable ~/.claude, so it carries skills (<dir>/skills/), instructions (<dir>/CLAUDE.md), settings, MCP config — and it persists claude's session state across containers. That last part is what makes resume + isolate work: every docker run is a fresh container, but the session lives in the mounted home, so a later --resume reattaches. Verified 2026-06-30 (remember 55 in one container → recall 55 in a brand-new one, projects//sessions/ written to the home). Without --claude-home, an isolated session's state dies with the container (--rm), so --resume --isolate silently starts fresh.

Config-file paths (--mcp-config, --system-prompt-file) are interpreted on the target — local host, remote box, or (under --isolate) inside the container. So for isolate, put them in --claude-home and pass the container path (e.g. --mcp-config /home/node/.claude/mcp.json).

The substrate pattern: keep a per-work-item --claude-home seeded with the substrate's skills + instructions + the pg-ai-stewards MCP config; mount the repo as --dir; run --isolate --skip-permissions; store loom's session_id on the work-item so a later dispatch resumes by re-mounting the same home. That is loom as the substrate's hands: reach (dir), voice back (MCP hinge), wall (isolate), memory (resume).

Full integration guide — a copy-in contract for a backend driving loom (subprocess model, the two walls, the canonical dispatch, prereqs, council note): docs/pg-ai-stewards-integration.md.

Serve — loom as a service (loom serve) + warm-resident sessions

loom serve runs loom as a websocket service: a client (another loom, a browser) drives sessions over a socket with a token instead of spawning subprocesses/ssh. It's the fourth transport (--connect ws://…), honoring the same Backend/Session/Interruptible interfaces — so run/chat/review work unchanged over the wire. There is no TLS yet: bind a mesh IP (e.g. NetBird 100.x), never 0.0.0.0.

loom serve --token-file ~/.loom/tokens --add-token         # mint a token (first run)
loom serve --listen 100.x.y.z:7777 --token-file ~/.loom/tokens [--idle-ttl 4h]

The warm-resident upgrade (the cheap round). A round — home implements → a remote box verifies over the socket — used to respawn claude and cold-read the whole session every drive (dollars per turn on a big session). Now a session opened under a stable name stays resident and warm; a later open of that name reattaches to the live process instead of respawning. First open cold-reads once; every later drive is a cache-warm reattach.

# reattach-or-open by name, send a turn, leave the resident warm for the next drive:
loom send --connect ws://100.x.y.z:7777 --token T --session verify-loop "verify feat/x @ <sha>"

# a minutes-long turn, detached — returns a turn-id at once; fetch the verdict later:
loom send  --connect … --token T --session verify-loop --detach "build + re-extract + report"   # → 7
loom await --connect … --token T --session verify-loop --turn 7 [--timeout 60]                   # blocks then returns the reply
loom await --connect … --token T --session verify-loop --last-reply                              # most recent turn, id unknown

loom sessions --connect … --token T   # list residents: name, backend, frozen opts, idle, last turn-id

run/chat also take --session <name> over --connect (a warm drive from discrete tool calls). The design guarantees, all hermetically tested (serve_test.go, no live claude / no cost):

  • Name-keyed, id-agnostic — keyed on the client-chosen name, never claude's session id (which forks on every cold resume). Two-writer fence: a name that's resident reattaches; a second process is never spawned against one lineage (opens serialize; opts froze at first open — a reattach with conflicting opts reattaches and notes it).
  • Per-turn reply ring — the last few replies are buffered by turn-id, so a socket that drops mid-turn loses no verdict: reconnect (open the same name) and await it.
  • send --detach / await — a long turn runs without a synchronous client pinned to it.
  • Idle TTL — a resident idle past --idle-ttl (default 4h; 0 = never) is downgraded: its process is closed but its evolved lineage id is remembered, so the next open of the name cold-resumes it — one cold-read, never lost data.

An open without a name is unchanged: ephemeral, dropped on disconnect (unless keep_alive).

Pairing & pinned mTLS (wss://) — no CA, no shared secret

loom serve binds a mesh IP over plaintext ws by design (the encryption is the mesh's, e.g. WireGuard). To make loom safe on its own — not just because it's on the mesh — two nodes can pair once and then talk over pinned-certificate mTLS:

# on each box, once — the watch-pairing ceremony:
loom pair --listen 0.0.0.0:9999            # box A waits
loom pair --connect A-host:9999 --name box-a   # box B dials

# both terminals show the SAME six-digit PIN grouped "465 629" and prompt:
#   match on both screens? [y/N]
# tap y on BOTH → each pins the other's key. n or a mismatch → abort (possible MITM).

# thereafter, serve + connect over mTLS instead of a token:
loom serve --tls --listen 0.0.0.0:7777                    # token optional under --tls; the pin is the wall
loom send  --connect wss://A-host:7777 --peer box-a --session iw "…"

The trust model is pinned raw keys (RFC 7250 / the SSH-and-WireGuard shape), not a CA: each node self-generates a persistent keypair + self-signed cert under ~/.loom/identity/; the ~/.loom/pins file maps a peer name → its SPKI fingerprint; verification is "does the peer's cert fingerprint match the pin?" — no certificate authority, no expiry ceremony. Revoke a peer = delete its pin. The pairing uses commit-then-reveal + a Short Authentication String bound to the ECDH shared secret (ZRTP's shape): a man-in-the-middle who substitutes keys makes the two PINs diverge, so the human's tap catches it. Zero external dependencies — Go stdlib crypto only. Plain ws:// + --token is unchanged; wss:// requires --peer <name> (dial a known pin, never trust-on-first-use).

For clients that can't do the two-screen PIN compare (a phone app, an unattended machine, an agent), loom enroll is the one-code alternative: the box shows a short code, the client submits it, and both sides end up mutually pinned just like pairing. And loom serve --tls-listen <addr> runs plain and mTLS at once so live clients migrate one at a time. See docs/enrollment.md for the enroll wire protocol (what the brain-app implements) and the plain→mTLS migration map.

# on the box: open a one-shot enrollment window, read the printed code to the device
loom enroll --serve --listen 100.x.y.z:7779 --name phone
# on the enrolling machine/agent: submit the code, pin the box
loom enroll --connect 100.x.y.z:7779 --code GV56-OSAX --name mybox
Test
go test ./...                 # pure unit tests (parsing, registry) — no money
LOOM_SMOKE=1 go test ./...    # + the live claude multi-turn oracle (spends a little)

Status / roadmap (v0.4)

  • ✅ Core Backend/Session interface · claude backend (persistent stream-json) · local backend (OpenAI-HTTP → :8090; verified single + multi-turn against the live rig; cloud+local panel proven) · structured event streaming (SendStream + --events; verified — claude's tool calls/thinking observable, proven on a real tool-using task) · agy backend (experimental) · panel (concurrent council) · loom review (diff/files → fan a review across agents) · CLI · smoke oracle.
  • Dogfooded: loom reviewed its own code and found+fixed real bugs (history-poisoning, a SessionID data race, the orphan-</think> CoT-strip gap).
  • Isolation (--isolate): claude in a docker sandbox (loom-claude), host walled to /work + read-only creds — verified.
  • North star: loom = the substrate's agent fabric — a uniform, walled way to summon intelligence; its soul is running agentic harnesses (Claude Code, agy) the substrate can't run itself, safely. Axes: agency (raw model ↔ agent) × trust (local ↔ sandboxed ↔ remote).
  • Remote (--remote): ssh transport live-verified end-to-end (2026-06-30) — a Windows loom.exe drove a Claude Code agent on a remote Ubuntu box, its → Bash tool-events streaming back, ~$0.12/turn. The trust axis is complete on the real path (direct / --isolate / --remote).
  • Resume (--resume <id>): durable sessions — reattach to a prior session across a process restart / dropped pipe (context restored from claude's on-disk session store). Verified 2026-06-30 by a two-process oracle + CLI e2e. The piece that makes a remote session survive a broken pipe.
  • Interrupt + steer (Interrupt() / Ctrl-C): stop a turn in flight and redirect on the live session (stream-json control_request interrupt; probe-verified wire format). Live oracle + -race on the concurrent path. Completes the session-lifecycle triad (carry / resume / interrupt+steer).
  • remote + isolate: sandboxed claude on the remote box (ssh → docker-on-remote, volume paths resolved there via $HOME). Built + unit-tested (the composed argv); live-verify pending the loom-claude image built on the remote. Reach + wall composed — "manage remote sessions safely."
  • The substrate hinge (config surface): --mcp-config (wire the substrate MCP into the agent — reads/writes back), --allowed-tools (capability wall), --skip-permissions (headless, safe in --isolate), --system-prompt-file (instructions), and --claude-home (the container's ~/.claude: skills/instructions/settings + persisted sessions → resume+isolate now works, live-verified). loom can now drive claude as a configured substrate worker, not just a chat.
  • --json output + integration guide: --json emits the Reply as one stdout line (the clean "pull" channel for subprocess callers; events stay on stderr). Full contract in docs/pg-ai-stewards-integration.md — subprocess model, the two walls, exfil channels (bind-mount / MCP / git / stdout), push-vs-pull data flow, the canonical dispatch.
  • Proven by the substrate + non-root image fix (2026-07-01): pg-ai-stewards ran the first real substrate→loom dispatch (pull-only, direct mode: claude read a corpus, wrote findings.md back, clean --json Reply). It surfaced one real bug — the loom-claude image ran claude as root, which Claude Code refuses for --dangerously-skip-permissions. Fixed: the image now runs as non-root node (mounts at /home/node/.claude); isolate + --skip-permissions live-verified. Autonomous isolated headless dispatch is unblocked.
  • Serve + warm-resident (loom serve / --connect): loom as a websocket service (token-gated, mesh-only, no TLS yet). Name-keyed resident sessions reattach to a live warm process instead of respawning + cold-reading (the cheap round); a two-writer fence, a per-turn reply ring (recover a dropped mid-turn verdict), send --detach/await for minutes-long turns, loom sessions, and an idle TTL that downgrades to cold-resumable (lineage remembered, never lost). Hermetically tested (serve_test.go, -race, no live cost); live cheap-warm-round proof is Michael's.
  • ★ Next: the first real pg-ai-stewards → loom run --isolate --mcp-config … dispatch (the viability test); tighter sandbox (scoped/short-lived token, egress limits — toward zero-trust); agy --isolate; panel role-routing (doer→critic).
  • Backlog: --session-id/--fork-session (pre-assign / branch) surfaced in the CLI; agy --conversation resume in the CLI; a condenser for very long sessions (pattern from OpenHands' LLMSummarizingCondenser); routing/role assignment across the panel.

ACP — researched 2026-06-29, decision: skip for now

ACP (the Agent Client Protocol, JSON-RPC-2.0 over stdio, now folding toward the Linux Foundation A2A standard) is an optional future backend, not a near-term need:

  • Claude Code has no native ACP (real-path-confirmed on v2.1.196); it'd require Zed's Node adapter (@agentclientprotocol/claude-agent-acp, renamed from @zed-industries/… — verify the exact name before installing). Our direct stream-json claude backend is dependency-free, single-process, and faster — no reason to route it through ACP.
  • Codex has no native ACP either (community adapters only).
  • Gemini CLI (gemini, the standalone) had native --acp — but ⚠️ Google EoL'd it for personal accounts on 2026-06-18 (Pro/Ultra/free stopped serving; consolidated into closed-source agy). It survives only for enterprise / API-key. So gemini --acp is a dead path for usagy is now the only Google terminal agent. The route to a fuller Gemini is not ACP; it's container config-injection for agy (seed its config dir with GEMINI.md/skills/MCP inside an isolated container — the same container-is-the-config-surface trick as --claude-home, generalized). Gate: agy's headless auth in a Linux container (API-key vs OAuth-keyring) — feasibility-probe it before building the loom-agy backend.

Decision: keep the direct CLI backends; an ACP-client backend is only interesting now if Codex ships native ACP (Claude's direct stream-json is faster + dependency-free; gemini --acp is gone). (ACP→A2A is worth watching — same lineage as pg-ai-stewards' A2A engine.)

Built alongside pg-ai-stewards (a substrate that drives Claude Code for code review — loom is the natural home for that long-lived-session logic) and garrison (a local-first coding agent). Reference: OpenHands (All-Hands-AI/OpenHands) for heavier agent-loop patterns.

Documentation

Overview

Package loom drives multiple coding-agent CLIs (Claude Code, agy/Gemini, …) as long-lived workers behind one interface — a harness around the harnesses.

A weaving harness is literally a loom component, and a loom holds many harnesses at once; loom holds many agent CLIs and weaves their work together.

Index

Constants

View Source
const (
	FlowGreen         = "green"
	FlowAgentFailed   = "agent_failed"
	FlowOracleFailed  = "oracle_failed"
	FlowDepFailed     = "dependency_failed"
	FlowBudgetRefused = "budget_refused"
)

Flow-record statuses (the journal's `status` field).

View Source
const (
	CostReal      = "real"      // the CLI reported spend in USD
	CostEstimated = "estimated" // derived from tokens by a price table (unused today)
	CostNone      = "none"      // no cost signal — tokens (at most) are the truth
)

Cost-source markers for Usage.CostSource.

View Source
const DuoBuildComplete = "BUILD COMPLETE"

DuoBuildComplete is the line a worker begins its reply with when it believes the whole task is done. It is ADVISORY only — loom never ends the loop on it; the critic still judges that final report (nothing outranks the check).

View Source
const DuoDefaultRounds = 6

DuoDefaultRounds caps the build-point loop when the caller passes Rounds <= 0 (and is the CLI's default). Six is enough for a real increment→review→revise arc without letting a stuck pair spin forever.

View Source
const HeartbeatStaleAfter = 95 * time.Second

HeartbeatStaleAfter is how long without a heartbeat marks a still-open run as "heartbeat-stale" (probably a dead or wedged wrapper). Three missed 30s beats.

Variables

This section is empty.

Functions

func AddToken

func AddToken(path string) (string, error)

AddToken mints a fresh random token, appends it to the token file (creating it with 0600 perms if absent), and returns the plaintext so the operator can hand it to a client. This is what `loom serve --add-token` calls.

func Backends

func Backends() map[string]Backend

Backends returns the built-in backend registry keyed by name.

func ExtractJSONValue

func ExtractJSONValue(text string) (json.RawMessage, error)

ExtractJSONValue finds the FIRST JSON value in a model's reply text. Models wrap JSON in prose and code fences, so the search is tolerant, in order:

  1. the whole trimmed text decodes as one JSON value → that value
  2. each ``` fenced block (```json or bare), in order → the first that decodes
  3. the first '{' or '[' from which a complete JSON value decodes → that value

The returned bytes are exactly the decoded segment (re-sliceable as json.RawMessage). A reply with no decodable JSON returns an error.

func FlowDir

func FlowDir(flowID string) string

FlowDir returns the journal home for a flow id: <loom home>/flows/<id>.

func GroupEnrollCode

func GroupEnrollCode(code string) string

GroupEnrollCode renders an 8-char code as "K7Q2-M9XA" for display (exported for the CLI).

func GroupSAS

func GroupSAS(sas string) string

GroupSAS renders the six-digit SAS as "123 456" for display (exported for the CLI).

func Home

func Home() string

Home returns the resolved loom home directory — the LOOM_HOME override, else ~/.loom, else ".loom" when there is no HOME. It is the SAME resolution identity/pins/tokens use (see loomDir), so per-run lifecycle records live beside the rest of loom's state.

func NewEnrollCode

func NewEnrollCode() (string, error)

NewEnrollCode mints a fresh, single-use enrollment code (8 base32 chars, 40 bits). The server displays it; the enrolling client submits it once. Grouping for display is the CLI's job (GroupEnrollCode).

func PeerNameFromState

func PeerNameFromState(state tls.ConnectionState, pins *PinStore) (name string, ok bool)

PeerNameFromState reports which pinned peer completed a handshake, read from the TLS connection state after Handshake succeeds. The server uses it to know who it is talking to (VerifyPeerCertificate proved the peer is SOME pinned peer; this names it). ok is false if the state carries no peer cert or its fingerprint is unpinned (which the verifier would already have rejected — this is a belt-and-suspenders read).

func ReadFlowJournal

func ReadFlowJournal(flowDir string) (map[string]FlowRecord, error)

ReadFlowJournal reads <flowDir>/journal.jsonl and returns the LATEST record per step — a later green supersedes an earlier failure. A missing journal is an empty map (a flow that never ran).

func RunStatus

func RunStatus(m RunManifest, sentinel *DoneSentinel, now time.Time) string

RunStatus derives the operable status of a run from its manifest + optional done sentinel. The ordering matters: a written sentinel or finished_at is terminal; only an OPEN run (neither) is judged by heartbeat freshness — a stale heartbeat with no finish is the machine-readable "the wrapper died" verdict.

func RunsDir

func RunsDir() string

RunsDir returns the per-run lifecycle-record root: <home>/runs. Each `loom run` writes its manifest.json (crash-legible lifecycle), output.log (streamed worker output), and a done sentinel under a <run-id> subdirectory here.

func SPKIFingerprint

func SPKIFingerprint(cert *x509.Certificate) string

SPKIFingerprint is the sha256 of a certificate's SubjectPublicKeyInfo, lowercase hex (64 chars, no separators). This is the pinned identity — NOT a fingerprint of the whole cert, because a node may legitimately re-issue its envelope (new serial, new validity) around the SAME keypair and stay the same peer. Two certs with the same public key share a fingerprint; a different keypair is a different node. Hex was chosen over base32 to match the hex idiom already used for tokens/handles and because it round-trips through a config file unambiguously.

func SaveFlowCopy

func SaveFlowCopy(flowDir string, f *FlowFile) error

SaveFlowCopy writes the RESOLVED flow (absolute dirs/prompt files) into the flow dir — the deterministic source a resume reads.

func SchemaPromptSuffix

func SchemaPromptSuffix(s *Schema) string

SchemaPromptSuffix is appended to an outgoing prompt when --output-schema is set, so the worker KNOWS the contract it must answer in — validating an answer against a schema the model never saw would burn the one retry on guessing. The authored schema rides verbatim.

func Serve

func Serve(addr, tokenFile string, backends map[string]Backend, idleTTL time.Duration) error

Serve binds addr and serves loom over websockets. tokenFile is REQUIRED (an empty or token-less file is refused — a daemon that runs agents with tool access must be gated). backends is injected so a caller passes Backends() and a test passes a stub. idleTTL downgrades a resident idle longer than it to cold-resumable (0 = never).

func ServeBoth

func ServeBoth(plainAddr, tlsAddr, tokenFile string, backends map[string]Backend, idleTTL time.Duration, id *Identity, pins *PinStore) error

ServeBoth runs loom on a plain listener AND a pinned-mTLS listener simultaneously, against one shared resident set. tokenFile is REQUIRED — it gates both listeners (the plain one has no cert wall). id + pins back the mTLS listener's server identity and the enrollment pin store (peers enrolled via `loom pair` or `loom enroll`). idleTTL reaps idle residents exactly as plain Serve does — the reaper runs ONCE for the shared server. The call blocks until either listener errors.

func ServeTLS

func ServeTLS(addr, tokenFile string, backends map[string]Backend, idleTTL time.Duration, id *Identity, pins *PinStore) error

ServeTLS is Serve over a pinned-mTLS listener — the whim-P2P wall. The TCP listener is wrapped in tls.NewListener with a config that presents this node's identity cert and admits a peer iff its cert SPKI fingerprint is pinned (see TLSServerConfig); the existing hand-rolled websocket then rides that encrypted, mutually-authenticated conn unchanged (wsUpgrade hijacks the *tls.Conn like any net.Conn). The token file is OPTIONAL here: the pin is already the wall, so a token — if supplied — is defense-in-depth / bootstrap, not the gate (see server.requireToken). Because every admitted peer proved a pinned identity, binding a wildcard/public address is safe in a way plain Serve is not, so this path does not warn about the bind address.

func SetChildSpawnHook

func SetChildSpawnHook(fn func(pid int))

SetChildSpawnHook installs (fn) or clears (nil) the spawn callback. Safe to call concurrently; the CLI sets it before a turn and clears it after.

func SetOpenAIClaudeHome

func SetOpenAIClaudeHome(home string)

SetOpenAIClaudeHome sets the default ~/.claude the OpenAI shim mounts.

func SetOpenAIHomeRoot

func SetOpenAIHomeRoot(root string)

SetOpenAIHomeRoot sets the directory that holds role-specific claude-homes (<root>/<role>-claude-home), selected by a "<model>#<role>" model name.

func SetOpenAIMCPConfig

func SetOpenAIMCPConfig(path string)

SetOpenAIMCPConfig sets the --mcp-config JSON path handed to shim sessions.

func SetOpenAITimeout

func SetOpenAITimeout(d time.Duration)

SetOpenAITimeout sets the per-completion wall clock (`--openai-timeout`).

func SetOpenAIWarm

func SetOpenAIWarm(on bool)

SetOpenAIWarm enables/disables warm-resident sticky seats.

func StartChild

func StartChild(cmd *exec.Cmd) error

StartChild starts cmd with lifecycle supervision so a dying loom wrapper does not leave the agent child orphaned:

  • Windows: the child is assigned to a process-wide Job Object created with JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE. When the wrapper exits by ANY means — clean, panic, os.Exit, or a hard TerminateProcess that skips every deferred cleanup — the OS closes the last handle to the job and reaps every process in it. This is the fully-robust path (see reap_windows.go).
  • Linux: the child gets PR_SET_PDEATHSIG=SIGKILL (best-effort; see reap_linux.go).
  • macOS/BSD: no OS reaping is wired (no job objects, no pdeathsig) — documented, not pretended-solved; the run manifest's stale heartbeat still makes the death visible (see reap_other.go).

It also fires the child-spawn hook so the run manifest can record the child PID.

This replaces a bare cmd.Start() at every persistent-agent spawn site (claude, codex, copilot, opencode). It changes semantics DELIBERATELY: a dead wrapper is now a dead run, cleanly — not a zombie child idling indefinitely.

func TLSClientConfig

func TLSClientConfig(id *Identity, pins *PinStore, expectPeer string) (*tls.Config, error)

TLSClientConfig builds the client side of pinned mTLS: present our identity cert, skip the default CA/hostname verification (InsecureSkipVerify — there is no CA), and accept the server iff its cert SPKI fingerprint matches the ONE peer we expect by name. expectPeer must be a pinned name; the client dialed a specific box and knows who should answer, so it pins to exactly that fingerprint (a stricter check than the server's any-pinned-peer). An unknown expectPeer fails closed.

func TLSServerConfig

func TLSServerConfig(id *Identity, pins *PinStore) (*tls.Config, error)

TLSServerConfig builds the server side of pinned mTLS: present our identity cert, REQUIRE a client cert (RequireAnyClientCert), and accept the handshake iff the client cert's SPKI fingerprint is pinned — trusting ANY pinned peer (a server does not know in advance which of its paired peers is dialing). It fails CLOSED: an empty pin store trusts no one, so every handshake is rejected until a `loom pair` adds a peer.

Which peer connected is recoverable post-handshake from the TLS state — see PeerNameFromState — because VerifyPeerCertificate can only return an error, not a name.

func WorkerReportedComplete

func WorkerReportedComplete(text string) bool

WorkerReportedComplete reports whether a worker reply opens with the BUILD COMPLETE sentinel (leading whitespace tolerated). Advisory only — see DuoBuildComplete.

Types

type AgyBackend

type AgyBackend struct {
	Bin      string // agy executable
	BrainDir string // ~/.gemini/antigravity-cli/brain
}

AgyBackend drives Google Antigravity's `agy` (Gemini). Unlike Claude Code, agy has NO stream-json mode — each turn is a one-shot `agy -p` with two known bugs:

  1. it HANGS waiting on stdin EOF in a non-TTY → we feed an empty stdin.
  2. it DROPS the response from stdout (exit 0, empty pipe) → we recover it from the on-disk transcript (newest …/transcript.jsonl, last PLANNER_RESPONSE).

Multi-turn is via `--conversation <id>` resume (a fresh process per turn). This backend is EXPERIMENTAL: the recovery is timing-sensitive (newest-transcript heuristic) and it spends the Google subscription, so the smoke test does not exercise it. The point of including it is to prove loom abstracts heterogeneous CLIs — claude (persistent stream-json) and agy (one-shot + transcript) behind one Session interface.

func DefaultAgyBackend

func DefaultAgyBackend() AgyBackend

DefaultAgyBackend resolves the usual Windows install path + brain dir.

func (AgyBackend) Name

func (b AgyBackend) Name() string

func (AgyBackend) Open

func (b AgyBackend) Open(ctx context.Context, opts SessionOpts) (Session, error)

type Backend

type Backend interface {
	Name() string
	Open(ctx context.Context, opts SessionOpts) (Session, error)
}

Backend is a driveable agent CLI.

type Budget

type Budget struct {
	// contains filtered or unexported fields
}

Budget is a spend ceiling across the turns of a loop (chat, duo, flow — and the one schema retry of a run). ONE numeric limit, interpreted per turn by what that turn honestly reports:

  • a turn with real USD cost counts its DOLLARS against the limit
  • a turn with tokens only counts its TOTAL TOKENS against the limit
  • a turn reporting neither counts nothing (agy) — the budget cannot see it

The ceiling trips when EITHER accumulated dollars OR accumulated tokens cross the limit. A mixed loop (claude worker + codex critic) therefore holds both meters against the same number — document the units in the flag you expose ("USD for cost-reporting backends, tokens otherwise"). Exceeded() flips AFTER the turn that crossed the line (loom refuses FURTHER turns; it does not abort a turn mid-flight). Nil-safe: a nil *Budget never refuses.

func NewBudget

func NewBudget(limit float64) *Budget

NewBudget makes a budget with the given ceiling; limit <= 0 returns nil (no budget), so callers can pass the flag value straight through.

func (*Budget) Allow

func (b *Budget) Allow() bool

Allow reports whether ANOTHER turn may start. Nil budget always allows.

func (*Budget) Exceeded

func (b *Budget) Exceeded() bool

Exceeded reports whether either meter has crossed the ceiling.

func (*Budget) Note

func (b *Budget) Note(r Reply)

Note accumulates one finished turn. Falls back to the legacy Reply.CostUSD when the backend filled cost but not the Usage struct (older serve peers).

func (*Budget) String

func (b *Budget) String() string

String renders the meters for the refusal message ("spent $0.41 + 12894 tokens of --budget 5000").

type ClaudeBackend

type ClaudeBackend struct {
	Bin string // default "claude"
}

ClaudeBackend drives Claude Code (`claude`) as a PERSISTENT stream-json session: one process, many turns fed over stdin, holding context across turns.

Verified 2026-06-29 (Claude Code v2.1.196): turn 1 "remember 42" → turn 2 recalled "42", same session_id, one process. Each turn emits its own `result` event; cost is cumulative so we report the per-turn delta. The cold session pays ~27K cache-creation tokens once; subsequent turns cache-READ the prior context — that amortization is the whole point of keeping the session alive.

func (ClaudeBackend) Name

func (b ClaudeBackend) Name() string

func (ClaudeBackend) Open

func (b ClaudeBackend) Open(ctx context.Context, opts SessionOpts) (Session, error)

type CodexBackend

type CodexBackend struct {
	Bin string // default "codex"
}

CodexBackend drives OpenAI Codex CLI (`codex`) as a PER-TURN exec session: each Send spawns `codex exec --json` (turn 1) or `codex exec resume <id> --json` (later turns), with the prompt fed over stdin. Codex persists every session to disk as it runs, so multi-turn context, resume-after-restart, and steer-after- interrupt all ride its own session store — the same durable-session shape as claude --resume, via a one-shot process per turn instead of a persistent one.

Verified 2026-07-02 (codex-cli 0.141.0): turn 1 "reply PONG" → `exec resume <thread_id>` turn 2 recalled PONG and ran a shell command; JSONL events (thread.started / item.started / item.completed / turn.completed) parsed as implemented here. Two CLI quirks the flags below encode: `exec resume` accepts a NARROWER flag set than `exec` (no -s/-C — sandbox rides `-c sandbox_mode=`), and codex reads stdin when piped, so the prompt is passed as the `-` sentinel.

The trust ladder maps to codex's NATIVE kernel-level sandbox (its own wall — no docker image needed):

SkipPermissions → --dangerously-bypass-approvals-and-sandbox  (no wall; for externally-sandboxed environments)
Consult         → sandbox read-only + the consult directive   (instruction AND enforcement)
Isolate         → sandbox workspace-write                     (edits walled to the workdir)
(none)          → codex's own config default

MCPConfig IS honored, local AND remote: the claude-format JSON is read on THIS box (the local file is the source of truth) and translated at Open into per-invocation `-c mcp_servers.<name>.<key>=…` config overrides (see mcpbridge.go) — codex's native MCP surface, without touching the target's ~/.codex/config.toml. The remote transport quotes every argv element (see codexCmd), so the TOML values that `bash -lc` word-splitting used to mangle now arrive intact. NOTE: the override's server COMMANDS/URLS must resolve on whichever box codex runs — a remote seat needs the MCP binary/endpoint reachable THERE. Claude-specific opts with no codex analog (AllowedTools, PermissionMode, ClaudeHome, Image) remain ignored. SystemPromptFile is approximated by prepending the file's contents to the first prompt (codex has no append-system-prompt flag).

func (CodexBackend) Name

func (b CodexBackend) Name() string

func (CodexBackend) Open

func (b CodexBackend) Open(ctx context.Context, opts SessionOpts) (Session, error)

type ConnectBackend

type ConnectBackend struct {
	URL         string // ws://host:port (or wss://host:port for pinned mTLS)
	Token       string // auth token the server verifies
	Agent       string // backend to open on the server ("" → claude)
	SessionName string // stable name for a warm resident ("" → ephemeral, closed on disconnect)
	AttachOnly  bool   // reattach a live resident by name, else error — never spawn

	// Pinned mTLS (used only for a wss:// URL). Identity is this node's cert/key; Pins is
	// the trust store; Peer names the pinned server we expect to answer. A ws:// URL
	// ignores all three (plaintext on the encrypted mesh). Pair first (loom pair) to
	// populate the pin these reference.
	Identity *Identity
	Pins     *PinStore
	Peer     string
}

ConnectBackend dials a `loom serve` endpoint. Agent is the backend name the SERVER should open (claude/agy/local) — carried in the open frame so the server opens the right one; ConnectBackend.Name() itself is always "connect" (the transport).

SessionName reattaches (or first-opens) a warm resident by a stable name: a second open of the same name reuses the live warm process instead of respawning. AttachOnly makes an open reattach-or-fail (never spawn) — used by `await`, which must not create a session just to poll one.

func (ConnectBackend) Kill

func (b ConnectBackend) Kill(ctx context.Context, target string) (kind, note string, found bool, err error)

Kill stops a server session by name OR handle, covering both ws residents and warm sticky seats. It returns the kind killed ("resident"|"warm-seat") and the server's note describing the semantics applied. found=false means nothing on the server matched the target (not an error — the caller may own it locally). Session-less, like Sessions/Overview.

func (ConnectBackend) Name

func (b ConnectBackend) Name() string

func (ConnectBackend) Open

func (b ConnectBackend) Open(ctx context.Context, opts SessionOpts) (Session, error)

Open dials, authenticates (hello), and opens a remote session (open). With a SessionName it reattaches a live warm resident if one exists (no respawn, no cold-read); else it first-opens one. It returns a connectSession bound to the server-side handle; the socket stays open for the life of the session (every turn is a frame exchange on it).

func (ConnectBackend) Overview

func (b ConnectBackend) Overview(ctx context.Context) ([]SessionOverview, error)

Overview lists every live session the server holds — ws residents (with a recent-reply tail) AND the OpenAI shim's warm sticky seats — for a supervising surface. Session-less (only the hello handshake), like Sessions.

func (ConnectBackend) Sessions

func (b ConnectBackend) Sessions(ctx context.Context) ([]SessionInfo, error)

Sessions lists the residents held by the server (session-less: it only needs the hello handshake). Feeds the `loom sessions` command + reattach UX.

type CopilotBackend

type CopilotBackend struct {
	Bin string // default "copilot"
}

CopilotBackend drives GitHub Copilot CLI (`copilot`) as a PER-TURN exec session: each Send spawns `copilot -p <prompt> --output-format json` (turn 1) or adds `--resume <sessionId>` (later turns). Copilot persists sessions itself; the id is STABLE across turns (verified live: a resumed turn recalled the prior turn's content and the final result event returned the same id).

Schema verified live (copilot 1.0.69, 2026-07-08) — JSONL on stdout:

{"type":"assistant.message","data":{"content":"PONG","model":"…",…}}          (LAST one = the turn's answer)
{"type":"assistant.reasoning","data":{…}} / assistant.*_delta                  (deltas are ephemeral noise here)
{"type":"tool.execution_start","data":{"toolName":"powershell","arguments":{"command":"…"},…}}
{"type":"tool.execution_complete","data":{"toolCallId":"…","success":true,"result":{"content":"…"}}}
{"type":"assistant.turn_end","data":{…}}
{"type":"result","sessionId":"…","exitCode":0,"usage":{"premiumRequests":1,…}}  (the FINAL line)

Trust ladder — copilot's native permission system is the wall:

SkipPermissions → --allow-all         (tools+paths+urls auto-approved; no wall)
Isolate         → --allow-all-tools   (tools auto-run, but file access stays
                                       walled to the workdir + temp — copilot's
                                       path verification is the workspace wall)
Consult         → directive only      (no allow flags: in -p mode unapproved
                                       tools fail closed, so a consult stays advice;
                                       verified: text-only turns need no allow flag)

Real mappings claude-only backends lack: MCPConfig → --additional-mcp-config @<path> (the substrate hinge), AllowedTools → repeated --allow-tool=<t>. Always passed: --log-level none --no-color --no-auto-update (a worker turn must never self-update mid-dispatch). PermissionMode/ClaudeHome/Image are ignored. The prompt rides argv (Windows ~32K command-line cap applies).

NOTE on installs: VS Code's copilot-chat extension ships its own copilot on PATH (globalStorage/…/copilotCli) which can SHADOW an npm-global install and lag it (1.0.34 vs 1.0.69 when this was written). Pin with LOOM_COPILOT_BIN when the versions matter.

func (CopilotBackend) Name

func (b CopilotBackend) Name() string

func (CopilotBackend) Open

func (b CopilotBackend) Open(ctx context.Context, opts SessionOpts) (Session, error)

type DetachSession

type DetachSession interface {
	// SendDetached starts a turn and returns its server-assigned turn-id at once.
	SendDetached(ctx context.Context, text string) (turnID int64, err error)
	// Await fetches a turn's reply, blocking up to timeout. It reports running=true if
	// the turn is still in flight when timeout elapses (poll again). lastReply fetches
	// the most recent turn without a turn-id (recovery for a dropped non-detached turn).
	Await(ctx context.Context, turnID int64, lastReply bool, timeout time.Duration) (reply Reply, running bool, err error)
}

DetachSession is an optional capability of a session reached over `loom serve`: fire a long turn detached (return immediately with a turn-id) and fetch its buffered reply later with Await — so a minutes-long verify need not pin a synchronous client, and a dropped socket loses no verdict. Callers type-assert for it, like Interruptible.

type DoneSentinel

type DoneSentinel struct {
	RunID      string    `json:"run_id"`
	Status     string    `json:"status"` // "ok" | "failed"
	FinishedAt time.Time `json:"finished_at"`
	ExitError  string    `json:"exit_error,omitempty"`
}

DoneSentinel is the tiny JSON written to the `done` file on a graceful exit.

func ReadSentinel

func ReadSentinel(dir string) (*DoneSentinel, error)

ReadSentinel reads <dir>/done. A missing sentinel returns (nil, error) — the caller treats a read error as "no sentinel yet" (the run is still open, or died un-gracefully).

type DuoConfig

type DuoConfig struct {
	Worker     Backend
	Critic     Backend // nil → defaults to Worker
	WorkerOpts SessionOpts
	CriticOpts SessionOpts // Consult forced true by Duo; Model defaults to WorkerOpts.Model
	Task       string
	Rounds     int // <= 0 → DuoDefaultRounds
	Observer   DuoObserver
	// Budget, when non-nil, is the loop's spend ceiling (see usage.go): both
	// seats' turns accumulate into it, and once exceeded the loop REFUSES to
	// start another round — status "budget_exceeded", last report kept. A round
	// in flight is never aborted mid-turn.
	Budget *Budget
}

DuoConfig is one duo run. Worker and Critic may be the SAME backend — the critic defaults to the worker's seat when Critic is nil and to the worker's model when CriticOpts.Model is empty. WorkerOpts carries the caller's trust flags exactly as `run` does; CriticOpts should share the worker's Workdir (so the critic inspects the same tree) — Duo forces CriticOpts.Consult regardless, so the critic can never be opened as a writing seat.

type DuoObserver

type DuoObserver struct {
	Round       func(round int)
	WorkerReply func(round int, text string)
	Verdict     func(round int, verdict, feedback string)
	Warn        func(msg string)
	WorkerEvent func(ev Event)
	CriticEvent func(ev Event)
}

DuoObserver receives progress callbacks during a duo run so a caller (the CLI) can narrate round banners + verdicts and stream each seat's tool events, without Duo itself knowing about stderr or flags. Every field is optional (nil-safe). Round fires BEFORE the worker turn so streamed events land under the right banner; WorkerEvent / CriticEvent carry each seat's tool calls when the caller wants `-events`.

type DuoResult

type DuoResult struct {
	WorkerSession string     `json:"worker_session"`
	CriticSession string     `json:"critic_session"`
	Rounds        []DuoRound `json:"rounds"`
	Status        string     `json:"status"`
	Text          string     `json:"text"`
	CostUSD       float64    `json:"cost_usd"`
}

DuoResult is the outcome of a duo run. Text is the worker's final build-point report (the report the critic judged DONE, or the last one before the rounds cap). CostUSD sums both seats across all rounds where the backends report cost (0 otherwise). The two session ids surface so either seat can be resumed / inspected afterward with `loom run --resume`.

func Duo

func Duo(ctx context.Context, cfg DuoConfig) (res DuoResult, err error)

Duo runs the worker↔critic build loop. It opens two sessions on the SAME working directory: the worker with the caller's trust flags, and the critic ALWAYS read-only (Consult forced true — the critic answers, it never acts). Each round is worker turn → critic turn → route: REVISE sends the critic's feedback back into the worker; CONTINUE tells the worker to proceed; DONE ends the loop. The critic session is RESUMED each round (same Session object), so it accumulates the whole trajectory across rounds — that memory is the point. An unparseable verdict fails OPEN — treated as CONTINUE with a warning — so a critic that garbles its verdict never wedges the loop; only a real DONE or the rounds cap ends it.

type DuoRound

type DuoRound struct {
	Verdict         string `json:"verdict"`
	FeedbackSummary string `json:"feedback_summary"`
}

DuoRound is the critic's judgment at one build point — what the JSON output carries per round. FeedbackSummary is a one-line clip of the critic's feedback (the FULL feedback is what gets routed into the worker; the summary is for the human / the log).

type EnrollResult

type EnrollResult struct {
	ServerFingerprint string
	ServerName        string
}

EnrollResult is what a client-side enrollment yields: the server's fingerprint and the local name it was pinned under (the --peer name the client then dials wss with).

func EnrollConnect

func EnrollConnect(addr, code, serverName, selfLabel string, id *Identity, pins *PinStore) (*EnrollResult, error)

EnrollConnect runs the client side: POST this node's cert + code-proof to the server's enrollment listener at addr (host:port, plain http), verify the server's returned cert against the code, and pin it under serverName. selfLabel is an optional name suggestion the server MAY use for its own pin of this client. On success the client can immediately drive the server over mTLS: `loom run --connect wss://<addr> --peer <serverName>`.

type EnrollServer

type EnrollServer struct {
	Identity *Identity
	Pins     *PinStore
	Code     string // the minted code (any casing/grouping; normalized internally)
	PinName  string // operator-chosen name for the enrolling client ("" → fall back to the client's Label)
}

EnrollServer holds one enrollment window: this box's identity + pin store, the minted code, and the name to pin the enrolling client under. It serves exactly ONE successful enrollment (the code is single-use), mirroring `loom pair`'s one-ceremony deliberateness.

func (*EnrollServer) ListenAndEnroll

func (e *EnrollServer) ListenAndEnroll(addr string, timeout time.Duration) (pinnedName, fingerprint string, err error)

ListenAndEnroll binds addr (PLAIN http — the client does not yet trust this box's cert, so the code+MAC is the wall, not TLS), accepts one VALID enrollment, pins the client's SPKI under the resolved name, and returns that name + the client's fingerprint. A bad MAC is refused (401) and does NOT consume the window — only a valid enrollment ends it. timeout 0 waits indefinitely; otherwise the wait is bounded and returns an error on expiry. The caller (the CLI) is responsible for displaying the code before calling this.

type Event

type Event struct {
	Kind    EventKind `json:"kind"`
	Backend string    `json:"backend"`
	Text    string    `json:"text,omitempty"` // message / thinking / result text
	Tool    string    `json:"tool,omitempty"` // tool name (tool_call / tool_result)
}

Event is a single streamed event during a turn. An onEvent callback (passed to SendStream) receives these as they arrive; the final Reply is still returned. This is what turns loom from a black box (final text only) into an observable harness — a code-review agent's *work* (which files it read, what it found) becomes visible, not hidden.

type EventKind

type EventKind string

EventKind is the type of a streamed event from a backend during a turn.

const (
	EvAssistant  EventKind = "assistant"   // assistant message text
	EvThinking   EventKind = "thinking"    // reasoning / chain-of-thought
	EvToolCall   EventKind = "tool_call"   // the agent invoked a tool
	EvToolResult EventKind = "tool_result" // a tool returned output
	EvResult     EventKind = "result"      // the final result of the turn
)

type FlowConfig

type FlowConfig struct {
	File     *FlowFile
	Backends map[string]Backend
	FlowDir  string // $LOOM_HOME/flows/<flow-id> — the journal home (created if missing)
	// BaseOpts carries the FLOW-WIDE trust/plumbing flags (Isolate,
	// SkipPermissions, MCPConfig, SkillsDir) applied to every step's session;
	// per-step Workdir/Model override it.
	BaseOpts    SessionOpts
	Budget      *Budget // nil = no ceiling
	Concurrency int     // overrides File.Concurrency when > 0
	Resume      map[string]FlowRecord
	Observer    FlowObserver
}

FlowConfig is one flow execution.

type FlowFile

type FlowFile struct {
	Flow        string     `json:"flow"`
	Concurrency int        `json:"concurrency,omitempty"`
	Steps       []FlowStep `json:"steps"`
}

FlowFile is a parsed, validated flow.

func ParseFlowFile

func ParseFlowFile(path string) (*FlowFile, error)

ParseFlowFile reads + validates a flow file and resolves relative step dirs / prompt files against the flow file's own directory (flows are portable; the file is the anchor). Validation is a hard gate: nothing runs on a flow with duplicate ids, unknown needs, cycles, a prompt/prompt_file conflict, or an unreadable prompt file — half a flow burned on a typo is the failure mode this prevents.

type FlowObserver

type FlowObserver struct {
	StepStart func(id string)
	StepDone  func(rec FlowRecord)
	Warn      func(msg string)
}

FlowObserver narrates a flow run (all fields optional / nil-safe).

type FlowRecord

type FlowRecord struct {
	Step       string    `json:"step"`
	Status     string    `json:"status"`
	StartedAt  time.Time `json:"started_at"`
	FinishedAt time.Time `json:"finished_at"`
	Reply      *Reply    `json:"reply,omitempty"`
	OracleRC   *int      `json:"oracle_rc,omitempty"`
	OracleTail string    `json:"oracle_tail,omitempty"`
	Cached     bool      `json:"cached,omitempty"` // resume served this green from the journal
}

FlowRecord is one journal line — a step attempt's full account.

type FlowResult

type FlowResult struct {
	Flow     string                `json:"flow"`
	Records  map[string]FlowRecord `json:"records"`
	AllGreen bool                  `json:"all_green"`
}

FlowResult is the outcome: the final record per step, and whether every step went green (the exit-code contract).

func RunFlow

func RunFlow(ctx context.Context, cfg FlowConfig) (FlowResult, error)

RunFlow executes the DAG: a bounded pool dispatches any step whose needs are all green; a failed step marks its transitive dependents dependency_failed (recorded, never silent) while independent branches keep running; the budget gates DISPATCH (in-flight steps complete). Resume entries whose status is green are served from the journal as cached results. Every outcome is appended to the journal AS IT HAPPENS — a crash mid-flow loses only the in-flight steps, which is exactly what resume re-runs.

type FlowStep

type FlowStep struct {
	ID         string   `json:"id"`
	Agent      string   `json:"agent,omitempty"` // default "claude"
	Model      string   `json:"model,omitempty"`
	Dir        string   `json:"dir,omitempty"`
	Prompt     string   `json:"prompt,omitempty"`
	PromptFile string   `json:"prompt_file,omitempty"`
	Needs      []string `json:"needs,omitempty"`
	Oracle     string   `json:"oracle,omitempty"` // "" = the turn succeeding is green (weaker)
}

FlowStep is one step of a flow file. Dir and PromptFile are resolved to ABSOLUTE paths at parse (against the flow file's directory), so the saved copy under LOOM_HOME is self-contained and a resume needs no original.

type Identity

type Identity struct {

	// Cert is the parsed self-signed certificate. Its SPKI fingerprint (Fingerprint)
	// is the node's stable identity — the string a peer pins and every later mTLS
	// handshake is checked against.
	Cert *x509.Certificate
	// contains filtered or unexported fields
}

Identity is this node's persistent keypair + self-signed cert. The private key never leaves the box; the cert (its public envelope) is what a peer pins.

func LoadOrCreateIdentity

func LoadOrCreateIdentity(dir string) (*Identity, error)

LoadOrCreateIdentity loads the node identity from dir, generating a fresh ECDSA P-256 keypair + self-signed cert on first use (dir "" resolves to ~/.loom/identity). key.pem is written 0600 (the private key); cert.pem is the public envelope. The operation is lazy and idempotent: a second call reuses the stored key, so a node's pinned identity is stable across restarts.

func (*Identity) CertDER

func (id *Identity) CertDER() []byte

CertDER is the node's certificate in DER form — what the pairing ceremony sends to the peer so it can compute (and, on confirmation, pin) this node's fingerprint.

func (*Identity) Fingerprint

func (id *Identity) Fingerprint() string

Fingerprint is this node's SPKI fingerprint — its stable pinned identity.

func (*Identity) TLSCertificate

func (id *Identity) TLSCertificate() tls.Certificate

TLSCertificate packages the key + cert for a tls.Config's Certificates slice. Leaf is set so the TLS stack need not re-parse it per handshake.

type Interruptible

type Interruptible interface {
	Interrupt() error
}

Interruptible is an optional capability: a session whose IN-FLIGHT turn can be stopped while the agent is working (claude's stream-json control_request interrupt). The session stays alive — to steer, call Send with a new instruction after Interrupt returns; the context is intact. Callers type-assert for it: `if it, ok := sess.(Interruptible); ok { it.Interrupt() }`.

type LocalBackend

type LocalBackend struct {
	BaseURL string // default http://localhost:8090/v1 (override via LOOM_LOCAL_URL)
	Model   string // default model when SessionOpts.Model is empty (else asks /v1/models)
	APIKey  string // optional; local routers usually ignore it
}

LocalBackend drives a local OpenAI-compatible endpoint (llama-chip's :8090 router, LM Studio, vLLM, …). It's the SIMPLEST backend: no process to spawn, no stdio protocol, no transcript-scrape — just a stateless POST /v1/chat/completions. loom keeps the message history per session for multi-turn (the OpenAI API is stateless, so you resend the history). This makes `panel` a real cloud+local council — e.g. a fast local doer + claude critic.

func DefaultLocalBackend

func DefaultLocalBackend() LocalBackend

func (LocalBackend) Name

func (b LocalBackend) Name() string

func (LocalBackend) Open

func (b LocalBackend) Open(ctx context.Context, opts SessionOpts) (Session, error)

type OpencodeBackend

type OpencodeBackend struct {
	Bin string // default "opencode"
}

OpencodeBackend drives the opencode CLI (`opencode run`) as a PER-TURN exec session: each Send spawns `opencode run --format json` (turn 1) or adds `-s <sessionID>` (later turns). opencode persists sessions to disk itself, so multi-turn context and resume-after-restart ride its own session store — the same durable-session shape as codex, prompt passed as the positional message.

Schema verified live (opencode-ai 1.17.15, 2026-07-08) — every event carries the sessionID at the top level:

{"type":"step_start","sessionID":"ses_…","part":{"type":"step-start",…}}
{"type":"text","sessionID":"…","part":{"type":"text","text":"PONG",…}}
{"type":"tool_use","part":{"type":"tool","tool":"bash","state":{"status":"completed","input":{"command":"…"},"output":"…","title":"…"}}}
{"type":"step_finish","part":{"type":"step-finish","tokens":{…},"cost":0.00103208}}

step_finish carries real USD cost (summed into Reply.CostUSD — opencode is the only backend that reports it) and Turns counts model steps, not user turns. Resume verified live: `run -s <id>` recalled a prior turn's content; the id is stable across turns.

Trust mapping: SkipPermissions → `--auto` (auto-approve everything — dangerous, for externally-sandboxed environments); default = opencode's own permission config, where unapproved tools fail closed in headless run mode; Consult rides the directive. opencode has NO native filesystem sandbox, so Isolate is not enforceable here — wall it externally (docker/remote) when it matters.

MCPConfig IS honored, local AND remote: the claude-format JSON is translated at Open into an opencode config document (see mcpbridge.go; read path verified live, opencode-ai 1.17.15). Locally it is written to a temp file handed to every spawned turn via the OPENCODE_CONFIG env var; for a --remote session the translated document is planted on the TARGET each turn (base64 into /tmp with an EXIT-trap, see remotemcp.go) and OPENCODE_CONFIG exported in the same script — the local file stays the source of truth, though the servers it names must resolve on the remote box. NOTE: in headless run mode opencode's permission config still gates tool calls (unapproved tools fail closed), so an MCP-wired seat may additionally need permissions granted in the user's opencode config, or SkipPermissions (--auto). Other claude-specific opts (AllowedTools, PermissionMode, ClaudeHome, Image) remain ignored. The prompt travels as one argv element: fine for worker dispatches, but Windows caps a command line at ~32K chars — don't feed whole flattened transcripts through this backend.

func (OpencodeBackend) Name

func (b OpencodeBackend) Name() string

func (OpencodeBackend) Open

func (b OpencodeBackend) Open(ctx context.Context, opts SessionOpts) (Session, error)

type PairOutcome

type PairOutcome struct {
	SAS             string // six digits — display grouped (loom.GroupSAS)
	PeerFingerprint string // pin this on confirmation
}

PairOutcome is what a completed ceremony hands the CLI: the PIN to show the human, and the peer fingerprint to pin IF they confirm. The CLI (not this package) does the human confirmation and the pinning — the ceremony computes trust; the human grants it.

func PairConnect

func PairConnect(addr string, id *Identity) (*PairOutcome, error)

PairConnect dials a peer's `loom pair --listen` and runs the SAS ceremony as the initiator (dialer). It returns the derived PIN + the peer's fingerprint; nothing is pinned — the caller shows the PIN, confirms it matches the other screen, and only then pins the fingerprint under a chosen name.

func PairListen

func PairListen(addr string, id *Identity) (*PairOutcome, error)

PairListen binds addr, accepts ONE pairing connection, and runs the ceremony as the responder, then returns. `loom pair` is a one-shot ceremony (two humans, one moment), not a daemon — one pairing per invocation keeps the trust decision deliberate.

type Pin

type Pin struct {
	Name        string
	Fingerprint string
	AddedAt     time.Time
}

Pin is one trusted peer: a human-chosen name, its pinned SPKI fingerprint, and when it was added (bookkeeping only — pins do not expire).

type PinStore

type PinStore struct {
	// contains filtered or unexported fields
}

PinStore is the in-memory set of pins backing a pin file. It is safe for concurrent reads (the TLS verify callbacks hit it on every handshake) and guarded writes.

func LoadPinStore

func LoadPinStore(path string) (*PinStore, error)

LoadPinStore reads the pin file at path (path "" resolves to ~/.loom/pins). A missing file yields an empty, writable store — the common first-run case, where the first `loom pair` creates it. Malformed lines are skipped rather than fatal, so one bad hand-edit never locks a node out of every peer.

func (*PinStore) Add

func (ps *PinStore) Add(name, fingerprint string) error

Add records (or replaces) a pin for name and persists the whole store. Re-pinning an existing name overwrites it — the path a peer takes after re-keying (or after you re-pair to rotate a leaked key).

func (*PinStore) Get

func (ps *PinStore) Get(name string) (fingerprint string, ok bool)

Get returns the pinned fingerprint for name. A client verifying a server it dialed looks up the ONE peer it expects by name.

func (*PinStore) List

func (ps *PinStore) List() []Pin

List returns all pins, sorted by name — for `loom pins` and for tests.

func (*PinStore) NameFor

func (ps *PinStore) NameFor(fingerprint string) (name string, ok bool)

NameFor reverse-looks-up the peer name pinned to fingerprint. A server accepting an inbound handshake trusts ANY pinned peer, so it matches on the fingerprint and reports which name connected. Empty fingerprints never match (a defensive guard against a cert with no key material producing a hollow pin).

func (*PinStore) Remove

func (ps *PinStore) Remove(name string) error

Remove deletes the pin for name (revoking that peer) and persists. Removing an absent name is a no-op success — revocation should be idempotent.

type RaceConfig

type RaceConfig struct {
	Contenders []RaceContender
	Opts       SessionOpts
	Prompt     string
	Oracle     string
	Dir        string
	Timeout    time.Duration
	Observer   RaceObserver
}

RaceConfig configures a one-turn race. Dir is copied per contender; no Dir means each contender starts in an empty temporary directory. Timeout covers the whole race.

type RaceContender

type RaceContender struct {
	Agent   string
	Model   string
	Backend Backend
}

RaceContender identifies one local agent/model combination in a race. Backend is deliberately injected here so Race remains testable without invoking live CLIs.

func ParseRaceContenders

func ParseRaceContenders(list string) ([]RaceContender, error)

ParseRaceContenders parses agent[:model] entries without a regexp so the accepted shape stays obvious alongside the CLI's backend lookup. Backend names themselves are checked by the caller, because this parser is also useful to callers with injected backends.

type RaceContenderResult

type RaceContenderResult struct {
	Agent     string  `json:"agent"`
	Model     string  `json:"model"`
	Status    string  `json:"status"`
	OracleRC  int     `json:"oracle_rc"`
	DurationS float64 `json:"duration_s"`
}

RaceContenderResult records one contender's final outcome. Dir is intentionally kept on the winner only: the winning tree is the deliverable, while the others are diagnostics.

type RaceObserver

type RaceObserver struct {
	Start   func(contender RaceContender, dir string)
	Finish  func(contender RaceContender, result RaceContenderResult)
	Verdict func(contender RaceContender, rc int, passed bool)
}

RaceObserver receives progress as contenders start, finish, and the oracle judges them. The CLI uses it for narration while the engine stays independent of terminal output.

type RaceResult

type RaceResult struct {
	Winner     *RaceWinner           `json:"winner"`
	Contenders []RaceContenderResult `json:"contenders"`
	Status     string                `json:"status"`
	CostUSD    float64               `json:"cost_usd"`
}

RaceResult is the final race report. Winner is nil when no contender passed.

func Race

func Race(ctx context.Context, cfg RaceConfig) (RaceResult, error)

Race starts every contender at once, then judges each completed tree. The first oracle pass cancels the shared context before waiting for cleanup: this makes the winner's tree stable while ensuring losing sessions receive cancellation and close promptly.

type RaceWinner

type RaceWinner struct {
	Agent     string  `json:"agent"`
	Model     string  `json:"model"`
	Dir       string  `json:"dir"`
	DurationS float64 `json:"duration_s"`
}

RaceWinner is the contender that first satisfied the oracle.

type Reply

type Reply struct {
	Backend   string  `json:"backend"`
	Text      string  `json:"text"`
	SessionID string  `json:"session_id,omitempty"`
	CostUSD   float64 `json:"cost_usd,omitempty"` // cost of THIS turn (delta), best-effort (kept beside Usage for wire compat)
	Turns     int     `json:"turns,omitempty"`
	Err       string  `json:"error,omitempty"`
	// Usage is the turn's normalized resource accounting — what this backend
	// honestly reports (tokens and/or USD; see usage.go's fidelity table). nil
	// when the backend reports nothing machine-readable.
	Usage *Usage `json:"usage,omitempty"`
	// Parsed is the schema-validated JSON extracted from Text when the caller
	// ran with --output-schema (see schema.go). nil otherwise.
	Parsed json.RawMessage `json:"parsed,omitempty"`
}

Reply is the result of one user turn.

func EnforceSchema

func EnforceSchema(ctx context.Context, sess Session, s *Schema, r Reply, budget *Budget, onEvent func(Event)) (Reply, error)

EnforceSchema validates a turn's reply text against the schema. On success the extracted JSON lands in Reply.Parsed. On failure it re-prompts ONCE on the same session ("your answer failed validation: …"); a still-invalid answer returns the final reply plus a non-nil error listing the violations. The retry turn's usage/cost is folded into the returned Reply so accounting never loses a turn. A nil-Exceeded budget gates the retry: past the ceiling, the invalid answer is returned immediately (with its errors) rather than spending another turn.

func Panel

func Panel(ctx context.Context, backends []Backend, opts SessionOpts, prompt string) []Reply

Panel runs one prompt across multiple backends CONCURRENTLY and collects the replies in input order — the "council" pattern (ask N agents the same thing, then compare / synthesize). Each backend gets its own fresh session.

A failed backend yields a Reply with Err set rather than aborting the panel, so one dead agent never sinks the others.

type RunManifest

type RunManifest struct {
	RunID       string     `json:"run_id"`
	StartedAt   time.Time  `json:"started_at"`
	Argv        []string   `json:"argv"`
	Cwd         string     `json:"cwd"`
	Backend     string     `json:"backend"`
	Model       string     `json:"model,omitempty"`
	WrapperPID  int        `json:"wrapper_pid"`
	ChildPIDs   []int      `json:"child_pids,omitempty"`
	HeartbeatAt time.Time  `json:"heartbeat_at"`
	FinishedAt  *time.Time `json:"finished_at,omitempty"`
	ExitError   string     `json:"exit_error,omitempty"`
	CostUSD     float64    `json:"cost_usd,omitempty"`
	Turns       int        `json:"turns,omitempty"`
	SessionID   string     `json:"session_id,omitempty"`
	// Usage is the run's normalized accounting (tokens and/or USD with a
	// cost-source marker — see usage.go). Absent while the run is open, and on
	// manifests written before this field existed; readers treat nil as
	// "unreported", never zero-cost-as-fact.
	Usage *Usage `json:"usage,omitempty"`
}

RunManifest is the lifecycle record one `loom run` writes to RunsDir()/<run-id>/manifest.json. StartedAt/HeartbeatAt/FinishedAt drive the derived status; WrapperPID is what a supervisor correlates a live process against; the usage fields (CostUSD/Turns/SessionID) are stamped in at finish and so are absent while a run is still open.

func ReadManifest

func ReadManifest(dir string) (RunManifest, error)

ReadManifest reads and decodes <dir>/manifest.json.

type Schema

type Schema struct {
	Types      []string           // empty = any type
	Properties map[string]*Schema // object member schemas
	Required   []string           // object members that must be present
	Items      *Schema            // array element schema
	Enum       []any              // decoded allowed values
	Minimum    *float64           // numbers: v >= Minimum
	Maximum    *float64           // numbers: v <= Maximum
	MinLength  *int               // strings: rune count >= MinLength
	MaxLength  *int               // strings: rune count <= MaxLength
	MinItems   *int               // arrays: len >= MinItems
	MaxItems   *int               // arrays: len <= MaxItems
	// contains filtered or unexported fields
}

Schema is one node of the parsed schema tree (the subset above).

func ParseSchema

func ParseSchema(b []byte) (*Schema, error)

ParseSchema parses one schema document (or sub-schema) from JSON bytes.

func ParseSchemaFile

func ParseSchemaFile(path string) (*Schema, error)

ParseSchemaFile reads and parses a JSON-Schema-subset file.

func (*Schema) Raw

func (s *Schema) Raw() json.RawMessage

Raw returns the schema node exactly as authored (for embedding in prompts).

func (*Schema) Validate

func (s *Schema) Validate(v any) []string

Validate checks a decoded JSON value (json.Unmarshal into any) against the schema and returns every violation as "path: problem" strings. Empty = valid.

type Session

type Session interface {
	Send(ctx context.Context, prompt string) (Reply, error)
	SendStream(ctx context.Context, prompt string, onEvent func(Event)) (Reply, error)
	SessionID() string
	Close() error
}

Session is a (possibly long-lived) conversation with one agent. Send may be called repeatedly; the session holds context across turns where the backend supports it.

Send is the simple final-text path. SendStream is the same, but invokes onEvent for each intermediate event (assistant text, thinking, tool calls/results) as it arrives — so a caller can observe the agent's work, not just its conclusion. Send is conventionally implemented as SendStream(ctx, prompt, nil).

type SessionInfo

type SessionInfo struct {
	Name           string `json:"name,omitempty"`
	Handle         string `json:"handle"`
	Backend        string `json:"backend"`
	Model          string `json:"model,omitempty"`
	Dir            string `json:"dir,omitempty"`
	AllowedTools   string `json:"allowed_tools,omitempty"`
	PermissionMode string `json:"permission_mode,omitempty"`
	KeepAlive      bool   `json:"keep_alive,omitempty"`
	IdleSeconds    int    `json:"idle_seconds"`
	LastTurnID     int64  `json:"last_turn_id"`
}

SessionInfo is one resident session as reported by `loom sessions` — enough to pick a name to reattach, to see what opts froze at open, and to feed the idle janitor.

type SessionOpts

type SessionOpts struct {
	Workdir   string // process working dir ("" = inherit)
	WorkdirRO bool   // (--isolate) mount Workdir as /work READ-ONLY — for context-only seats (shim role workdirs) whose only write channel is their MCP hinge
	Model     string // backend-specific model override ("" = default)
	Isolate   bool   // run the agent in a docker sandbox (claude backend) — walls the host
	Image     string // docker image for isolation ("" = loom-claude)
	// ExtraMounts adds docker bind mounts BEYOND the single /work + ~/.claude the
	// sandbox gives by default (claude backend + Isolate only). Each entry is a raw
	// `docker run -v` value — "host:container" or "host:container:ro" — with the host
	// path already in the target platform's form (forward-slashed for Docker Desktop
	// on Windows; $HOME-relative for a remote). This is what lets a seat ground on the
	// whole workspace at /work READ-ONLY while still having WRITABLE islands (a build
	// dir, a scratch/journal dir) at their own paths — the shape loom-mcp commissions.
	// An older serve that predates this field ignores it (unknown JSON), degrading to
	// the single-/work mount, never erroring.
	ExtraMounts []string
	Remote      string // run the agent on a remote box over ssh (e.g. "cpuchip@host"); "" = local
	Resume      string // resume a prior session by id (claude --resume); "" = fresh session

	// Configuring the claude agent — the substrate-integration surface. Paths in the
	// config-file fields are interpreted on the TARGET (local host / remote box /
	// inside the container via ClaudeHome), so put them where the agent will run.
	MCPConfig        string // claude --mcp-config: wire in MCP server(s) from JSON — the hinge back into pg-ai-stewards
	AllowedTools     string // claude --allowed-tools: scope which tools (incl. MCP) the agent may call
	PermissionMode   string // claude --permission-mode (e.g. "acceptEdits", "plan")
	SkipPermissions  bool   // claude --dangerously-skip-permissions (headless; safe INSIDE --isolate)
	SystemPromptFile string // claude --append-system-prompt-file: inject instructions
	ClaudeHome       string // (--isolate) host dir mounted as the container's writable ~/.claude: skills/instructions/settings/MCP + PERSISTED session state (this is what makes resume+isolate work)
	Consult          bool   // read-only "consult" drive: inject a directive so a QUESTION drive doesn't sprawl into edits/commits/journaling (instruction-level, not a hard sandbox — use AllowedTools for enforcement)

	// SkillsDir is a source directory of authored skills (each a <name>/SKILL.md
	// folder, or a single skill folder). At Open, loom mirrors them into BOTH
	// .claude/skills/ and .agents/skills/ of the session workdir so whichever
	// backend runs discovers them — "author once, every harness sees it" (see
	// skills.go). Local only; ignored for a remote session. "" = no skills.
	SkillsDir string
}

SessionOpts configure a session.

type SessionOverview

type SessionOverview struct {
	Kind        string `json:"kind"` // "resident" (ws) | "warm-seat" (OpenAI shim sticky)
	Name        string `json:"name,omitempty"`
	Handle      string `json:"handle"` // ws handle (resident) OR sticky key (warm seat) — the kill target
	Backend     string `json:"backend,omitempty"`
	Model       string `json:"model,omitempty"`
	State       string `json:"state"` // resident: "running"|"idle"; warm seat: "warm"
	IdleSeconds int    `json:"idle_seconds"`
	LastTurnID  int64  `json:"last_turn_id,omitempty"`
	Tail        string `json:"tail,omitempty"` // most recent reply text (residents); warm seats carry none
	Named       bool   `json:"named,omitempty"`
}

SessionOverview is one live session as reported by `overview`: a ws resident or a warm sticky seat, with enough to render a card (kind, model, state, idle) and a short tail to glance at before stopping it.

type Usage

type Usage struct {
	InputTokens      int     `json:"input_tokens,omitempty"`      // fresh (non-cache-read) input tokens
	OutputTokens     int     `json:"output_tokens,omitempty"`     // includes reasoning output where the CLI folds it in
	CacheReadTokens  int     `json:"cache_read_tokens,omitempty"` // prompt tokens served from cache
	CacheWriteTokens int     `json:"cache_write_tokens,omitempty"`
	PremiumRequests  int     `json:"premium_requests,omitempty"` // copilot's billing unit
	CostUSD          float64 `json:"cost_usd,omitempty"`         // THIS turn (delta), when CostSource=real
	CostSource       string  `json:"cost_source,omitempty"`      // real | estimated | none
}

Usage is one turn's normalized resource accounting. Zero-valued fields simply mean "this backend doesn't report that" — see the fidelity table above.

func (*Usage) TotalTokens

func (u *Usage) TotalTokens() int

TotalTokens is the budget's token denominator: everything the turn moved through the model. Cache reads count (they are real context the provider meters, just cheaper) — the point is a hard ceiling, not a bill.

Directories

Path Synopsis
cmd
loom command
Command loom drives coding-agent CLIs (Claude Code, agy/Gemini) as workers.
Command loom drives coding-agent CLIs (Claude Code, agy/Gemini) as workers.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL