mcp: first-class worktree support — every call names its workspace - #652
Open
oyvindberg wants to merge 28 commits into
Open
mcp: first-class worktree support — every call names its workspace#652oyvindberg wants to merge 28 commits into
oyvindberg wants to merge 28 commits into
Conversation
… stale
One MCP server serves a whole Claude session, including subagents working in
other git worktrees. The server pinned one workspace at boot, so a subagent's
compile silently built the parent checkout — green result, wrong code — and
the server couldn't detect it: tool calls carry project names, not paths, and
MCP tells the server nothing about the caller's cwd.
So the caller states it. Every tool that acts on a build now requires
`directory`, and the server holds no ambient workspace at all: each call
bootstraps fresh from that directory's bleep.yaml (CLI semantics, CLI cost),
opens its own connection to the shared compile daemon, and disconnects.
Boot is workspace-free — `bleep mcp-server` starts from any directory, which
makes one user-scoped registration (`claude mcp add --scope user bleep`)
serve every checkout and worktree.
Statelessness deletes whole categories of invalidation logic rather than
fixing them:
- the bleep.yaml file-watcher and build reload path: a fresh bootstrap per
call sees config edits (including JVM changes) with no machinery at all
- the persistent BSP connection and its reconnect-on-death manager
- watch jobs (bleep.watch/sync/watch.stop): background fibers holding
connections into a daemon whose idle accounting counts connected clients
- the "last build" result cache and its diff-vs-previous responses, which
went stale the moment anyone compiled outside MCP
What replaces the result cache is a RequestLog keyed by explicit requestId:
compile/test return a compact summary plus their id, and the new
bleep.details tool returns that run's full transcript — every diagnostic,
every stack trace, paginated. A transcript describes its own run, never
current state, so it cannot go stale; retention is a bounded ring of the
last 32 requests. The verbose flag dies with the cache: details are always
one call away instead of a re-run away.
A build declaring a different $version than the running server fails loudly
with the fix in the message (install matching binary, call bleep.restart) —
the wire protocol between client and daemon is version-locked, so serving it
would be a lie. The JVM leg needs no such gate: daemons are keyed by
bleep version + JVM + options, and per-call bootstrap routes each workspace
to its matching daemon, spawning it if needed.
Verified over real stdio JSON-RPC: 14 tools listed with directory required
in the schema; a call with a valid directory bootstraps and answers; a
relative path and a missing directory both fail with named errors; details
without history and with an unknown id name exactly what is wrong.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…arned The "Built for the agents" section described the previous MCP server: 18 tools, in-process claims, diff-against-previous-run responses. Rewritten around what actually ships now: subagents fanning out into git worktrees as the design center, one user-scoped registration covering every checkout on the machine, every call naming its target so the wrong-checkout build cannot happen, one shared daemon deduplicating analyses across worktrees, and summary-plus-requestId responses whose transcripts cannot go stale. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…hem grep Given a long build log, an agent's reflex is to divert it to a file and grep it — losing the structure and, often, the answer. bleep.details gains a `query` parameter: a case-insensitive regex matched against each diagnostic's message, rendered text and path (compile) or each failure's project, suite, test, message and stack trace (test). Only matching items are returned, still as structured data; summary counts keep describing the full run, and the response echoes the query. An invalid regex fails with the parse error, and searching composes with project/limit/offset. Also sharpens the docs and front page on why compact structured output matters for agents: it is not merely fewer tokens, it removes the divert-grep-and-miss failure mode entirely. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The page sold build-as-data, fail-hard, and the fast inner loop to humans tired of build tools, while the same features are the strongest arguments for the visitor who now ships through agents. Re-aim rather than invent: - hero names agents and gains a "Bleep for agents" button anchoring to the MCP section (#agents) - the specimen, inner-loop and CI sections each get one connective line: agents can safely edit a data build file, an agent compiles fifty times a session, and fail-hard means nothing degrades silently - the agents section gains its number (~200 tokens instead of a 30,000-token build log), a five-line terminal vignette of four subagents working four worktrees, and the dogfood line: bleep is developed by parallel agents in worktrees, building bleep with bleep - the install CTA becomes two pasted lines — curl install plus `claude mcp add --scope user` — with per-line copy buttons - page description mentions MCP-native and agentic development, since people search for exactly that and no credible result exists yet Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The compile/test summaries said "2 errors in 1 projects" and "All 1 tests passed" — and the docs example faithfully quoted the bug. Adds a plural helper and uses it for projects, errors, warnings and tests. Landing page: the local-build-cache link now points at the #local-directory-cache anchor; the agents lede no longer says "built for this shape" twice; the flailing-agent sentence gets real verbs; the vignette's bare "warm" becomes "instant"; "matters double" becomes "matters more than ever". Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The stat and vignette now carry the mechanics, so each card keeps only what nothing else on the page says: zero-setup worktrees, one shared hot daemon plus the local cache, and searchable structured results. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…daemon's locks
A fresh git worktree has its parent's sources and none of its compiled state,
so its first build recompiles the world. Content-addressed caching cannot fix
the interesting part: digests are transitive, so one dirty edit in a base
project makes every downstream project a cache miss — while zinc's invalidation
cone for the same edit is a few files, because it keys on API hashes. What a
fork actually wants is its parent's exact state as an incremental baseline.
`bleep copy-state <parent>` (MCP: `bleep.copy-state { directory, from }`)
provides exactly that, and only that: state is copied from the worktree you
forked off and nowhere else. What you start with is exactly what your parent
had at the moment of copying — dirty-file analyses included, which is the
baseline that makes the first compile touch a few files rather than a few
hundred. No cache tiering, no nearest-match heuristics, nothing to explain
after the fact.
The copy runs in the compile daemon on purpose. Ask-then-clone is a TOCTOU
race — another agent can start compiling the parent between the asking and
the cloning — so the daemon takes the same per-project locks compiles take
(shared on each source project, one at a time, in the same global order) and
the race is closed rather than narrowed. bleep/copyState joins bleep/status
and bleep/shutdown on the admin surface: no build/initialize, workspaces
named by absolute path, neither build needs to be resolved — projects are
enumerated from disk.
Per project, holding the lock: classes and test-classes, the zinc analysis
(byte-portable by design, and byte-identical to one already resident, so the
daemon's cross-workspace analysis sharing serves the fork for free), and
generated sources/resources. Deliberately NOT copied: noop-manifest.bin — its
keys are absolute paths into the parent, and a copied manifest VALIDATES
against them, yielding a false noop that points the fork at the parent's
classes. The fork's first compile does one zinc round-trip per project and
writes its own manifest. Also skipped: .zinc/cache, ksp/, and .bleep-lock.
Transport is CloneDir, chosen per OS up front with no fallback chain:
`cp -Rc` on macOS (APFS clonefile), `cp -a --reflink=auto` on Linux, a JVM
recursive copy elsewhere. The `.zinc`/`analysis.zip` path layout moves into
BuildPaths (zincDir/zincAnalysisFile, generated*BaseDir) instead of being
respelled at each call site.
Tested on the real filesystem so CI proves every transport lane: cloning is
byte-faithful and clones are independent copies; copy-state waits for an
exclusively-locked (mid-compile) project and completes when released; the
manifest and lock file never travel; target-with-state, empty source,
from==to and non-root directories all refuse loudly. The test fixture drives
raw JSON-RPC over in-memory pipes — java.io.Piped* throws spurious "Pipe
broken" when responses arrive from short-lived fiber threads — and reads
Content-Length as bytes, not chars, which matters the moment an error
message contains a multi-byte character.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…don't list Benchmarked on dlab (130 projects): a flat listing of .bleep/projects/ copied only the 13 flat-named projects and silently skipped every `dfmt/main`-style cross name, whose value contains a slash and therefore nests. Enumerate by walking for dirs containing builds/<variant> and derive the cross name from the relative path. Endpoint test now covers a nested name. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…t purge inherited output
bleep/copyState seeds a fresh worktree from its parent, but the parent is a
working tree: its compiled state can be AHEAD of git — an uncommitted edit that
was compiled. The clone carries that dirty output into a workspace whose
sources never contained it. This test pins, through real compiles, that the
design's load-bearing half actually holds: zinc reconciles the target's actual
sources against the inherited analysis on the first compile.
Scenario (verified manually on a real repo before this test existed):
workspace A compiles core+app, app gets an uncommitted marker method
(copyStateDirtyMarker12345) and is recompiled, so A's classes AND analysis
contain the marker. Workspace B is created with the ORIGINAL sources,
bleep/copyState clones A's state into B (asserted: the marker is now
byte-for-byte present in B's App.class), then B compiles. Assertions:
(1) app — whose source differs from the inherited analysis — recompiles,
while core — whose source matches — noops (classfile mtime and bytes
untouched by B's compile);
(2) after B's compile the marker is GONE from B's App.class. This is the
load-bearing assertion: the inherited classfile declares a method B's
sources never wrote, and anything compiled against stale inherited
output links against phantom API. If zinc ever trusted inherited
output — say a copied noop manifest validating against the parent's
absolute paths — this is the assertion that catches it;
(3) B's compile succeeds.
Built on the two patterns that already exist: BspTestHarness drives the real
MultiWorkspaceBspServer through build/initialize + buildTarget/compile (as the
other real-compile integration tests do), and bleep/copyState goes over the
same connection as a raw JSON-RPC admin request (as CopyStateEndpointTest
does — it needs no BSP handshake). The harness gains a rawRequest method for
the latter; CopyStateEndpointTest keeps owning the endpoint's own contract
(locking, refusals, what is and isn't copied).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tes through copied analyses
The first dirty-parent test edits the downstream project: it pins "dirty
project recompiles, clean dependency noops". This adds the direction it could
not see: the uncommitted edit lives in `core`, the UPSTREAM project, and what
must be proven is that invalidation PROPAGATES across the copied dependency
analyses rather than stopping at the project whose source differs.
Scenario: A compiles core+app clean, then core gains an API edit app is
sensitive to — an added overload of `coreValue`, the very name app calls
(zinc's name-hashing invalidation only invalidates dependents that USE an
affected name, so a marker method alone would leave app untouched) — plus a
marker method (coreApiMarker67890), and A recompiles. B is created with the
ORIGINAL sources and seeded via bleep/copyState. B's compile must then:
- recompile core and purge the marker+overload from B's Core.class
(byte-searched, same as the first test);
- recompile app EVEN THOUGH app's own source is identical to what the
inherited analysis recorded — the core API it was compiled against no
longer exists. If the copied analyses carried cross-project API hashes
wrongly, app would silently keep linking against the phantom overload.
Asserted via classfile mtime, not bytes: app's source never changed, so
a correct recompile is byte-identical — the rewrite is the observable.
Not vacuous by construction: the sibling test proves an uninvalidated seeded
project's classfile mtime stays put across B's compile, so app's moving mtime
here can only mean propagation, not a blanket recompile-everything.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…as a checked-in skill The flow (sourcegen → compile bleep-cli → publish local-ivy → native-image → verify → install → cycle daemons) runs several times a week and every one of its failure modes has cost real time at least once: GenNativeImage silently imaging stale classes, version skew from a dirty tree, cp breaking the macOS signature (SIGKILL with no output), stop-all missing orphaned daemons from older versions, and long-lived mcp-server processes pinning old jars. Captured as a project skill so the procedure and its gotchas live in the repo rather than in one person's session memory. Verified end-to-end today deploying 1.0.0-M12+15-b322f6f4-SNAPSHOT. .gitignore: .claude/ becomes .claude/* with !.claude/skills/ — per-user Claude state (settings.local.json, session data) stays ignored, shared skills don't. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… connection Observed during the first real run of the skill: the restart tool reports "available again in a few seconds", but from the calling session's point of view the tools are delisted the moment the process exits, and the respawn is lazy — it took several use attempts and a turn boundary before the connection came back. /mcp reconnects immediately; new sessions always pick up the new binary via the user-scope registration. So the skill now says what the recipe implied but never stated: bleep.restart goes LAST, after all MCP-dependent work, with the CLI as the fallback for the rest of the turn. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…mechanically and temporally The RequestLog already stores the complete event stream per request — per-test statuses (all nine: passed/failed/error/skipped/ignored/cancelled/ assumption-failed/pending/timeout) with durations and messages, per-project CompilationReason with invalidated files, diagnostics on CompileFinished — and bleep.details rendered none of it beyond failures. This turns that stored stream into the primitive of the agent loop: edit, rerun, "what changed?". Two tools, deliberately separate: bleep.diff(base, target) is the MECHANICAL diff. It is computed over a projection of the transcript that contains no time — not "durations zeroed out", they never enter the compared data structures — so two runs with the same logical outcome diff as identical no matter how timings jittered. Tests keyed by (project, suite, test): newlyFailing / fixed / newlySkipped (with the assume reason) / unskipped / added / removed, and stillFailing listed as context with a messageChanged flag (same failure vs different failure — only the latter counts as a difference). Scope drift shows as added/removed, never as fixed: a failing test that left the scope is not a fix. Compiles keyed by project: reason transitions (up-to-date→incremental), invalidated-file deltas, status changes, new/resolved diagnostics keyed by (severity, path, message) with line as attribute — a diagnostic that only moved lines is neither new nor resolved. Cross-workspace diffs are allowed and flagged: compile parent, copy-state, compile fork, diff — the "everything nooped except the dirty project" verification becomes one call. bleep.diff-timing(base, target) owns durations: per-item deltas above max(50ms, 20% of base) as slower/faster (jitter suppressed and counted), plus slowestInTarget for the absolute question. Old diff-vs-last caches went stale the moment anyone compiled outside MCP; diffing two immutable ring entries by explicit requestId cannot. 14 scenario tests in bleep-tests/src/scala/bleep/mcp/RequestDiffTest.scala, the first of which pins the load-bearing property: identical outcomes with wildly different timings and event order diff as identical. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ability
The agents section was one section carrying two arguments: how parallel
agents in worktrees share one server, and how an agent finds out what its
edit actually did. With bleep.diff and bleep.diff-timing landed there is
too much of the second argument to keep them fused.
Section one keeps the #agents anchor and the fan-out vignette: one
user-scope registration, every call names its directory (a subagent can't
silently build the parent checkout, nothing pinned at boot to go stale),
copy-state so forks start warm, one hot daemon. Section two gets the
~200-tokens stat and a new vignette of the diff beat from a real session:
break a test and diff says "1 newlyFailing"; revert and the diff of first
vs last run is {"identical":true,"summary":"No logical differences."}
across ~800ms of timing noise. Cards: details' regex-searchable
transcripts, bleep.diff (durations never enter the compared data, so
timing can't fake a difference), bleep.diff-timing (jitter suppressed,
slowest named).
Migration flips to sectionPaper so backgrounds keep alternating with the
inserted section. Verified with a full docusaurus build; both anchors
render (the /#agents broken-anchor warning is docusaurus not seeing
JSX-set ids, and predates this change).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ity survives worktrees First stage of moving run history out of the MCP server's memory and into the build itself. bleep.requests gains: Transcript — the record of one completed compile/test request: header (id, workspace, variant, mode, targets, client) plus the full event stream and test-run result, circe-codecked for disk. Paths inside events stay ABSOLUTE: transcripts are grepped and clicked, and absolute + workspace can always derive relative while the reverse loses paths outside the workspace. TranscriptStore — <workspace>/.bleep/builds/<variant>/requests/<id>.json. Workspace-local on purpose: the socket dir dies with the daemon (stopping a daemon deletes metrics.jsonl history — lesson learned), while a worktree's history should live and die with the worktree. Ids are per-workspace monotonic, never reused; assignment happens under a file lock so racing writers cannot mint the same id; writes are tmp+rename atomic so readers never see torn files and take no lock. Retention: newest 32 entries, 64MB cap, oldest evicted first — noop transcripts kept deliberately, "everything up-to-date" being exactly what a copy-state verification diff wants to see. RequestDiff moves from bleep-cli's mcp package to bleep-core, now operating on Transcript, and gains the rule that makes cross-worktree diffs honest: identity relativization. Diagnostic identity was (severity, path, message) over absolute paths, so the same warning in a parent and its fork — same file, different root — would have counted as new + resolved. Now each side's paths, and occurrences of each side's workspace root embedded in message text, are relativized against that side's own root — ephemerally, for the comparison key only, never serialized and never shown. Paths outside any workspace (coursier, JDK) stay absolute for identity, which is correct on one machine. For single-workspace diffs the transformation is applied to both sides against the same root: an identity-preserving no-op, so the mechanical diff's determinism property is untouched (still pinned by the scenario suite, now 16 scenarios + 4 store tests in bleep.requests). The MCP server bridges its in-session ring into the shared model for now; the daemon-side store replaces that ring in the next stage. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…t caught a leak
copy-state and the remote cache each kept a hand-maintained notion of which
parts of a project's compiled state may cross the workspace boundary, and
they had already drifted: copy-state correctly skipped .zinc/cache, KSP
caches and lock files, while the remote cache's filter denied only
noop-manifest.bin BY NAME and shipped everything else in the variant dir —
zinc's classfile cache (absolute paths), per-machine KSP state, and even a
.bleep-lock if one existed at pack time.
bleep.StateSharing is now the single authority both consume: an ALLOW-list
over paths relative to the variant build dir — {classes/, test-classes/,
.zinc/analysis.zip} — with deny-by-default semantics, so state invented in
the future is workspace-private until someone consciously declares it
shareable, never leaked by omission. Request transcripts
(.bleep/builds/<variant>/requests/) are workspace-level and outside both
mechanisms' reach by construction; CopyStateEndpointTest now pins that a
seeded fork inherits neither them nor the zinc cache.
The layout-level version of this rule — physically separating shared/ from
local/ under the variant dir so the classification is enforceable by a
directory walk — is deliberately left for a follow-up: it breaks on-disk
layout (one cold build per workspace) and deserves its own change.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…eturns the id
Recording moves from the MCP server's client-side ring into the daemon
itself, at the one choke point every request event already passes through:
sendEvent. It now takes a per-request TranscriptRecorder — created at the
top of handleCompile/handleTest and threaded structurally through the whole
chain (compile handler, heap-pressure listener, lock-contention callbacks,
the DAG event consumers, sendTestEvent) — so what the client sees and what
the transcript stores are the same stream by construction. No connection-
or daemon-scoped mutable state; the recorder is a parameter with request
scope, thread-safe because events are produced concurrently.
At completion — success, failure or cancellation alike; a red run's
transcript is exactly what a diff wants — the daemon persists the stream
via bleep.requests.TranscriptStore under the workspace's own
.bleep/builds/<variant>/requests/, tagged with the client name from the
build/initialize handshake ("bleep", "bleep-mcp", "Metals", ...). Every
client now shares one durable per-worktree history.
SANCTIONED EXCEPTION to fail-loudly: a transcript-write failure must not
fail a build that already ran — the daemon logs and returns the result
without an id. Everything else still throws.
The id travels back in the response:
- compile: CompileResult dataKind "bleep-request-id", data
{"requestId": <id>} (RequestIdPayload in bleep-bsp-protocol)
- test: TestRunResult gains requestId: Option[Long] — None exactly when
the write failed, and in the copy embedded inside the transcript
itself, whose own id is authoritative. Missing-on-the-wire decodes as
None, so older daemons' responses keep decoding.
The noop fast path now emits CompilationReason(UpToDate) before returning:
without it a noop's transcript was just Started/Finished, indistinguishable
from a compile whose reason was lost — and two noop runs would not have
carried identical logical facts. With it, the flagship determinism property
holds end-to-end through the real daemon, pinned by
TranscriptStoreIntegrationTest: three compiles over the real protocol leave
requests/1..3.json, the responses carry the ids, a clean build vs a noop
diffs as exactly one reason transition, and noop vs noop diffs identical
while every duration differs.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…mon needed
Three new commands over the per-worktree history the daemon now writes,
all pure file reads via TranscriptStore (history survives daemon restarts
and is inspectable on a machine with no daemon at all):
bleep requests list recorded requests: id, time, mode,
targets, which client ran it
bleep details [id] full transcript as JSON (latest when omitted);
--project, --query (regex), --limit, --offset
bleep diff <base> <target> mechanical diff as JSON; --timing switches to
the jitter-suppressed duration comparison;
--limit N; --base-dir <path> resolves base in
another worktree's history (the copy-state
verification flow)
The details rendering moves mechanically out of the MCP server into
bleep-core as requests.TranscriptFormat, so `bleep details` and the MCP
`bleep.details` are one implementation that cannot drift; the MCP server
now delegates to it. New TranscriptFormatTest pins the contract: header
rides along, query/project narrow items while summary counts keep
describing the full run, bad regex fails loudly.
Compile/test summary lines gain the id when the response carried one:
ReactiveBsp decodes the compile response's "bleep-request-id" data (test:
TestRunResult.requestId) into a new BuildEvent.RequestRecorded, the
reducer folds it into BuildState/BuildSummary, and both summary renderers
print `Request: #N (bleep details N)`. Absence — older daemon, failed
write — simply means no line.
RequestsCliIT closes the loop end-to-end: `bleep new`, two compiles
through the (in-process, but production) BSP server, then asserts the
transcripts landed in the workspace tagged client="bleep", the summary
named both ids, and requests/details/diff (including --base-dir and
--timing) run over the files.
docs/reference/cli regenerated via `bleep gen-cli-docs` (this also picks
up the previously ungenerated copy-state page).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
bleep.compile / bleep.test report the daemon-assigned requestId from the BSP response (compile: the "bleep-request-id" data; test: TestRunResult.requestId) instead of minting their own. Streamed events are still collected — the compact summary and failure streaming need them — but nothing stores them: RequestLog, the transcriptOf bridge and the requestLog Ref are gone, and with them the last per-session state in the MCP server. A response without an id (transcript write failed, older daemon) simply omits the field. bleep.details / bleep.diff / bleep.diff-timing read TranscriptStore directly. DetailsArgs and DiffArgs gain the REQUIRED `directory` every other tool already has — request ids are per-workspace, so an id means nothing without saying whose history to look in — and DiffArgs gains optional `baseDirectory` for cross-worktree diffs (copy-state verification), riding on RequestDiff's path-relativized identity. These are pure file reads: BuildLoader.find + BuildPaths, deliberately no bootstrap and no daemon spawn. The path derivation is shared with the CLI's --base-dir as commands.Requests.workspacePaths, which now fails loudly when the directory holds no bleep build instead of degrading into "no request with that id". Server scaladoc and instructions rewritten to match: history is per-worktree, written by the daemon, shared with `bleep requests` and IDEs, and survives MCP server restarts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
It lives in the worktree now — written by the compile daemon, shared with the CLI and IDEs, surviving MCP server restarts — and details/diff take `directory` like every other tool. Also list bleep.diff / bleep.diff-timing in the tool table; they existed but the page never mentioned them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The diff vignette showed the what; nothing on the page said why only bleep can do it. The answers section's eyebrow becomes "Data all the way down" and the lede walks the chain: bleep.yaml is data, the resolved model is immutable data, the daemon emits typed events, a completed run is a transcript written into the worktree — and values compose, so diffing two runs is a pure function over two files. Not a feature bolted on; it falls out of the architecture. The specimen section gets a forward pointer (the YAML is just the first layer), and a new footer under the diff cards surfaces the CLI side the page never mentioned: bleep requests / details / diff are pure file reads over the worktree's transcripts — no daemon, and --base-dir diffs across worktrees. Verified with a full docusaurus build; the /#agents and /#answers broken-anchor warnings are docusaurus not seeing JSX-set ids (known, predates this change). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… never depend on hash order CI caught a real one: the two cross-worktree scenarios failed on windows-latest because relPath/relMessage built the root prefix with File.separator — the separator of the machine READING the transcript, which is irrelevant to the machine that wrote it. On Windows the unix-style roots never matched, relativization never fired, and the same diagnostic under two roots compared as different. Both helpers are now separator-agnostic: everything normalizes to forward slashes for the ephemeral identity key (paths entirely; message text gets both spellings of the root replaced). The contract stays same-machine cross-WORKTREE identity — a Windows parent diffed against a unix fork is a cross-MACHINE diff and out of scope, since message tails keep their native spelling; the test pins Windows-vs-Windows and documents the boundary. And the order-independence audit the question deserved: the mechanical diff was already interleaving-safe (per-key maps make concurrent projects' event ordering invisible — now pinned by a permuted-stream scenario), but the timing diff broke ties by hash-set iteration order: two equal deltas or equal durations could render in different order on different runs. Ties now break on the item key, so the same two transcripts render byte-identical JSON every time — also pinned. The one ordering that remains is the one that should: diff(base, target) is directional by explicit input; ids record completion order, and the diff of two racing compiles' transcripts does not care who won the race. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The run-history surface grew piecewise and its names showed it: three flat
CLI commands (requests / details / diff), MCP tools under three different
prefixes, a wire dataKind saying "request", a package saying "requests",
and a response field requestId. One concept, five spellings. Nothing here
is released, so it is renamed outright — no shims, no aliases.
The namespace is `history`: `runs` collides with `bleep run` and `builds`
with `bleep build`; `show` follows the `bleep build show` precedent.
CLI bleep requests -> bleep history
bleep details [id] -> bleep history show [id]
bleep diff <a> <b> -> bleep history diff <a> <b>
MCP bleep.details -> bleep.history.show
bleep.diff -> bleep.history.diff
bleep.diff-timing -> bleep.history.diff-timing
(new) bleep.history.list -- the CLI listing as JSON, for parity
wire dataKind bleep-request-id -> bleep-history-id
TestRunResult.requestId -> historyId (and every response field)
disk .bleep/builds/<v>/requests/ -> .../history/ (BuildPaths.historyDir)
code package bleep.requests -> bleep.history
RequestDiff -> TranscriptDiff (it diffs transcripts;
Transcript/TranscriptStore keep their accurate names)
commands.Requests -> commands.History (ListEntries/Show/Diff)
BuildEvent.RequestRecorded -> HistoryRecorded
Build summaries print `History: #N (bleep history show N)`; store errors
say "No history entry #..." / "No history recorded ..." (retention hint
kept). The JSON-RPC message id in MultiWorkspaceBspServer is renamed rpcId
so it can never be confused with a history id again.
docs/reference/cli regenerated via `bleep gen-cli-docs` (requests/details/
diff pages replaced by history/{index,show,diff}); the MCP docs page and
the landing page follow (bleep-site builds clean); PR #652's body updated
to the new names.
Verified: full compile green; bleep-tests 422 passed (HistoryCliIT covers
the CLI loop end-to-end); bleep-bsp-tests 721 passed
(TranscriptStoreIntegrationTest pins the new dataKind and the history/
directory through the real daemon).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
oyvindberg
force-pushed
the
mcp-stateless-directory
branch
from
August 13, 2026 09:24
7e9cc78 to
282fb71
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Agentic development has a shape: an orchestrating session fans subagents out into git worktrees, each working a branch in parallel. Bleep's compile daemon was built for that shape — one process serving every checkout from shared, deduplicated state — but the layer agents actually talk to was not.
One idea runs under everything this PR ships: bleep realizes the build as immutable data, throughout.
bleep.yamlis a value, not a DSL. The resolved build model is immutable data passed structurally — no global state anywhere. Everything the daemon does while building is emitted as typed events (compilation reasons with invalidated files, diagnostics, per-test status/duration/reason). So it is only logical that a completed run is also a value — a transcript written to a file in the worktree — and that values compose like values: diffing two runs is a pure function over two files. "Build logs you can diff" is not a feature bolted on; it falls out of the architecture.The PR lands that arc in six pieces, in dependency order: a stateless MCP server where every call names its workspace;
bleep copy-stateso a forked agent's first build compiles only the diff; transcripts as a durable per-worktree history written once by the daemon and read by every client;bleep.history.diff/bleep.history.diff-timing— the "edit, rerun, what changed?" primitive over those transcripts; the same surface in the CLI (bleep history/history show/history diff), no daemon required; and one allow-list deciding what compiled state may leave a workspace at all. By the end, the MCP server's "one thing kept in memory" is nothing.1. Stateless MCP server: every call names its workspace (4d15dbd)
One MCP server serves a whole Claude session, including subagents working in other git worktrees. The server used to pin one workspace at boot, so a subagent's compile silently built the parent checkout — green result, wrong code — and the server couldn't detect it: tool calls carry project names, not paths, and MCP tells the server nothing about the caller's cwd. Any fix based on detection is impossible; the only honest design is to make the caller state its location, which it always trivially knows.
So every tool that acts on a build now requires
directory, and the server holds no ambient workspace at all: each call bootstraps fresh from that directory's bleep.yaml (CLI semantics, CLI cost), opens its own connection to the shared compile daemon, and disconnects.directoryisrequiredin every tool schema, so the wrong-checkout build is not guarded against — it is unrepresentable. Boot is workspace-free, so one user-scoped registrationserves every checkout and every worktree: no per-checkout
.mcp.json, no seeding ongit worktree add, nothing to clean up ongit worktree remove.Statelessness deletes whole categories of invalidation logic rather than fixing them:
bleep.watch/sync/watch.stop) — background fibers holding connections into a daemon whose idle accounting counts connected clientsA build declaring a different
$versionthan the running server fails loudly with the fix in the message (install matching binary, callbleep.restart) — the client↔daemon wire protocol is version-locked, so serving it would be a lie. The JVM leg needs no such gate: daemons are keyed by bleep version + JVM + options, and per-call bootstrap routes each workspace to its matching daemon, spawning it if needed.2. bleep copy-state: a fork starts warm (4a1a807, 665250b)
A fresh worktree has its parent's sources and none of its compiled state, so its first build recompiles the world. Content-addressed caching cannot fix the interesting part: digests are transitive, so one dirty edit in a base project makes every downstream project a cache miss — while zinc's invalidation cone for the same edit is a few files, because it keys on API hashes. What a fork actually wants is its parent's exact state as an incremental baseline.
bleep copy-state <parent>(MCP:bleep.copy-state { directory, from }) provides exactly that, and only that: state is copied from the worktree you forked off and nowhere else — dirty-file analyses included, which is the baseline that makes the first compile touch a few files rather than a few hundred. No cache tiering, no nearest-match heuristics.The copy runs in the compile daemon on purpose. Ask-then-clone is a TOCTOU race — another agent can start compiling the parent between the asking and the cloning — so the daemon takes the same per-project locks compiles take, and the race is closed rather than narrowed. Per project, holding the lock: classes and test-classes, the zinc analysis (byte-portable by design, and byte-identical to one already resident, so the daemon's cross-workspace analysis sharing serves the fork for free), and generated sources/resources. Deliberately NOT copied:
noop-manifest.bin— its keys are absolute paths into the parent, and a copied manifest validates against them, yielding a false noop that points the fork at the parent's classes. Transport is APFS clonefile on macOS,--reflink=autoon Linux, a JVM recursive copy elsewhere — chosen per OS up front, no fallback chain.665250b fixed enumeration for hierarchical project names (
dfmt/main-style cross names nest their state dirs): walk for dirs containingbuilds/<variant>instead of listing flat — found on a real 130-project repo where the flat listing silently copied 13 projects and skipped the rest.The dangerous case, tested end-to-end (2b77392, b322f6f)
The parent is a working tree: its compiled state can be ahead of git — an uncommitted edit that was compiled — so the clone carries output into a workspace whose sources never contained it. Two integration tests drive real compiles through the BSP server to pin that zinc reconciles the fork's actual sources against the inherited analysis:
App.class). B's compile recompiles app, noops core (classfile mtime and bytes untouched), and afterwards the marker is gone from B'sApp.class. That byte-level assertion is the load-bearing one: the inherited classfile declares a method B's sources never wrote, and if zinc ever trusted inherited output, this is the assertion that catches it.core— an added overload of the very name app calls (zinc's name-hashing invalidation only invalidates dependents that use an affected name), plus a marker. B's compile must purge both fromCore.classand recompile app even though app's own source matches the inherited analysis — the core API it was compiled against no longer exists. Asserted via classfile mtime (a correct recompile of unchanged source is byte-identical), made non-vacuous by the sibling test proving uninvalidated seeded projects' mtimes stay put.CI runs these on the real filesystem on every platform, so every clone transport lane is proven, alongside the endpoint's own contract tests: locking (copy-state blocks on an exclusively-locked mid-compile project and completes on release), manifest/lock-file exclusion, and loud refusals for every invalid input.
3. Transcripts live in the worktree (bef2ba8, e906dcf, fdcbaef)
What replaced the deleted result cache went through two stages. The first was an in-server
RequestLog— a bounded ring keyed by explicit requestId. That already fixed staleness (a transcript describes its own run, never current state, so it cannot go stale), but it was still per-process memory: invisible to the CLI and IDEs, gone on restart, blind to compiles that didn't pass through MCP. The architecture points somewhere better: run history is a property of the worktree, not of any client. So:Transcriptis a value in bleep-core (bef2ba8,bleep.history): a header (id, workspace, variant, mode, targets, client) plus the full typed event stream and test-run result, circe-codecked for disk. Paths inside events stay absolute — transcripts are grepped and clicked, and absolute + workspace can always derive relative while the reverse loses paths outside the workspace.The daemon writes it, once, for every client (e906dcf). Recording happens in the daemon at the one choke point every request event already passes through —
sendEvent— via a per-requestTranscriptRecordercreated at the top of handleCompile/handleTest and threaded structurally through the whole chain. No connection- or daemon-scoped mutable state; what the client sees and what the transcript stores are the same stream by construction. At completion — success, failure or cancellation alike; a red run's transcript is exactly what a diff wants — the stream is persisted to.bleep/builds/<variant>/history/<id>.jsonin the workspace, tagged with the client name from the BSP handshake ("bleep", "bleep-mcp", "Metals", …). Every client shares one durable per-worktree history that survives daemon restarts, MCP-server restarts, and inspection on a machine with no daemon at all. Ids are per-workspace monotonic, minted under a file lock so racing writers cannot collide; writes are tmp+rename atomic so readers never see torn files and take no lock; retention is newest-32 / 64MB, noop transcripts kept deliberately — "everything up-to-date" is exactly what a copy-state verification diff wants to see. The id travels back in the BSP response (compile:CompileResultdataKindbleep-history-id; test:TestRunResult.historyId). One SANCTIONED EXCEPTION to fail-loudly: a transcript-write failure must not fail a build that already ran — the daemon logs and returns the result without an id. Everything else still throws.The noop fast path now emits
CompilationReason(UpToDate)before returning: without it a noop's transcript was just Started/Finished — indistinguishable from a compile whose reason was lost, and two noop runs would not have carried identical logical facts. With it, the flagship determinism property holds end-to-end through the real daemon, pinned byTranscriptStoreIntegrationTest: three compiles over the real protocol leavehistory/1..3.json, the responses carry the ids, clean-vs-noop diffs as exactly one reason transition, and noop-vs-noop diffs identical while every duration differs.The MCP server now holds nothing (fdcbaef).
bleep.compile/bleep.testreport the daemon-assigned id instead of minting their own;bleep.history.show/bleep.history.diff/bleep.history.diff-timingreadTranscriptStoredirectly — pure file reads, deliberately no bootstrap and no daemon spawn.RequestLog, the bridge, and the lastRefin the server are deleted: the answer to "what does the MCP server keep in memory?" is nothing. history.show/diff gain the requireddirectoryevery other tool has — ids are per-workspace, so an id means nothing without saying whose history to look in — and diff gains optionalbaseDirectoryfor cross-worktree comparisons.bleep.history.showis unchanged in spirit: expand any past run — every diagnostic, every stack trace, paginated, and searchable with a regexqueryover messages, paths, suite/test names and stack traces (agents grep, so give them grep — server-side, over structured data, returning exactly the matching items). Theverboseflag stays dead: details are always one call away instead of a re-run away.4. bleep.history.diff and bleep.history.diff-timing: "what changed?" (0cb899c, bef2ba8)
A transcript stores the complete event stream per request — all nine per-test statuses with durations and messages, per-project compilation reasons with invalidated files, diagnostics — and this turns that stored stream into the primitive of the agent loop: edit, rerun, "what changed?". Two tools, deliberately separate:
bleep.history.diff(base, target)is the mechanical diff. It is computed over a projection of the transcript that contains no time by type — not "durations zeroed out", they never enter the compared data structures — so two runs with the same logical outcome diff as identical no matter how timings jittered. Tests keyed by (project, suite, test): newlyFailing / fixed / newlySkipped (with the assume reason) / unskipped / added / removed, and stillFailing listed as context with a messageChanged flag (same failure vs a different failure — only the latter counts as a difference). Scope drift shows as added/removed, never as "fixed": a failing test that left the scope is not a fix. Compiles keyed by project: reason transitions (up-to-date→incremental), invalidated-file deltas, status changes, and new/resolved diagnostics keyed by (severity, path, message) with line as an attribute — a diagnostic that only moved lines is neither new nor resolved.Cross-worktree diffs are allowed, flagged, and made honest by identity relativization (bef2ba8): diagnostic identity over absolute paths would count the same warning in a parent and its fork — same file, different root — as new + resolved. Now each side's paths, and occurrences of each side's workspace root embedded in message text, are relativized against that side's own root — ephemerally, for the comparison key only, never serialized and never shown. Paths outside any workspace (coursier, JDK) stay absolute for identity, correct on one machine. For single-workspace diffs the transformation is applied to both sides against the same root: an identity-preserving no-op, so the determinism property is untouched. Compile parent, copy-state, compile fork, diff with
--base-dir— the "everything nooped except the dirty project" verification becomes one call.bleep.history.diff-timing(base, target)owns durations: per-item deltas above max(50ms, 20% of base) as slower/faster, jitter suppressed and counted, plus slowestInTarget for the absolute question. The old diff-vs-last cache went stale the moment anyone compiled outside MCP; diffing two immutable transcripts by explicit historyId cannot.16 scenario tests + 4 store tests in
bleep-tests/src/scala/bleep/history/, the first of which pins the load-bearing property: identical outcomes with wildly different timings and event order diff as identical — andTranscriptStoreIntegrationTestpins the same property end-to-end through the real daemon.From the deployed binary — break a test, rerun, diff:
{"identical":false,"summary":"1 newlyFailing","newlyFailing":[{...,"from":"passed","to":"failed","message":"\"expected 2, got [3]\" was not equal to \"expected 2, got [4]\""}]}revert, rerun, diff the first run against the last:
{"identical":true,"summary":"No logical differences."}despite ~800ms of wall-clock difference between the two runs — and
bleep.history.diff-timingon the same pair suppressed all 14 per-test deltas as jitter (116ms → 120ms total).5. The same history from the terminal (18c1d70)
Because transcripts are files in the worktree, the CLI surface is pure reads — it works with no daemon running and no MCP session anywhere:
bleep history— list recorded runs: id, time, mode, targets, which client ran itbleep history show [id]— full transcript as JSON (latest when omitted);--project,--query(regex),--limit,--offsetbleep history diff <base> <target>— the mechanical diff;--timingswitches to the jitter-suppressed duration comparison;--base-dir <path>resolves base in another worktree's history (the copy-state verification flow)The details rendering moved out of the MCP server into bleep-core as
history.TranscriptFormat, sobleep history showandbleep.history.showare one implementation that cannot drift (contract pinned byTranscriptFormatTest). Compile/test summary lines print the id when the response carried one —History: #N (bleep history show N)— so a human sees the same handle an agent would use.HistoryCliITcloses the loop end-to-end:bleep new, two compiles through the production BSP server, transcripts land tagged client="bleep", the summary names both ids, and history list/show/diff (including--base-dirand--timing) run over the files.6. One allow-list decides what leaves a workspace (ef5997b)
copy-state and the remote cache each kept a hand-maintained notion of which parts of a project's compiled state may cross the workspace boundary, and they had already drifted: copy-state correctly skipped
.zinc/cache, KSP caches and lock files, while the remote cache's filter denied onlynoop-manifest.binBY NAME and shipped everything else in the variant dir — zinc's classfile cache (absolute paths), per-machine KSP state, and even a.bleep-lockif one existed at pack time.bleep.StateSharing(bleep-model) is now the single authority both consume: an ALLOW-list over paths relative to the variant build dir —{classes/, test-classes/, .zinc/analysis.zip}— with deny-by-default semantics, so state invented in the future is workspace-private until someone consciously declares it shareable, never leaked by omission. History transcripts are workspace-level and outside both mechanisms' reach by construction;CopyStateEndpointTestnow pins that a seeded fork inherits neither them nor the zinc cache. The layout-level version of this rule — physically separating shared/ from local/ so the classification is enforceable by a directory walk — is deliberately left for a follow-up: it breaks on-disk layout and deserves its own change.Why an MCP layer at all
The daemon does the heavy lifting, and agents could shell out to the CLI. The MCP layer earns its existence on three things:
bleep.compilewhile still gatingbleep.clean. Bash invocations get no such structure.Also in this PR
The whole run-history surface lives under one namespace: CLI
bleep history/bleep history show/bleep history diff, MCPbleep.history.list/bleep.history.show/bleep.history.diff/bleep.history.diff-timing, wire dataKindbleep-history-id, fieldhistoryId, on-disk.bleep/builds/<variant>/history/.historybecauserunscollides withbleep runandbuildswithbleep build;showfollows thebleep build showprecedent.bleep.history.listis new — the CLI listing as JSON, for parity./deploy-localas a checked-in skill (eab72f2, f77c312): the local snapshot deploy recipe, executable, including the gotcha thatbleep.restartsevers the calling session's own MCP connection.The landing page tells this story in two sections — orchestration (one server, every worktree; forks start warm) and observability (the build that answers questions) — and sells the architecture behind it by name: data all the way down, from
bleep.yamlto diffable runs, with the CLI surface alongside MCP (cdcaeae, 74625dd).The MCP docs page stops claiming run history lives in server memory, documents the per-worktree store, and lists
bleep.history.diff/bleep.history.diff-timingin the tool table (fc3cfc4).docs/reference/cliregenerated viableep gen-cli-docs— the new history pages plus the previously ungenerated copy-state page.Verified
Over real stdio JSON-RPC against a live daemon (locally published snapshot): every workspace-acting tool lists
directoryas required in its schema; a valid directory bootstraps, compiles, and answers with a summary + historyId; relative paths, missing directories, and unknown historyIds fail with named errors;bleep.history.showwith an invalid regex fails with the parse error, caret and all. The daemon-side store is pinned end-to-end byTranscriptStoreIntegrationTest(real protocol, ids in responses, determinism through the daemon), the CLI byHistoryCliIT(production BSP server, transcripts tagged and diffed over files), copy-state by the integration tests above plusCopyStateEndpointTest's state-sharing assertions, and the diff semantics by the 16-scenario suite. The demo output quoted here is verbatim from the deployed binary.🤖 Generated with Claude Code