The JSON API
One envelope for the snapshot, one NDJSON stream for the changes, and the six traps that have caught every consumer so far.
scruff --json # a snapshot. Same bytes as `scruff list --json`
scruff watch --json # the changes, one NDJSON object per lineThe envelope
{ "scruff": "1.3.0", "schema": 2, "warnings": [], "lanes": [ … ] }scruff is the version, schema the payload version. Check schema rather
than sniffing for fields.
Each lane:
| Field | Meaning |
|---|---|
name | the lane's name |
repo / main | the repo's remote slug, and the main checkout's path |
branch | the full branch name |
path | the checkout path on disk. Empty once parked |
parent | the checkout of the pane that spawned it: its own main checkout for an ordinary lane, another lane's checkout for one made by scruff child or opened from inside a lane's pane. "" if unrecorded |
chat | the checkout whose conversation scruff <name> opens. Equal to path when the lane holds a chat of its own, the parent's path when it doesn't, "" when scruff could not tell |
agent | the client: claude, codex, opencode, pi |
state | live, parked or stray. A closed set |
occupied / dirty | nullable. null means undetermined, not false |
occupied_by | the evidence behind occupied: true: [{pid, command, path, via}], via being lsof or leases. Absent entirely when nothing holds the lane |
landed | {verdict, via, confidence}. See the four verdicts |
post_merge_ahead | {commits, pr, diverged}: work done after the PR merged |
last_commit | the most recent commit's subject |
The six traps
null is not false
occupied and dirty are three-state. null means scruff could not
determine it: no lsof, no forge, a cache miss. Reading it as falsy is how you
tell somebody a lane is clean when scruff had no idea, and it is the single most
common way to get this payload wrong.
stateandlanded.verdictare closed sets. Additions are a minor version, removals a major one, and an unknown value must resolve to the safe direction: an unrecognisedverdictorviais not landed.landed.verdict: "fresh"is notyes. It means the branch has never carried a commit of its own. Render it as nothing yet, never as merged.warningsis the only place a degraded run explains itself. Under--jsonthe human-readable notes are suppressed, so a consumer that ignoreswarningswill silently report a partial sweep as a complete one.occupiedsays a process is standing there, not that a pane is. A dev server or an orphaned daemon holds a lane exactly as hard as a live agent. Readoccupied_bybefore telling somebody to go and close a window that may not exist.chat, notparent, says whether a lane has a pane of its own. A lane made byscruff childand a lane opened from inside another lane's pane record the sameparent, and only the second has a window and a conversation — so a picker that hides lanes onparenthides a running agent.chatis measured: equal topathwhen the lane holds its own chat, the parent's path when it doesn't.""means scruff could not tell (a client whose transcripts it cannot cheaply probe) and must be read as show it, the same safe directionnulloccupancy takes.post_merge_ahead.pris a plain int, and absent is0, nevernull.divergedis what separates "committed after the merge" (reshippable,+N) from "this tip never built on what merged" (stale or sideways,~N).
A listing is not read-only
scruff and scruff --json also sweep landed parked branches on the way past.
Harmless, and the invariants still hold, but don't poll it thinking you are only
looking.
The stream
scruff watch --jsonOne JSON object per line. A hello header first, then one sync per lane
already alive, then ready, then the changes as they happen:
{"kind":"hello","seq":0,"scruff":"1.3.0","schema":2,"capabilities":["registry"]}
{"kind":"sync","seq":1,"ts":"…","source":"registry","lane":{…}}
{"kind":"ready","seq":2}
{"kind":"created","seq":3,"ts":"…","source":"registry","lane":{…}}kind is sync, ready, created, parked, resumed, reaped, changed
or warning, and it is a closed set on the same terms as the others: treat an
unknown kind as noise, not as an error. seq is monotonic across the whole
stream, hello included, so a consumer fanning this out over its own transport
can detect a dropped line without scruff knowing anything about that transport.
lane carries the exact same shape --json uses, so one schema serves the
snapshot and the stream alike.
Landedness is not on the stream
landed and post_merge_ahead change at the forge, and nothing local fires
when a PR merges, so they are deliberately not events: a consumer that wants
them polls --json at a cadence it can afford. Everything else is a
registry mutation and arrives at once (parked has a local rescan timer behind
it, because an unlanded pane closing never touches the registry).
If that ever changes you will not have to guess: every event carries source
("registry" today, "forge" reserved) and hello carries capabilities, so
you can ask the stream what it can ever send.
What is frozen
Four things are versioned and breaking-change-gated, because downstreams pin
them within a day: the registry schema, this --json output, the hook
protocol, and the exit codes. So are the user-facing command names and
flags. Changing one is a semver major conversation, not a refactor.
Field additions are non-breaking, which means the other half of the deal is yours: ignore unknown keys.
The registry is local, and stays local
Two things follow from that, and both bite a consumer rather than scruff. The rows hold absolute paths on one machine, so a lane is not meaningful on a different box: don't ship one across. And mutation takes an exclusive lock, so two writers meet exit 5 rather than losing an update.
Pointing SCRUFF_STATE at a network share does not make scruff distributed, it
makes the lock a lie. A remote transport speaking this same protocol is a
different question, and an open one.