All notable changes to Katsuobushi are recorded here, newest first. The
format follows Keep a Changelog; the project is versioned with Git tags
following SemVer. While in 0.x, any release may break — consumer-facing
breaking and behavioral changes are detailed in MIGRATING.md.
Closes the ways a dispatch could report success it had not achieved. A drive no
longer returns before the agent's terminal report, an armed drive no longer
waits forever when a turn ends unreported, and sandbox status no longer shows
a stalled turn as making progress. The board stops travelling inside guest
branches, and the landing procedure gains a guard that restores it and refuses
to continue quietly on an unexpected failure. See
MIGRATING.md.
- A dispatch drive returned before the agent's terminal report. The guest
pushed
TurnCompleted{reported:true}intoout.messagesahead of theReport, so an armed host drive left its loop without ever seeing the terminal text — and therefore without journaling it toreports.ndjson. The conclusion survived only inwork-state.json, which no skill named. TheTurnCompletedpush is now deferred until after theReport. - An armed drive waited forever when the auto-nudge never fired. Three
faults compounded. The
is_work_idleguard onGraceExpireddeferred nudges until work went idle, and a turn whose carrying duration kept climbing never looked idle — sonudgeCountstayed at0against a budget of5. The host drive then looped on heartbeats with no bound. Observed in practice: a turn ended unreported after eight minutes and the drive waited two hours with no timeout and no notice. Nudges now fire unconditionally on the grace timer, and an armed drive fails with a clear message once the guest's budget is provably exhausted. sandbox statusreported a stalled turn as active work. TheLIVENESSandWORKcolumns counted elapsed time rather than activity, so a turn that had already ended read as healthy progress; onlyturn-state.json's frozenlastActivityAtwas honest.render_livenessnow reportsended unreported <duration> agowhenphaseisin-flightandended_atis set.- A guest branch carried the board and merged it back silently.
dispatchclaims the card (aBOARD.mdedit) and then seeds the guest from the working tree viagit stash create, so every branch carried a dispatch-time board snapshot that merged back at landing without a conflict — putting a card in the wrong lane inside the commit that shipped its work. The seed is now filtered: board paths are restored to theirHEADstate before the mirror push, so the guest's board never diverges from the host's. - The landing guard swallowed unexpected
git checkoutfailures. The|| truethat makes the guest-added-file case work also hid every other failure, so a landing could appear to have guarded the board when it had not. Residue left staged under the board path with a status other thanAnow aborts the landing with an error on stderr. - The landing guard's abort could close the operator's shell.
exit 1in a block pasted into an interactive terminal ends that session. Both recipes are now wrapped in a subshell, so the abort exits only the subshell.git commitandjj git importsit inside it and remain gated by the guard. sandbox statustruncated a terminal report to its first line. The detail view now renders the full text, so a multi-line verdict survives a lost terminal.
- Nudges fire unconditionally on the grace timer. Previously they waited for the work state to go idle, which meant a turn that ended unreported while a background build ran was never nudged at all. An agent can now be re-prompted while a build is still running — but only once its turn has already ended unreported, and the nudge is queued in the MCP stream and cannot stop the build.
- The dispatch seed is a single
dispatch seedcommit. It replaces theWIP on …/index on …pair thatgit stash createproduced, and its tree carries the board atHEADrather than the orchestrator's uncommitted edits. - The guest contract forbids leaving background work running. An agent must
finish or explicitly kill every job before it reports, must say which, and its
report donemust state that nothing was left running. Paired with a note that the guest/nix/storeis a thin overlay on a large shared read-only base, so whole-store verification is never appropriate there. project-orchestrationgains guidance on not leading the reviewer. Five rules, a worked example pairing a leading directive with a neutral rewrite, and a gotchas entry. The prior advice to list "what should block" is reworded to ask questions rather than assert findings, because it invited the verdict it was meant to test.- The
sandboxskill's journaling limit names both unjournaled exits. It documented only the killed-process case; a nudges-exhausted natural exit lands in the same place, and the skill now says so and names where to read the conclusion instead.
is_work_idleand theWorkStateIdle/WorkStateActiveevents. Dead after nudges stopped depending on the idle transition. The work-state coordinator,GuestMessage::WorkStateTransition, andwork-state.jsonare unaffected.
Rebuilds how work moves between the parties that write, review, and integrate
it. Nothing enters the owner's history until a peer review passes, landing
creates a commit instead of rewriting one, a resumed instance works against the
current tip, and moving a branch between instances is a command rather than a
raw git push at a storage path. Also removes progressStallSecs and moves the
sandbox fetch destination to refs/remotes/sandbox-guest/<inst>. See
MIGRATING.md.
sandbox deliver <instance> --branch <ref>. The opposite direction ofsandbox fetch: pushes any host ref into a target instance's mirror asrefs/heads/delivered/<basename>. A guest sees only its own host directory, so no instance can read another's mirror; this is the supported path between two parties. Thedelivered/prefix cannot collide with the target guest's ownsandbox/<instance>working branch, and the push is a force-push so re-delivery after new commits simply advances the ref. Inside the guest the mirror isorigin, so the branch reads asorigin/delivered/<basename>.- A resumed instance is refreshed to the current tip. On resuming a named
instance, the orchestrator advances
refs/heads/basein that instance's mirror and the guest rebases its own branch onto it. The guest rewrites only its own branch, in its own clone. checks.sandbox-verb-coverage. A flake check that fails when thesandboxwrapper's subcommand list and the RustSandboxCommandvariants diverge, in either direction, naming the offending verb and both files. Two verbs had already drifted out of the wrapper when it was added.sandbox deliverandsandbox pruneare reachable from thesandboxcommand. Both existed inkatsuctlbut were missing from the wrapper's subcommand list, sosandbox pruneansweredUnknown subcommand.
- Work enters the owner's history at
ready, not atdone. The report bridge no longer lands ondone: it fetches, verifies real commits landed, and moves the card toneeds-review. Landing is the step that moves a card toready. Three consequences — the owner's history holds only reviewed work, a bounce costs no landing, and "has this landed" has an exact answer. - Landing creates one squash commit instead of duplicating and repairing.
git merge --squashplusgit commit, with the message taken from the card rather than from a commit on the branch. The commit is the owner's by construction, so nothing re-authors it afterwards. One card contributes one commit however many review rounds it took. - The board is orchestrator-only state, not host-only. The orchestrator is a role, not a machine; the skills no longer define it as "the host agent". The role runs on the host today and could later run in its own sandbox.
- The dispatch guard is documented as a backstop. Under squash landing there are no agent-authored commits to re-attribute, so the guard has no routine case to catch. It still catches work landed by another route.
- A reconciliation is recorded on the card. A branch is reviewed against the
tip it was seeded from and integrated onto the current tip. Where the
orchestrator had to choose something in a file the branch changed, the card's
## Review notesrecords what did not apply and whether the card returned to review. sandbox fetchwrites torefs/remotes/sandbox-guest/<inst>. The destination moved fromrefs/heads/sandbox/<inst>(written by 0.4.1) torefs/remotes/sandbox-guest/<inst>. Writing torefs/remotes/means jj imports the ref as a remote bookmark, so a force-update can never rewrite local history regardless of what the host has built on top. Oldrefs/heads/sandbox/*refs are left behind and are safe to delete; seeMIGRATING.md.
progressStallSecsis removed fromlib.sandbox. The argument controlled a host-side "no reports for Ns" notice; both the knob and the notice are gone. Use the WORK column ofsandbox statusto tell a working guest from a stuck one:Activemeans the heartbeat is current,Active (Late)means heartbeats stopped, andIdlemeans the agent stopped without reporting. SeeMIGRATING.md.- Attribution repair is gone from the landing procedure.
--reset-author,jj metaedit --update-author, and the rule that re-attribution had to happen before the next dispatch all existed to fix up duplicated commits. A squash landing creates a new commit, so there is nothing to repair and no deadline.
-
sandbox fetchguard uses strict descent instead of containment. The -
sandbox fetchguard uses strict descent instead of containment. The guard previously calledgit for-each-ref --contains=<old-tip>without comparing objectnames, so anyrefs/heads/sandbox/<inst>left by the prior fetch scheme (whose tip IS the old tip, not a descendant of it) was treated as evidence of a rebased host commit and the fetch was refused. Every instance that existed before this change migrated successfully on the first post-upgrade fetch and was then permanently un-refetchable from the second onward. The guard now fetches%(refname) %(objectname)and filters out refs whose objectname equals the old tip — migration debris — keeping only refs that strictly descend from it. -
sandbox fetcherror message names the actual recovery. The previous message said to usejj duplicate/git cherry-pick"rather than re-fetching", but re-fetching is the only way to obtain new guest commits. The message now names the offending refs, explains that a stalerefs/heads/sandbox/<inst>pointing at pure guest history is safe to delete, and retains guidance for the genuine bad-rebase case. -
git cherry-pickcommand in thesandboxskill corrected. The landing instructions usedgit cherry-pick <name>@sandbox-guest, which is jj notation and is rejected bygit rev-parse. The correct git form isgit cherry-pick sandbox-guest/<name>, which git resolves via disambiguation torefs/remotes/sandbox-guest/<name>. -
README inventory brought back in line with the flake. The root inventory listed three of six skills and presented
menuas alib.*output when it ships through the overlay aspkgs.katsuobushi. Thesandboxguide documented seven of ten verbs, showed asandbox statusexample that predated the WORK column, and never mentioned the report journal (reports.ndjson) or the persisted directive (directive.md/sandbox prompt --redeliver). Theprojectguide omittedproject labelsand the icebox.
A fix for one 0.4.0 regression: sandbox fetch's new ref layout was invisible
to Jujutsu. See MIGRATING.md.
sandbox fetchis visible to Jujutsu again. 0.4.0 landed the guest branch inrefs/katsuobushi/<inst>, a namespace jj never imports (jj readsrefs/heads/*,refs/tags/*, andrefs/remotes/*), so on a colocated jj repo the fetched commits sat in the object database unreachable from every revset. The fetch now force-updates+sandbox/<inst>:refs/heads/sandbox/<inst>. Idempotency comes from the leading+— a refetch after a review bounce force-updates the branch rather than failing non-fast-forward — not from the ref's location, so the branch stays where jj can see it. Thelandedprobe reads the same branch, and the success message again names the ref actually written.
Two design threads land together. The board grows a label-and-icebox model
(PDD001): a label is an epic you read through a filter, an iced note is a card
with no board entry, and the host is the board's single writer. On top of it
come three authoring skills (PDD002): design writes a PDD, plan files
its work as cards, and implement drives a sandbox swarm to ready. No spec or
instance-state change (specVersion 4 / instanceVersion 2 unchanged) — the
sandbox fetch change is host-local. Consumers with legacy design: fields run
one project lint --fix; see MIGRATING.md.
project status --label=<value>— read an epic as a filtered board view. The match is exact and whole-token. Repetition is AND, and the option composes with--laneand--available.project labels— enumerate the label vocabulary with a card count per label.project labels --jsonmaps each label to its card ids. Archived cards are excluded by default;--include-archivedfolds them in. Iced notes count.- The icebox —
project new --iceboxwrites a note with no board card, so a filer never collides with the host onBOARD.md.project status --iceboxlists the iced notes (id, title, labels) and composes with--label.project status set <id> icebox | todo | cancelledshelves a card off the board, promotes an iced note to the front of To-do, or cancels one straight into the archive. project new --label— the canonical, repeatable label option (the older--labelsspelling stays as an alias).- Three authoring skills —
katsuobushi:design,katsuobushi:plan, andkatsuobushi:implement.designruns the PDD authoring ritual (grill, draft, Simplified Technical English, context-free read-through).planfiles an accepted PDD's work as atomic cards.implementschedules a per-card implementor and reviewer sandbox to take a label thread toready.
sandbox fetchlands into a per-instance tracking ref. The guest branch now fetches intorefs/katsuobushi/<inst>, a ref the host never rebases, instead of the localsandbox/<inst>branch. A second fetch of an already-landed instance — every review bounce — is now a clean fast-forward rather than a non-fast-forward failure. The guest push target and thesandbox statusmirror probe are unchanged.project linttreats an orphan note as the icebox. A note with no board card was awarn: orphan-note; it is now aninfoicebox inventory line that never fails the gate.- The card face drops the
designcolumn. A freshlyinited board's settings block no longer declares adesignmetadata-key; the value lives on a label. - The host-only board rule is stated as an invariant in the
sandboxandproject-orchestrationskills and in.dispatch-instructions.md: a sandbox guest never writesproject/kanban/, and findings return through thereportchannel.
project new --design <ref>— deprecated. It now warns and records the reference as a label. Use--label.
- The
design:note frontmatter field. Its meaning moves to a label.project lint --fixfolds a legacydesign:reference into the card's labels, drops the dead key, and drops thedesignsettings column. The migration is idempotent.
Makes Nix-built Rust artifacts diagnosable — the groundwork for having agents
start warm instead of cold-compiling a dependency closure before their first
useful action. Two additions to lib.rust (an alignment manifest beside every
deps bundle, and a checker that says whether cargo will actually reuse it), plus
agent-facing guidance that steers dispatched agents at the project's own menu
rather than at habitual cargo invocations. No spec or instance-state change
(specVersion 4 / instanceVersion 2 unchanged). Consumers see one deps-bundle
rebuild — see MIGRATING.md.
The motivating measurement, for context: seeding a cargo target directory from a crane deps bundle achieves nothing on its own — cargo discards the artifacts and rebuilds — unless the shell also resolves dependencies through the same vendored source replacement the bundle was built against. That is why alignment is worth a tool: every way of getting it wrong is silent, and looks exactly like an honest long build.
- Alignment manifests beside every deps bundle.
lib.rust's deps-only derivations now writemanifest.jsonnext totarget.tar.zst, recording what cargo folds into every unit hash: the rustc identity, target triple, profile,RUSTFLAGS/CARGO_ENCODED_RUSTFLAGS/CARGO_BUILD_RUSTFLAGS, a content hash of the workspace cargo config the build actually saw, and the vendored source directory.target.tar.zstitself is unchanged and crane ignores the extra file. lib.rust.checkArtifactAlignment—katsuobushi-check-artifact-alignment [--profile <name>] <manifest.json|bundle-dir>. Compares a bundle's manifest against the environment a live cargo would run under, reproducing cargo's own config resolution (.cargo/config.tomlfrom the cwd upward plus$CARGO_HOME, nearest wins) to find the effective[source.crates-io] replace-withdirectory. Exit 0 aligned, 1 with the diverged field named and its consequence spelled out, 2 when the verdict is unknown (no manifest, a cargo config that cargo itself could not parse, or a broken check) — an unknown is never reported as a mismatch. Verified against ground truth: a green verdict corresponds to cargo reusing the closure (3 workspace units), a red one to a full rebuild (91 units).- A
sourceIncludelint.lib.rustwarns at evaluation when the project has a.cargo/config.tomlthatsourceIncludedoes not carry into the Nix builds. Those flags then apply to the dev shell but not to anything Nix builds, and cargo discards the mismatched artifacts without saying why. The defaultsourceIncludealready includes.cargo; this catches a project that overrode it for an unrelated reason.
- The guest contract points dispatched agents at the project's menu. Both
the sandbox's guest README and the always-on agent contract now tell an agent
to run
nix develop -c menubefore its first build/test/run command and to prefer the project's own commands — and, when the menu has nothing for the job, to use the raw tool and report the gap, so a missing menu command becomes board signal instead of a silent divergence. The contract is explicit that an agent session starts in a plain login shell, wheremenu/showMenuare not onPATHuntil you go throughnix develop. - The
project-orchestrationandsandboxskills drop the cold-compile folklore. The reviewer-directive template no longer teaches "first build cold-compiles and takes minutes"; build/test commands are documented as belonging in the menu rather than in.dispatch-instructions.md; and the advice to raiseprogressStallSecsnow asks first why the guest is compiling that long at all.
Takes the rebuild latency out of the multi-turn review loop and stops the
orchestrator guessing at how big a sandbox VM is. Docs-only — the
project-orchestration skill; no tooling, spec, or instance-state change
(specVersion 4 / instanceVersion 2 unchanged). See
MIGRATING.md.
- A card's sandboxes stay warm until it is
ready. The skill previously read as though a VM were spent once its branch landed, so orchestrators carried thesandboxskill'ssandbox stop --remove <name>habit — correct when work is accepted — intoneeds-review, where it isn't. Every round of reviewer feedback then paid a full cold compile in a fresh instance before it could change a line, which in a project with a 20-minute build is most of the loop. The implementor VM is now paused (sandbox stop card-<id>, no--remove) atneeds-reviewand resumed with the findings —sandbox promptauto-starts a paused instance, and its scratch volume still holds the built target dir and the cargo/nix caches. The counterpart reviewer is paused the same way between rounds, since it may re-review several times.--removefor both is now tied to the card reachingready(or being bounced, cancelled, or stalled). The skill spells out the three things a resume forces you to write around: the VM's RAM is wiped so the prompt must stand alone, its mirror is frozen at launch so fixes come back on the old seed, and both full instance names belong on the card's## Dispatch logso a later turn can find the pair. - Fan-out is sized from the project's own
lib.sandboxconfig. The concurrency guidance asserted "each VM is ~4 vCPU", which is only the lib's default — a project that setsvcpu = 8was silently budgeted at half its real cost. The orchestrator now readsvcpuandmemout of the consuming flake before computing anything, and falls back to 4 vCPU / 8192 MiB only when the call sets neither. RAM joins cores as a bound, sincememoften binds first:max VMs ≈ min((cores ÷ 2) ÷ vcpu, (RAM ÷ 2) ÷ mem), read fromnproc/free -m. The session-start question to the product owner now has to quote the per-VM size and the VM count it implies, rather than asking for "half the box" in the abstract. - The "if you loaded only this skill" section is a pointer, not a summary.
It restated three sandbox facts that have since been documented in place; it
now just says to load the
sandboxandprojectskills.
Burns down the 2026-08 post-mortem: a sandbox launch is now diagnosable while it provisions, recoverable when a dispatch dies or a harness wedges, and honest about what it concluded. Alongside it, two silent data-integrity fixes on the project board — a formatter run could blank a card's title and empty its dependency list, and opening the board in Obsidian could fail the Markdown gate.
The consumer-visible behavior changes are the --until-report default flip for
sandbox dispatch / prompted sandbox start, a third sandbox status state
(provisioning), and lib.markdown's exclude doing something for the first
time. No spec or instance-state change (specVersion 4 / instanceVersion 2
unchanged). See MIGRATING.md.
-
lib.projectexportsmarkdownExclude. Alib.markdownconsumer merges it intoexcludeto keep the machine-managedBOARD.mdout of the Prettier gate:exclude = project.markdownExclude. The glob is derived fromboardDir, so it follows a relocated board. Card notes (<boardDir>/issues/*.md) stay gated — they are prose Obsidian does not reformat. -
progressStallSecsis alib.sandboxargument, and the progress notice no longer cries wolf. The stall window was a hard-coded internal constant (300s) rendered into the spec but not exposed, and onlyworking/inforeports reset its clock — heartbeats are deliberately silent — so an agent inside one long foreground tool call looks idle however hard it is working. A dispatched agent's first act is typically a 15-25 minute cold compile, so the notice fired on essentially every launch and was benign every time. A watchdog that always barks trains operators toward alarm, and in the field their one alarmed response was the destructive one. Two changes: the window is now a consumer knob (progressStallSecs ? 300, so a project with 20-minute builds can set1500), and the notice escalates instead of asserting trouble up front — the first fire reads "no reports for Ns — normal during long builds …", and only a second fire at three times the window says the agent may be stuck, after which the episode goes quiet. Neither ever breaks or kills the turn, and the default window is unchanged. -
Report texts are journaled to
reports.ndjson. An agent's reports existed only as a transient stream: the guest relayed each one and the drivingprompt/dispatchprocess rendered it to stdout, while host-side persistence (liveness.json) held only heartbeat freshness and the turn counter and guest-side (turn-state.json) only phase and timestamps. A turn could reachended-ok— the fact of a clean terminal report durable — with the report's content gone if that stdout was lost. In the field a reviewer'sreport done "VERDICT: …"landed, the operator lost the stream, grepped the whole state dir for "VERDICT", found nothing, and had to re-prompt the reviewer to restate its own verdict. Every relayed report — plus theStopped/ReArmedlifecycle verdicts — is now appended at the drive sink toreports.ndjsonin the instance state dir, one JSON object per line withturnId,status,text, and a host timestamp. (The journal is written by the live drive, so it survives losing that drive's output — a closed terminal, a dead tmux pane — but not the drive process itself; a report landed by a later auto-nudge after the drive is gone is not journaled.) (a multi-line verdict stays one line; a torn trailing line never hides earlier history). The watchdog's stall notice is deliberately not journaled — it is not something the agent said.sandbox status <inst>renders the latest terminal report as alast report:line. Best-effort throughout: a failed append warns and never breaks the drive, and the--jsonstream is unchanged. -
A prompted launch persists its directive, and
sandbox prompt --redeliverresends it.dispatchclaims the card, composes the directive, boots the VM detached, and only then tail-callspromptto deliver it — so the text lived solely in the launching process's argv. Killing that process left a healthy idle VM, a card marked in-progress, and the directive nowhere on disk; recovery meant hand-recomposing it from the card plus the instructions file, which only works if you know the composition rule. The composed directive is now written todirective.mdin the instance's state dir besideinstance.json, andsandbox prompt <inst> --redeliverdelivers it verbatim as a fresh turn (normal turn-id and delivery-ack semantics; passing text as well is a usage error, and a missing directive fails with the explicit command to run instead). A promptless launch of an instance that has one prints the--redeliverhint. No new secret exposure — a directive is card text already plaintext in the board, and secrets ride fw_cfg. The file's lifetime follows the state dir, so an ephemeral instance's is reaped with it and a named instance's persists across stop/start. -
Pre-boot provisioning is visible. The whole window between "launch started" and "QEMU is up" — mirror clone, nix DB snapshot, context and secret staging — used to be structurally silent:
instance.jsonis written before the recipe runs while the QMP socket only appears at boot, sosandbox statussaidstopped, andconsole.logdid not exist yet because only the runner writes it. An operator watching an 11-minute stall had/procspelunking as their only diagnostic, and killed a live step because of it. Now:- the launch recipe tees its own output to
provision.login the state dir from its first step, with a::: <step>/::: done (Ns)marker pair per step (the real stdout/stderr are saved and restored before the runner takes over, so interactive mode still hands a true TTY to ssh+tmux); - it maintains a
phasemarker thatsandbox statusrenders asprovisioning (<step>)in the detail view and--json, and asprovisioningin the list — a live VM still wins, so a stranded marker can never mask a running instance; - the guest's Nix-DB seeding service publishes its verdict to the share, and
sandbox status <inst>renders it as astore db:line (host-seeded/system-only — host seed skipped/… rolled back). An unseeded guest boots perfectly healthy and can't run a single gate; that is now visible before an agent burns a session discovering it.
- the launch recipe tees its own output to
- An injected-but-never-accepted turn now has a recovery path. The guest
turn machine distinguishes created, injected (the channel notification was
written to the harness's MCP transport) and accepted (the harness actually
began the turn, evidenced by a hook or first report). Injection success only
meant
send_notificationreturnedOk, which says nothing about the harness processing it — so a harness that accepted the write and never started a turn leftinjected && !accepted, a state the machine recorded but no recovery path consumed: every host resend hit the dedupe arm and vanished, and the only way out wassandbox stopplus a restart. The guest now allows a bounded number of re-injections (2) for a turn that is injected but neither accepted nor ended, carrying an "if you already received turn N, continue it; otherwise begin it now" guard preamble so the small double-run risk degrades to a no-op. Re-injection stops the moment the turn is accepted (unspent budget is kept), and an accepted, ended, or budget-exhausted turn still dedupes exactly as before — an executed turn is never re-run. If the turn still never accepts, the host's verdict now names that specific state and the recovery (sandbox stop/sandbox start --agent --name, then--redeliver) instead of the ambiguous "delivery failed". - An aborted launch no longer leaves an instance reading
provisioningforever. The pre-bootphasemarker was cleared only on the success path, so a launch that failed mid-provisioning — an unreadable secret exits 1, andset -e/Ctrl-C can end the recipe anywhere — left the marker behind andsandbox statusreportedprovisioning (<step>)indefinitely for an instance with no VM. That is worse than thestoppedit used to report, and would strand an orchestrator that (per the migration note) treatsprovisioningas a live launch worth waiting for. The recipe now armstrap 'rm -f <phase>' EXIT INT TERMbefore the first step. - A long
labels:/blocked_by:list no longer gains a phantom empty entry. Once a flow list is long enough, Prettier explodes it one item per line with a trailing comma — the common on-disk shape for a real label set. The flow parser read that trailing comma as an extra, empty item, which reached--json,project status, and the Obsidian card face. Items whose raw text is blank are now dropped; an explicitly empty item (written"") still round-trips. - Concurrent
<name> format/<name> lintruns no longer race their staged ignore file. Both share a configurationname, so they shared one.prettierignore.<name>path and the first to finish deleted it out from under the second. Prettier does not error on a missing--ignore-path— it exits 0 having ignored nothing — so the losingformatrun would silently rewrite every excluded file,BOARD.mdincluded. The staged file now carries the pid, and a missing one is a hard error rather than a silent no-op. - The launch recipe keeps stdout and stderr separate. Provisioning output
was teed with
2>&1, which sent the recipe's own failures (a missing secret) to the caller's stdout. Each stream is now teed back to its own descriptor;provision.logstill captures both. sandbox prompt <inst>with no text is a usage error instead of delivering an empty turn.--redeliveris the supported way to omit it.- The launch-time host Nix DB snapshot no longer livelocks against a busy
host. The snapshot used a plain SQLite
.backup, whose API restarts from page zero whenever another connection writes the source — and the nix-daemon writesdb.sqliteon every derivation registration, so any overlapping hostnix build(a second concurrent launch is one) restarted it indefinitely. Observed in the field at 11m32s and 9.1 TB read for a database that snapshots in under a second when quiescent. It is now aVACUUM INTO, which holds a read transaction instead of restarting, wrapped in a 120stimeoutso any future pathology degrades to the already-designed fallback — a guest that boots unseeded — rather than wedging provisioning. Failure or timeout stays non-fatal, publishes no partialnix-db.sqlite, clears its temp file, and prints a visible warning naming the consequence. A missing host database is now skipped rather than snapshotted into an empty one (sqlite3creates a database on open). - Prettier-reflowed frontmatter no longer blanks a card. When a frontmatter
value exceeds Prettier's print width it is reflowed onto indented continuation
lines — valid YAML, and what
markdown formatconsiders canonical — but the key's own line then reads as empty. The line-oriented note reader took that as "absent", so a routine format run silently and retroactively blanked card titles (project statusand the Obsidian card face both rendered empty) and emptiedblocked_bylists, corrupting the dependency graph.get_scalarandget_listnow fold the continuation lines back into the value, the way YAML does. Block lists and nested mappings are still never mistaken for a wrapped value, andset_scalarreplaces a wrapped value's continuation lines rather than orphaning them. The reader-only fix keeps the editor's byte-for-byte preservation guarantee — nothing rewrites the note, so unknown keys (Obsidian's included) survive as before. project lintflags a card with no readable title (empty-title). Previously a blanked title left the board and the note perfectly consistent, so lint reportedcleanwhile the card rendered blank everywhere — the sharpest edge of the reflow bug. It is an error, not a warning:newrequires a title, so an empty one is unambiguous corruption.lib.markdown'sexcludenever actually excluded anything. Prettier applies.gitignoresemantics to--ignore-path, which anchors any pattern containing a/to the directory holding the ignore file. Because the generated file lived in the Nix store, a workspace-relative entry likevendor/**resolved against/nix/store/…and silently matched nothing. The format command and the flake check now stage the generated ignore file at the workspace root (the check copies the source tree first, since a store path is read-only) soexcludeentries are workspace-relative as documented.- Opening
BOARD.mdin Obsidian no longer fails the Markdown gate. The board has three writers with incompatible serializations — thekatsuctl projectCLI, Prettier, and the Obsidian Community Kanban plugin — and the plugin's serializer is fixed and cannot be made Prettier-stable, so merely reading the board in Obsidian brokemarkdown lint. The board is machine-managed structured data rather than prose, so it is excluded from the gate (seemarkdownExcludeabove); this repo's own flake wires it.
sandbox dispatchand a promptedsandbox start --agentstay armed by default (--until-reportis now the default for them). With the flag off, a drive returned the moment the agent yielded — aTurnCompleted{reported: false}surfaced the "stopped without reporting" warning and broke, instead of staying armed for the guest's auto-nudges or a late terminal report. For an interactive prompt that is defensible; for an orchestration flow it inverts what the command exiting means, since an orchestrator (or a human skimming) reads "the command returned" as "the work concluded". In the field the flag was omitted on both reviewer launches, so a reviewer paused mid-cold-build read as complete and its verdict was judged from a drive that had simply disarmed. Documenting the flag as opt-in made forgetting it the default outcome exactly where it mattered most.--no-until-reportrestores the old early return;--until-reportis still accepted on both and is now a no-op, so existing invocations keep working. Interactivesandbox promptis unchanged — it still returns on an unreported yield — but its warning now names the two recoveries (re-run armed, or checkturn-state.json/sandbox statusfor a laterended-ok, since the guest's auto-nudges may land a report after the command exits).- Loading the
sandboxskill is now a hard prerequisite of theproject-orchestrationskill. The skill previously only described the relationship ("complements … thesandboxskill"), and an orchestrator that read that line and skipped the skill flew its VMs blind. The body now opens with an imperative instruction to load it, the frontmatter description says so at selection time, the dispatch and reviewer sections name it as a prerequisite, and a short "if you loaded only this skill" callout inlines the three facts whose absence has actually cost sessions (whereconsole.loglives, that the no-progress notice is usually benign, and stop-vs-remove).
Teaches lib.rust to build under an arbitrary Cargo profile, refreshes the
default wasm-bindgen-cli to the latest release, and tightens the
project status archive window so finished work clears the human view within
the hour. No spec or instance-state change (specVersion 4 /
instanceVersion 2 unchanged). See MIGRATING.md.
- Per-helper Cargo build profiles in
lib.rust. Every exported build helper (buildCrate,buildWasmCrate,buildTrunkCrate,buildTestArchive) now takes an optionalprofile(default"release"); passprofile = "dev"for an unoptimized build. The shared workspace-deps bundle is keyed by(target, profile), so a crate and the dependency closure it reuses are always built under the same profile, andbuildTestArchivethreads the profile intocargo nextest archive(--cargo-profile) so its archived test binaries no longer diverge from that bundle. Release builds are byte-identical to before.
- Default
wasm-bindgen-clibumped0.2.108→0.2.126. The hashes shipped indefaultWasmBindgenHashesnow track the latestwasm-bindgenrelease; a workspace whoseCargo.lockresolves a different version still overrides via thewasmBindgenHashesargument. project statusclears archived cards after 1h (was 24h). The non-JSON list drops accepted and cancelled cards once theirdisposition_atis over an hour old, keeping the human view on live work.--jsonis unaffected — tooling still sees every archived card.
Fixes the project board writer's formatter-instability churn and the archive
corruption it caused, plus a sandbox build that exhausted the guest's RAM-backed
scratch. The board writer now emits prettier-stable markdown and anchors the
archive on its ## Archive heading, so a CLI rewrite followed by
markdown format is byte-identical and duplicate ## Archive sections can no
longer accrete. No spec or instance-state change (specVersion 4 /
instanceVersion 2 unchanged). See MIGRATING.md.
project lintstructural-lane checks. Errors on duplicate lane headings (duplicate-lane—cards_inreads only the first, so cards in the rest are dropped on the next rewrite), and warns on a card in an unrecognized lane (unrecognized-lane) or a checked- [x]card stranded in an active lane (checked-in-lane, the fingerprint of a card un-archived by separator loss).
- The board writer emits formatter-stable markdown. Empty lanes get a single
blank line (not the
\n\n\na formatter collapses), and the archive separator is---(what Prettier normalizes***to). A freshlyinited board and the settings block are prettier-canonical from birth, so the firstmarkdown formatover a board is a no-op instead of a drift chore. - The nix-daemon build directory lives on the disk-backed scratch volume.
lib.sandboxguests pointbuild-dir(and the daemon'sTMPDIR) at/scratch/nix-buildso a large in-guest build spills to disk instead of the RAM-backed root tmpfs.scratchVolumeSizemust now also cover transient build trees; the images are sparse, so a generous cap stays cheap.
- Board format-drift churn and duplicate
## Archivesections. The writer's formatter-unstable output left the board dirty under themarkdown formatgate after every CLI mutation; once a formatter rewrote the***separator the parser stopped recognizing the archive and appended a fresh## Archive, stranding cards in an unreachable lane. Archive parsing now anchors on the## Archiveheading (tolerating any or no separator) and merges duplicate sections back into one on the next write. - Trailing human content dropped on rewrite. Foreign content left after the last lane on a settings-less board was consumed as stray lines and lost; it is now preserved verbatim, while mid-board strays are still skipped so the structure after them survives.
- Leading blank lines on a frontmatter-less board.
to_textno longer opens a board that has no frontmatter with the\n\na formatter would strip.
Makes host↔guest turn completion event-driven again when a sandbox agent ends a
turn without reporting — the failure mode that turned dispatch/prompt
orchestration into wall-clock guessing. Three layers: the agent contract now
mandates foreground work and a report before every turn-end; the guest
auto-nudges an unreported idle agent before giving up; and the host can stay
armed across an unreported turn-end. No spec or instance-state bump
(specVersion 4 / instanceVersion 2 unchanged) — the new knobs are guest-only
env vars and the flag is host-only CLI. See MIGRATING.md.
- Guest auto-nudge for an unreported turn-end. When a sandbox agent stops
without a terminal
report done/blocked, the guest server now re-prompts it ("report your real state now") up toKATSU_MAX_NUDGEStimes (default 3),KATSU_NUDGE_INTERVAL_MSapart (default 30s), before resolving the turn asended-unreported. This recovers the two common silent-stop cases — the agent forgot to report, or it backgrounded work and yielded — without operator intervention.maxNudges/nudgeIntervalMsare the Nix-side knobs;0disables nudging (the prior single-grace behavior). sandbox prompt/dispatch/start --until-report. Keeps the host stream armed across an unreported turn-end (emitting arearmednote and continuing to wait for a real terminal report) instead of returning with the "stopped without reporting" warning. Pairs with the guest auto-nudges so a backgrounded build that finishes and reports long after the turn ended is still caught live.
- Agent-mode turn discipline. The always-on agent contract now mandates
running builds/tests in the foreground, never yielding a turn with unfinished
work, and always running a terminal
reportbefore stopping — baking in the rule dispatched agents previously had to be told ad hoc.
- The
project-orchestrationskill loads again. A:(colon-space) inside its YAMLdescriptionbroke frontmatter parsing, so the skill was silently dropped from the registry and could only be found by crawling the filesystem. The description is now a colon-free plain scalar.
Corrects the project-board guidance so agents are pointed at the project menu
command rather than the underlying katsuctl project binary, which is meant to
be a hidden implementation detail. Docs-only; no spec or instance-state bump
(specVersion 4 / instanceVersion 2 unchanged). See
MIGRATING.md.
- The
projectskill and board docs reference theprojectcommand, notkatsuctl. Theprojectskill, the scaffoldedproject/kanban/README.md(and its generator inkatsuctl), and thelib.projectREADME/notes now presentprojectas the interface —katsuctlis an implementation detail behind the menu command, which forwards--jsonpayloads through untouched. Newly scaffolded boards get the corrected README; existing boards are unaffected.
Makes katsuctl build on non-Linux (macOS): the project domain is
cross-platform, and the Linux-only sandbox domain is now conditionally
compiled out. Also fixes streamed agent reports being lost from a captured,
non-TTY stream. No spec or instance-state bump (specVersion 4 /
instanceVersion 2 unchanged). See MIGRATING.md.
katsuctlbuilds on non-Linux. Thesandboxdomain (and its Linux-onlytokio-vsockdependency) is gated behind#[cfg(target_os = "linux")], so on macOSkatsuctlcompiles with theprojectboard commands and omits the sandbox subcommands.lib.projectandpackages.<system>.katsuctlare now available on every system; the sandbox library stays Linux-only.
- Streamed agent reports are no longer lost in captured output.
sandbox prompt/sandbox dispatchrendered live reports to stdout, which theemitExecmenu wrappers capture and which races teardown in a non-TTY/backgrounded stream — so a terminalreport donecould vanish. Reports now stream to stderr (the reliable channel the other progress lines already use), and gate their color on stderr's TTY-ness;--jsonstreaming stays on stdout.
- The shared output/rendering module moved from
sandbox::outputto a top-leveloutputmodule — an internal refactor enabling the cross-platform split.
Adds a file-backed project board and agent orchestration built on it:
queue work as Obsidian-Kanban cards (katsuctl project / the project menu
command / lib.project), dispatch cards to sandbox VMs for implementation, and
peer-review in sandboxes. Ships two new skills. Additive for existing consumers
— no spec or instance-state bump (specVersion 4 / instanceVersion 2
unchanged). See MIGRATING.md.
projectboard +lib.project. A lightweight backlog rendered as an Obsidian Kanban board:project init / new / status / status set / prioritize / lint. Six-state lifecycle (to-do → in-progress → needs-review → ready → accepted, plus cancelled), 6-hex card ids, andblocked_bydependencies that clear their dependents atready.project status— the unified view+mutate surface: bare lists the board,<id>shows one card,--available/--lanefilter,--jsonis machine-readable. A card entering Ready auto-slots into suggested acceptance order (dependencies first, then oldestcreated) without disturbing a manual order.project status set --accept-allbulk-accepts every Ready card. Terminal transitions stamp adisposition_attimestamp; the humanproject statuslist shows only cards archived in the last 24h (--jsonreturns all).sandbox dispatch <card>launches an agent VM to implement a board card, andsandbox fetchnow reports whether committed work actually landed (branch tip vs. launch seed).- Two skills —
project(board mechanics) andproject-orchestration(the implementor / peer-reviewer / product-owner / orchestrator roles, sandbox dispatch, the report bridge, and a host-core concurrency budget).
- The Katsuobushi repo's own sandbox
allowedOriginsnow include the crates.io origins so in-guestcargobuilds fetch dependencies normally.
Fixes a regression from the 0.2.6 command-tree rename: prompting a paused, named
sandbox no longer fails trying to resume it. The fix is host-side, so a
dev-shell rebuild picks up the corrected controller; no spec or instance-state
bump (specVersion 4 / instanceVersion 2 unchanged). See
MIGRATING.md.
sandbox promptresumes a paused named instance again. The 0.2.6 rename removed thesandbox:startmenu binary, butsandbox prompt's auto-resume still shelled out to that name — so prompting a powered-off named instance errored instead of booting it to deliver the turn. Resume is now self-contained: it re-runs thestartsubcommand and execs the boot recipe that emits, via the pinnedkatsuctl/bashstore paths, depending on no menu command being onPATH.- Stale
sandbox:*command names in hints. Status, attach, resolve, and stop messages — and the post-launchstartecho — still suggested the removed colon-namespaced commands (sandbox:status,sandbox:fetch, …); they now name the current subcommand forms (sandbox status,sandbox fetch, …).
Adds a built-in menu command to every dev shell, so the command table can be
reprinted on demand. Menu-only; no spec or instance-state bump (specVersion 4
/ instanceVersion 2 unchanged). See MIGRATING.md.
- Built-in
menucommand.makeMenunow always contributes amenucommand ("Print this menu.") that reprints the top-level command table. Like any command it shows its own figlet banner + description first, but it does not reprint the hero graphic — that stays a one-time greeting shown when you drop into the dev shell. Define your ownmenuincommandsto override the built-in.
A menu-polish follow-up to 0.2.6: subcommand banners regained their figlet
header, and all menu decoration moved to stderr so it can no longer muddy a
command's stdout. Menu-only; no spec or instance-state bump (specVersion 4 /
instanceVersion 2 unchanged). See MIGRATING.md.
- Subcommand banners show the full command chain again. 0.2.6's subcommand
trees dropped the pretty figlet header other commands show — a branch printed
none, and a leaf would at most have shown its own bare key. A resolved leaf
now banners with its whole Nix-encoded chain (
sandbox status, notstatus;markdown format), and a bare branch banners with its own name (markdown).
- Menu decoration goes to stderr. The dev-shell greeting (hero + command
table) and every command's figlet banner now write to stderr instead of
stdout, so
nix develop -c 'sandbox status --json' | jq— and any captured/piped menu command — keeps clean, pipeable stdout. The greeting still displays on the terminal and still clears the screen on entry; the per-command banner still precedes the command's real output.
Dev-shell menu commands are now organized as subcommand trees, so a namespace
collapses to a single command + menu row instead of one row per verb. The
sandbox:* and format:* / lint:* commands are renamed accordingly — the one
consumer-facing break in this release. No spec or instance-state bump
(specVersion 4 / instanceVersion 2 unchanged). See
MIGRATING.md.
makeMenucommand trees. A menu command may now be a branch — an entry with asubcommandsattrset instead of acommand— which compiles to one shell application that dispatches on its first argument and recurses to any depth. Both leaves and branches take an optionalhelpstring; running a branch bare (or with-h/--help) prints that preamble plus an aligned table of its subcommands. Flat command sets are unchanged, so an existing menu keeps working untouched.- Sandbox usage lines read as
sandbox …. clap prints its errors andUsage:lines qualified by katsuctl's real path (e.g.katsuctl sandbox --config <CONFIG> attach <INSTANCE>); the menu wrappers now rewrite that prefix back to the command the user typed (sandbox attach <INSTANCE>) in katsuctl's stderr. Only stderr is filtered, so streaming stdout — notablysandbox prompt's live report stream — is untouched.
lib.sandboxmenu commands are nowsandbox <verb>. The sevensandbox:*entries collapse into onesandboxbranch withstart,prompt,status,fetch,stop,attach, andscreenshotsubcommands. Each verb keeps its exact behavior, andnix run .#sandboxis unchanged.lib.markdownmenu commands are now<name> <verb>. Each invocation'sformat:<name>/lint:<name>pair becomes a single<name>branch withformatandlintsubcommands (default namemarkdown, somarkdown format/markdown lint). The flakecheckname is unchanged.
- The colon-namespaced command names.
sandbox:start(and the other sixsandbox:*),format:<name>, andlint:<name>no longer exist as dev-shell commands — use the subcommand forms above. Update any script, CI step, ornix develop -c …invocation that calls an old name.
A hardening release from a full engineering audit of the sandbox feature: shell
quoting, secret staging, and teardown fixes on the host; turn-delivery
correctness fixes on both sides of the agent-mode channel; guest control-plane
hardening; and safe parallel launches. No spec or instance-state bump
(specVersion 4 / instanceVersion 2 unchanged) and no config changes — but
restart agent instances so the guest-side fixes take effect, and note the
tightened eval-time validation. See MIGRATING.md.
- Recipes single-quote host paths. The emitted start recipe double-quoted
paths (the git toplevel, XDG-expanded roots, context entries), leaving
$, backticks, and\shell-active; every path is now single-quoted with the same close-escape-reopen idiom the prompt payload already used, so a path containing shell-special characters is inert. fromEnvsecrets are born0600. The credential file was created under the default umask and then chmod'd, leaving a brief window where the plaintext token was world-readable; it is now recreated under a subshellumask 077, matching theinstall -m 0600guarantee thefromFilebranch already had.sandbox:stopconfirms the VM died before removing its state.quitwas fire-and-forget: a wedged monitor fell through to recursive removal, deleting the disk images out from under a still-running qemu while reporting success. Stop now polls the monitor afterquitand refuses removal (loud, nonzero, nothing deleted) while it still answers; both dir removals are also attempted before an error surfaces, so a partial failure no longer strands a half-torn-down instance.- A failed first injection no longer wedges the turn. The guest committed a turn to in-flight before the injection ran, so if the injection failed (the first-turn race) every host resend of that id was dedupe-dropped forever. Delivery is now tracked separately: an undelivered turn re-injects on resend, a delivered one dedupes, and a resend during the stop-grace window no longer creates a fresh turn (which would have executed it twice).
- The turn-id counter never rewinds. A corrupt (or schema-newer)
liveness.jsonsilently resetnextTurnIdto 1, and the guest's turn-id dedupe would then drop the next genuinely-new prompt. A corrupt record now failssandbox:promptloudly instead, the best-effort heartbeat writers skip rather than clobber it, and unknown fields no longer fail the parse. sandbox:statusno longer reports a phantom active stream. ThestreamActiveflag is only cleared by a clean driver shutdown, so a panicked/killed driver leftstatusclaiming an active stream forever; the flag is now believed only while the recorded heartbeat is within the watchdog deadline.- A stale report cannot end the wrong turn. Both sides applied
accept/terminal transitions to whatever turn was in flight, so a late
donefrom turn N could terminate turn N+1 and falsely satisfy its delivery ack; a report naming a different turn now relays without transitioning. - Parallel
sandbox:starts cannot collide. CID/port selection read sibling instances before either launch had persisted its claim (and a sibling's ssh port is not even bound until its qemu boots, so the bind probe alone could not see it). The planner now skips sibling-recorded ports and CIDs and holds an advisoryflockunder the project state root across the probe→persist window — swarm launches allocate safely.
- The guest bounds and times out its I/O. Inbound lines on the control and
report sockets are capped at 1 MiB (the report socket is reachable by the
unprivileged in-guest agent, so an unterminated flood was an in-guest OOM),
outbound writes to the host time out after 10s and drop a wedged connection
instead of freezing the heartbeat behind it, and the
turn-state.jsonpersist moved off the async workers so a stalled 9p share cannot pin them. - Eval-time validation is tighter. A
homeFilesentry with an unknownmodenow fails evaluation instead of silently never appearing in the guest, andhomeFiles/extraReposdestinations get the same full..traversal check asworkspaceContext(whose/..-suffix formextraReposhistorically missed). - Ephemeral instance names are UTC-stamped. The timestamp is now formatted
in Rust (it was the lone bare-PATH
dateinvocation in an otherwise pinned-tool contract) and uses UTC where the shell used host-local time.
A packaging hotfix: the sandbox:* menu commands failed for consumers with
katsuctl: command not found. They invoked katsuctl by bare name and relied
on it already being on the dev shell's PATH — which only Katsuobushi's own dev
shell arranged, so a project that wired in just sandbox.menuCommands got
commands that could not find their own controller. The instance spec bumps to
specVersion 4; see MIGRATING.md.
sandbox:*commands work withoutkatsuctlon PATH. Every menu command (andnix run .#sandbox) now invokes the controller by its absolute store path, and the agent-modestartrecipe self-references it through a newtools.katsuctlspec field instead of a barekatsuctl … prompttail-call run in a child shell. A consumer that wires onlysandbox.menuCommandsinto a dev shell no longer hitskatsuctl: command not found. No PATH manipulation remains in any command.
lib.sandboxexposeskatsuctl. The host controller derivation (built vialib.rust/crane from Katsuobushi's pinned source) is now returned fromlib.sandboxaskatsuctl, so a project can put a barekatsuctlon its dev shell PATH for direct use. The sandbox template wires it in for power users; thesandbox:*commands no longer require it.
A graphics hotfix: in a graphics guest an X11 app — or any tool that probes
DISPLAY — failed out of the box because only WAYLAND_DISPLAY was set. See
MIGRATING.md.
- X11 apps work in a graphics guest. The guest now exports
DISPLAY=:0alongsideWAYLAND_DISPLAY(in both the sshdSetEnvand the login shell) and shipsxwayland, so sway's XWayland shim serves X clients on:0. A tool that probesDISPLAY, or an X-only app, now runs with no per-invocation ceremony. Gated on the graphics opt-in; a graphics-off guest is byte-for-byte unchanged.
Opt-in graphics: a sandbox can now boot a headless compositor and a
paravirtual GPU so a browser or Wayland app actually renders. It is off by
default, so existing consumers are unaffected; enabling it widens the
host-facing attack surface (the GPU command stream is parsed in the host QEMU
process), which the README documents plainly. The instance spec bumps to
specVersion 3; see MIGRATING.md.
lib.sandbox: opt-ingraphicscapability. A newgraphicsattrset (enable, defaultfalse;gpurole-preference list, default["integrated" "discrete" "software"];output, default1920×1080@60) boots a headless sway compositor on a virtual output and, when a GPU rung resolves, hands QEMU avirtio-gpu-gldevice against a host render node — so a browser (WebDriver/Playwright) or a Wayland app can render. The browser/app goes in the existingpackageslist. Pinninggpu = ["software"]keeps the full original boundary (llvmpipe, no GPU device) at a performance cost. When enabled,sandbox:statusadds agraphicspreflight row that runs the real GPU resolver against the host and flags a missingrender-group membership, and a launch-time notice records the widened attack surface. Seelib/sandbox/README.md.sandbox:screenshot <instance|#> [path]. A new menu command that grabs a PNG of the headless-sway output by runninggrimover the existing loopback ssh — no daemon, no new port. Defaults to a timestamped PNG in the cwd;-streams to stdout. Requires the graphics opt-in; a purely-offscreen workload that never composites a surface screenshots as blank (expected).sandbox:statusGRAPHICS column. The instance list now shows the GPU rung each instance launched on —integrated,discrete,software, ornonewhen graphics is off — recorded per-instance ininstance.json(and surfaced in the detail view and--json).
- The instance spec is now
specVersion 3(carrying thegraphicsblock); a stale v2 spec is rejected loudly. Rebuild your dev shell (nix develop) so the spec re-renders. No config changes are required. instance.jsonis nowinstanceVersion 2(it records the resolved graphics rung). A v1 instance state from an earlier release is rejected on read, so recreate any persistent (--named) instance after upgrading — ephemeral instances are unaffected.
Sandbox liveness: the host and guest now agree on when a turn started,
finished, or silently died — closing the first-turn race and surfacing
unreported hangs. An agent-mode VM emits heartbeats and lifecycle edges, and the
guest persists turn state to the share so sandbox:status can report it
out-of-band, even with nothing attached. No action for devshell users beyond
rebuilding (the instance spec bumps to specVersion 2); see
MIGRATING.md.
- Turn/transport liveness machinery. The guest controller runs a per-turn
state machine and writes a durable
turn-state.jsonto the share on every transition (idle→in-flight→ended-ok/ended-unreported), plus a periodic heartbeat. Areport hook <event>bridge wires Claude Code'sStop,SessionStart, andUserPromptSubmithooks (managed-settings tier) into that machine. - Host
drivewatchdog.sandbox:promptnow runs a deadline-aware loop: ack-and-resend of an undelivered first turn, a heartbeat-deadline that detects a dead transport, a one-shot progress-stall notice, and a pre-send ready-gate that closes the first-turn race — a prompt to a just-booted instance no longer lands in the arming gap. Heartbeats are silent, so a backgrounded drive is never woken by a tick, and a monotonic, persistedturn_idmakes resends safe. sandbox:statusliveness line. Status readsturn-state.json(and the host-writtenliveness.json) to show per-instance turn/transport state with no connection — e.g.turn 3 ended-unreported 14m ago · no active stream— corroborated against QMP.- Seven liveness tunables (
heartbeatSecs,heartbeatMiss,progressStallSecs,deliveryDeadlineSecs,deliveryRetries,readyGateSecs,stopGraceMs), Nix-driven from one source into both the spec and the guest env.
- The instance spec is now
specVersion 2(carrying the liveness tunables); a stale v1 spec is rejected loudly. Rebuild your dev shell so it re-renders.
- The per-instance share root is now guest-writable, so the guest controller
can create
turn-state.jsonon a real boot. Themapped-xattr9p share left the root root-owned; the launch recipe now opens ita+rwX, as it already did forsync.git. liveness.jsonis written atomically (temp + rename), sosandbox:statusnever reads a torn record.
The host side of the sandbox is rewritten from an unmaintainable pile of
untested shell into a compiled, tested Rust binary, katsuctl. From a
devshell user's perspective this is a no-op — sandbox:start, sandbox:prompt,
sandbox:status, sandbox:fetch, sandbox:stop, and sandbox:attach keep
their names and behavior — but their logic now lives in
katsuctl <domain> <command> with unit, golden-snapshot, and seam-level tests,
verified end-to-end against a real KVM boot. The three in-tree Rust crates are
also renamed for clarity (breaking only for anyone depending on them directly —
see MIGRATING.md).
katsuctlhost-side controller (katsuctl sandbox <command>) absorbing all the sandbox host logic: instance naming / ssh-port / vsock-CID / seed-commit decisions made in tested Rust, a Nix-rendered instance spec passed via--config, a native QMP client (liveness + quit), a consolidatedinstance.jsonper-instance metadata file, an emit-script harness for thestart/attachterminal hand-offs, and dual human/--jsonoutput with strict color gating. Built reproducibly via the flake (nix build .#katsuctl).
- The six
sandbox:*devshell commands are now thinkatsuctlwrappers — same names and behavior, but every decision is made in tested Rust and the shell that remains is a flat, generated recipe. Secrets are emitted as references, never values, and the start/attach recipes are golden-snapshotted. - Rust crates renamed (see
MIGRATING.md): the host controller cratekatsuctl→katsuobushi-controller(it still ships thekatsuctlbinary),katsuobushi-protocol→katsuobushi-sandbox-protocol, andkatsuobushi-sandbox-control→katsuobushi-sandbox-guest(its guest channel-server binary renames with it). sandbox:statusgains an aligned, color-coded table and a--jsonmode. The list shows# / INSTANCE / STATE / MODE / PERSIST; the ssh port and vsock CID moved to the per-instance detail view (sandbox:status <name>). A barestatusdoubles as the launch prerequisite gate — nonzero exit if a declared secret or/dev/vhost-vsockis missing.
- The old host-side shell —
sandboxRunner, theisRunningQMP probe,instanceHelpers, andstatusSecretChecks— and the standalonekatsuobushi-sandbox-prompthost-client binary, all replaced bykatsuctl.
A sandbox release with one consumer-facing breaking change: the guest's writable
scratch is now disk-backed instead of RAM-backed, and the single
storeOverlaySize argument is replaced by three sparse-image sizes. Also adds
auto-start when prompting a paused instance. See
MIGRATING.md.
sandbox:promptauto-starts a paused instance. Prompting a named instance that was stopped (but kept) now restarts it — booting and arming the channel (~30–60s) before delivering the turn — instead of hanging against the powered-off VM. A pause discards the VM's RAM, so the live conversation does not survive it; only the pushed branch does, and the resumed agent begins a fresh session on top of its branch.
- Writable scratch is disk-backed, not RAM-backed. The writable
/nix/storeoverlay, the workspace clone (with build artifacts), the relocatedcargo/rustup/XDG caches, and the guest Nix database now live on per-instance sparse disk images instead of tmpfs. Capacity scales with host disk rather than a fraction ofmem, so a Rusttarget/can no longer exhaust guest RAM; the guest root/stays a tmpfs. A named instance keeps these images across a stop/restart, so warm build caches (and host-path registrations) survive a pause; ephemeral instances get fresh images each launch. importHostStoreDb: the guest Nix database now persists and is seeded once. On its own persistent volume, the host-DB snapshot is applied a single time per named instance (gated on a marker) and then accumulates the agent's in-VM registrations — keeping it consistent with the persistent store overlay across a restart, rather than re-seeding every boot.
storeOverlaySizeis replaced bystoreVolumeSize/scratchVolumeSize/dbVolumeSize. The old single tmpfs-size string is gone; the three new arguments size the disk images (in MiB, sparse). Defaults: 16384 / 32768 / 4096. SeeMIGRATING.md.
A sandbox-ergonomics release: instances are now numbered, and there is a
one-shot command to attach to a running agent's live session. Purely additive —
no consumer config changes; see MIGRATING.md.
sandbox:attach <instance|#>. A new menu command that SSHes into a running instance, pinsTERM=xterm-256colorin the remote session (so terminals like ghostty don't confuse the guest'stmux), and attaches the agent'skatsuobushitmux session — collapsing the ssh-then-tmux attachdance thatsandbox:status <instance>used to print by hand.- Numeric instance references.
sandbox:statusnow prints a leading#column numbering each instance, and that index is accepted anywhere a name is —sandbox:prompt,sandbox:status,sandbox:attach,sandbox:fetch, andsandbox:stopall resolve an all-digit argument as a 1-based index into the current listing. The numbering is positional (it can shift as instances come and go); names remain the stable handle. Real instance names always carry a-from their timestamp or hex suffix, so a name is never mistaken for an index.
sandbox:statuslisting gains a#column. The instance table now leads with a 1-based index; anything parsing that table by column position should account for the extra leading field.
A sandbox release. One default-on behavioral change — the guest now reuses the
host's Nix store instead of re-downloading what the host already built — that is
transparent in normal use; see MIGRATING.md.
lib.sandbox:importHostStoreDboption. A new argument (defaulttrue) that makes the guest reuse everything the host has already built instead of re-downloading it. The guest already mounts the host/nix/storeread-only, but microvm registers only the guest's system closure as valid, so other host paths (e.g. anix developtoolchain) were present on the mount yet re-substituted from the network. The runner now snapshots the host'sdb.sqliteat launch (a consistent SQLite.backup, ~0.5s) into the share, and a guest boot service transplants it over the system-only DB — after microvm's own closure registration — so every host-built path becomes valid with no network and no copying. The transplant is best-effort: a missing snapshot or a host/guest Nix schema mismatch rolls back to the system-only DB, so a sandbox always boots. No new read exposure — the whole host store was already readable over the mount. SetimportHostStoreDb = falseto opt out.
lib.sandbox(this repo's own config): allowliststatic.rust-lang.org. Dropping intonix developinside the sandbox provisions the Rust toolchain via rust-overlay, which fetches fromstatic.rust-lang.org; that host was missing from the egress allowlist. WithimportHostStoreDbon, the toolchain is reused from the host offline, so this is only the fallback for picking up a newly bumpedrust-toolchain.toml.
A docs-and-features release; nothing to migrate (see
MIGRATING.md).
lib.menu.makeMenu:colorizeGraphicoption. A new optional argument (defaulttrue, preserving current behavior) controls whether the ASCII art banner is run through the colorizer. SetcolorizeGraphic = falseto print the banner raw while still colorizing the title and command table. Has no effect when no banner is set.lib.menu.makeMenu:graphicFileoption. A new optional argument (defaultnull) supplies the banner from a file path that iscated at runtime rather than inlined as a string. This keeps raw bytes — notably ANSI escape (U+001B) sequences in pre-colorized terminal art — out of theshellHook, whichnix developwould otherwise reject when serializing the shell environment to JSON. Takes precedence overgraphic; pair withcolorizeGraphic = falseto preserve the art's embedded colors. Katsuobushi's own banner now ships as pre-colorized pixel art (hero.ansi) through this path.
lib.sandbox:sandbox:*menu descriptions trimmed to short summaries. The dev-shell menu entries forsandbox:start/prompt/status/fetch/stopdropped their inline usage hints (e.g.sandbox:fetch <instance>), leaving a one-line summary; full usage lives in thesandboxskill. Command names and behavior are unchanged.lib.sandbox:sandbox:statuspreflight names the OAuth token fix. WhenCLAUDE_CODE_OAUTH_TOKEN's host source is missing, theenvironment:report now appends arun 'claude setup-token'hint alongside the variable to export.
A skill-and-docs release; nothing to migrate (see
MIGRATING.md).
sandboxskill: fan out via sub-agents; refined jj landing guidance. The skill now drives parallel fan-out by giving each task its own sub-agent — each launches and drives its own--named VM todoneand returns its branch plus the agent'sdonesummary — while integration stays serial in the orchestrator. The jj landing step now anchors accepted work on the working-copy commit@(jj new <tip>) and leaves bookmark placement to the user, keeping landed work durable across the git imports the sandbox commands trigger. Touchesplugins/katsuobushi/skills/sandbox/SKILL.mdandlib/sandbox/README.md; no library change.
A docs-only release; nothing to migrate (see
MIGRATING.md).
sandboxskill: remove an instance once its work is accepted. The skill now directs tearing the sandbox down withsandbox:stop --remove <name>as soon as its unit of work is complete and accepted — both in the branch-landing workflow and in the lifecycle section — since thesandbox/<name>ref is the durable artifact, not the VM. No library change.
lib.sandbox: a provided--nameis suffixed with random entropy. At launch,--name foonow mints an instance namedfoo-<8 hex>(e.g.foo-a3f9c2d1), so every launch is a fresh, collision-free instance instead of a silent resume of an older same-named branch. The full suffixed name is printed at launch and bysandbox:stop; drive (prompt/status/fetch/stop) and resume with that full name. A name that already carries the 8-hex suffix is left as-is, so passing the printed name back is safe. SeeMIGRATING.md.
A docs-and-internals release; nothing to migrate (see
MIGRATING.md).
sandboxskill docs substantially revised — added the branch-landing / integration workflow, conflict-reconciliation-as-delegation guidance, and parallel fan-out notes.lib.sandbox:sandbox:statuspreflight internals refactored. The preflight now builds its report in a subshell and carries the problem count out via the subshell's exit status (the|| errs=$?is load-bearing underinherit_errexit). Observable behavior is unchanged from 0.1.1.
A docs-only release; nothing to migrate (see
MIGRATING.md).
sandboxskill docs reworked — clearersandbox:statusguidance, a note thatsandbox:*are dev-shell menu commands (nix develop -c sandbox:statusfrom outside the shell), and Prettier reflow.- **Markdown linting now covers
plugins/**/\*.md.** Repo-internal; no consumer impact.
lib.sandbox:sandbox:statuspreflight. A baresandbox:statusnow prints anenvironment:block before listing instances, verifying every declared secret at its host source (thefromEnvvariable is set, or thefromFilepath is readable) and checking for/dev/vhost-vsock. It names the exact host variable feeding each guest secret, so "is this host ready to launch?" is a single command with no project-specific knowledge required.
lib.sandbox:sandbox:statusexits non-zero when the preflight fails. Previously the bare command always exited0; it now exits with the count of missing prerequisites, so its exit status alone is a usable gate. SeeMIGRATING.md.- Docs: clarified that the guest always reads
CLAUDE_CODE_OAUTH_TOKENwhilesecrets.*.fromEnvchooses which host variable supplies it, and documented the agent-harness workaround (a harness scrubsCLAUDE_CODE_OAUTH_TOKENfrom its children, so source it from a differently-named host variable, e.g.HARNESS_OAUTH_TOKEN). Toucheslib/sandbox/README.md, thesandboxskill, and thesandboxtemplate.
lib.sandbox: the guest can push to the 9p sync mirror. The per-instance bare mirror is now shared over 9p withsecurity_model=mapped-xattr(wassecurity_model=none), so files the guest creates are recorded as agent-owned. The unprivileged agent could previously never write its receive-pack quarantine dir, sogit pushfailed and no work crossed the sandbox boundary. The mirror's pre-existing directories are also opened so the agent can create entries inside them.
The first tagged release. Highlights below; consumer-facing migration notes for
everything tracked on untagged main up to this tag are in
MIGRATING.md.
lib.sandbox— a new library that assembles amicrovm.nixguest which boots into a working dev environment where an agent harness (Claude Code by default) runs with its blast radius bounded by a real VM. Providesapps.sandbox(nix run .#sandbox), thesandbox:*menu commands (start,prompt,status,fetch,stop),checks.sandbox, andnixosConfiguration. Scaffold withnix flake init -t github:cdata/katsuobushi#sandbox.sandboxtemplate andsandboxagent skill for the above.rusttemplate for scaffolding Rust projects.- Transitive infra dependency inheritance. Katsuobushi now owns
crane,nix-filter,rust-overlay, andmicrovm, passing them through to consumers so alib.rustconsumer flake collapses from six inputs to two. lib.rust:targetargument onbuildCrate/buildTestArchivefor cross-compiling to arbitrary triples;sourceIncludeargument for crates that do not live underrust/.
lib.markdownnow uses Prettier instead ofrumdl, which mishandled GFM tables. Scope is nowinclude/excludeglob lists plus anamelabel (replacingdocsDir);settingstakes Prettier options; outputs and menu commands are namespaced per invocation (format:<name>/lint:<name>).lib.rustinput arguments renamed to match nixpkgs vocabulary:buildInputs→nativeBuildInputs(build tools) andlibraries→buildInputs(link libraries); both now default to[ ].lib.rustwasm-bindgen version is derived fromCargo.lockrather than hard-pinned, failing fast with a copy-pasteable fix on a mismatch. Default hashes ship for0.2.108.lib.rustcrate version is derived fromCargo.tomlinstead of a hardcoded0.1.0; derivation name prefix derives fromprojectId.
See MIGRATING.md for the full upgrade details.