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
- func AddToken(path string) (string, error)
- func Backends() map[string]Backend
- func ExtractJSONValue(text string) (json.RawMessage, error)
- func FlowDir(flowID string) string
- func GroupEnrollCode(code string) string
- func GroupSAS(sas string) string
- func Home() string
- func NewEnrollCode() (string, error)
- func PeerNameFromState(state tls.ConnectionState, pins *PinStore) (name string, ok bool)
- func ReadFlowJournal(flowDir string) (map[string]FlowRecord, error)
- func RunStatus(m RunManifest, sentinel *DoneSentinel, now time.Time) string
- func RunsDir() string
- func SPKIFingerprint(cert *x509.Certificate) string
- func SaveFlowCopy(flowDir string, f *FlowFile) error
- func SchemaPromptSuffix(s *Schema) string
- func Serve(addr, tokenFile string, backends map[string]Backend, idleTTL time.Duration) error
- func ServeBoth(plainAddr, tlsAddr, tokenFile string, backends map[string]Backend, ...) error
- func ServeTLS(addr, tokenFile string, backends map[string]Backend, idleTTL time.Duration, ...) error
- func SetChildSpawnHook(fn func(pid int))
- func SetOpenAIClaudeHome(home string)
- func SetOpenAIHomeRoot(root string)
- func SetOpenAIMCPConfig(path string)
- func SetOpenAITimeout(d time.Duration)
- func SetOpenAIWarm(on bool)
- func StartChild(cmd *exec.Cmd) error
- func TLSClientConfig(id *Identity, pins *PinStore, expectPeer string) (*tls.Config, error)
- func TLSServerConfig(id *Identity, pins *PinStore) (*tls.Config, error)
- func WorkerReportedComplete(text string) bool
- type AgyBackend
- type Backend
- type Budget
- type ClaudeBackend
- type CodexBackend
- type ConnectBackend
- func (b ConnectBackend) Kill(ctx context.Context, target string) (kind, note string, found bool, err error)
- func (b ConnectBackend) Name() string
- func (b ConnectBackend) Open(ctx context.Context, opts SessionOpts) (Session, error)
- func (b ConnectBackend) Overview(ctx context.Context) ([]SessionOverview, error)
- func (b ConnectBackend) Sessions(ctx context.Context) ([]SessionInfo, error)
- type CopilotBackend
- type DetachSession
- type DoneSentinel
- type DuoConfig
- type DuoObserver
- type DuoResult
- type DuoRound
- type EnrollResult
- type EnrollServer
- type Event
- type EventKind
- type FlowConfig
- type FlowFile
- type FlowObserver
- type FlowRecord
- type FlowResult
- type FlowStep
- type Identity
- type Interruptible
- type LocalBackend
- type OpencodeBackend
- type PairOutcome
- type Pin
- type PinStore
- type RaceConfig
- type RaceContender
- type RaceContenderResult
- type RaceObserver
- type RaceResult
- type RaceWinner
- type Reply
- type RunManifest
- type Schema
- type Session
- type SessionInfo
- type SessionOpts
- type SessionOverview
- type Usage
Constants ¶
const ( FlowGreen = "green" FlowAgentFailed = "agent_failed" FlowOracleFailed = "oracle_failed" FlowDepFailed = "dependency_failed" FlowBudgetRefused = "budget_refused" )
Flow-record statuses (the journal's `status` field).
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.
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).
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.
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 ¶
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 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:
- the whole trimmed text decodes as one JSON value → that value
- each ``` fenced block (```json or bare), in order → the first that decodes
- 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 GroupEnrollCode ¶
GroupEnrollCode renders an 8-char code as "K7Q2-M9XA" 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 ¶
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 ¶
SaveFlowCopy writes the RESOLVED flow (absolute dirs/prompt files) into the flow dir — the deterministic source a resume reads.
func SchemaPromptSuffix ¶
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 ¶
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 ¶
SetOpenAITimeout sets the per-completion wall clock (`--openai-timeout`).
func SetOpenAIWarm ¶
func SetOpenAIWarm(on bool)
SetOpenAIWarm enables/disables warm-resident sticky seats.
func StartChild ¶
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 ¶
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 ¶
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 ¶
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:
- it HANGS waiting on stdin EOF in a non-TTY → we feed an empty stdin.
- 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 ¶
NewBudget makes a budget with the given ceiling; limit <= 0 returns nil (no budget), so callers can pass the flag value straight through.
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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) NameFor ¶
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).
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 ¶
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 ¶
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 ¶
ParseSchema parses one schema document (or sub-schema) from JSON bytes.
func ParseSchemaFile ¶
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).
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 ¶
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.
Source Files
¶
- admin.go
- agy.go
- backend.go
- claude.go
- codex.go
- connect.go
- copilot.go
- duo.go
- enroll.go
- events.go
- flow.go
- home.go
- identity.go
- local.go
- mcpbridge.go
- openai.go
- openai_sticky.go
- opencode.go
- pair.go
- panel.go
- pins.go
- race.go
- reap.go
- reap_linux.go
- remotemcp.go
- runmanifest.go
- schema.go
- serve.go
- serveboth.go
- skills.go
- tlsconn.go
- tokens.go
- usage.go
- ws.go