magus v0.4.2 is out. See what's new
¶ View markdown source · ✎ Suggest an edit
17 min read

Event stream design notes

Why the event stream is shaped the way it is, and what building the first clients changed about it. Recorded 2026-08-27.

The request

Provide a mechanism third parties can build integrations against - an Emacs package, a Vim plugin, a status bar, a notifier - without magus shipping one integration per host.

What the research found

magus does not lack an integration mechanism. It has five partial ones that do not compose, and no integrator can be pointed at a single thing.

producer schema transport consumer today
internal/report {"schema":4,"type":"run.target.result",...} JSONL stdout of magus run -o jsonl CI post-process
internal/journal {ts,inv,kind,stream,text,ref} JSONL per-invocation file; loopback SSE with --open browser viewer
types.Event {schema_version,outcome,severity,source,where} session notify, attention store humans
internal/trail Kind + JSONL + blob refs /api/v1/activity Connect governance
daemon SSE graph seq, base64 proto Status, base64 OTLP /api/v1/events, bearer-gated console PWA

An editor plugin can already reach targets (-o json), Buzz completion (magus buzz lsp), file changes (magus watch), and per-target results carrying a fetchable ref. It cannot reach, without speaking protobuf and holding a bearer token: live output from a run it did not spawn, workspace-wide change notifications, or diagnostics.

So the work is not "add hooks". It is "pick one envelope and give it a subscribe verb". Everything underneath is built.

Naming

hook is taken. magus session hook is the agent-host guard adapter: stdin payload, verdict out. The outbound stream is named events, matching the plain register magus already uses for reads (refs, status, watch, query) rather than the thematic register reserved for engine concepts (spell, charm, ward).

The seam test this design has to pass

docs/scope.md seals the engine, the cache, the graph schema, and the guard's evaluation, and states the test for any new extension seam:

it may change what magus does, never what a verdict means.

An outbound stream passes cleanly: it reads a model magus already built, and no subscriber can alter an outcome. Inbound lifecycle callbacks - commands magus invokes mid-run - do not pass, because a post-target callback that touches outputs breaks the cache-replay contract. They are out of scope, deliberately, and this section is the record of that decision rather than a gap to fill later.

Direction: outbound only

magus emits; integrations consume and react. Extending build BEHAVIOR stays where it already is - spells, charms, the magusfile - which are declarations the sealed engine evaluates.

Relationship to magus session hook

They are duals, not the same mechanism, and fusing them would open the seam above.

session hook events
direction inbound outbound
shape request/reply, blocking stream, fire-and-forget
purpose change what happens (a verdict) inform
audience one agent host any number of subscribers

The unification that is free and correct is at the VOCABULARY, not the mechanism: the guard already writes KindAgentCommand / KindAgentSpawn into the trail, and those become guard.verdict events on the stream. session notify becomes attention.raised. The inbound mechanism keeps its sealed reply channel and additionally broadcasts what it decided, so a status bar can show a denial live without anything being able to influence one.

Transport: one contract, two transports, one front door

                  types.StreamEvent  (one envelope, one taxonomy)
                           |
                     stdout JSONL
                  magus events --follow
                           |
              <cacheDir>/runs/*.jsonl  (the bus)

The transport question answered itself. Every magus run already appends to <cacheDir>/runs/<inv>.jsonl, so a follower polling that DIRECTORY sees runs from any terminal with no daemon, no socket, and no token. The directory IS the bus.

internal/proc was the candidate before that: a JSONL-framed Unix socket in a 0700 directory where the filesystem permissions ARE the authentication, and proc.DiscoverSocket finds a live daemon with no env var. It lacks a subscribe frame - every call in internal/proc/client.go is request/reply - and adding one turns a control plane into a bus. That work was not done and is not needed: it would buy latency over a 250ms poll, and it would make the stream depend on a daemon the design deliberately does not require.

The CLI stays the front door because the daemon is optional by design (docs/scope.md names the daemon dependency as a standing strain) and because a subprocess pipe is available in every editor, while a Unix socket is not. There is no socket frame for this stream and no plan to add one until something needs the latency: an earlier draft of this section promised integrators "a documented frame", which was never built.

The taxonomy

SHIPPED. All four come from the run journal, which is what let the adapter stay a leaf with no dependency on the engine.

type from why an integration wants it
run.started journal KindStarted show a spinner, record lineage
run.finished journal KindFinished clear the spinner, report the outcome
target.result journal KindResult pass/fail/cached, with a fetchable ref
target.output journal KindOutput live log tailing (opt-in; high volume)

target.output is the one high-volume type and is off unless asked for. An editor that subscribes to everything by default drowns.

NOT SHIPPED, and deliberately absent from StreamEventTypes() rather than present and silent. Each has a store; none has an adapter. The cost column is what the review of this branch measured, not an estimate made while designing.

type store what it costs
diagnostic.emitted diagCollector, already fanning to report + the graph's runtime shard smallest: a new journal.Kind plus one adapter arm. file/line need a wider change - types.DiagnosticEvent carries only unit, code, message
guard.verdict trail agent_command, verdict in a payload blob the trail lives in activity/, not runs/, so this needs a second reader with an unrelated line schema and blob dereferencing. The hook is its own short-lived process, so inv would be empty
attention.raised sessions attention_open a third file, in XDG state rather than the cache dir. The store FLATTENS types.Event to strings on the way in, so the body cannot be reconstructed from it without a second record
workspace.changed none - magus watch persists nothing largest, and it breaks the design's central property: there is no store to adapt, so either a long-running watcher writes to runs/ (which is not what that directory means) or magus events grows a watcher and stops being a pure reader of the bus

An earlier draft of this table listed a target.started type sourced from KindExec. It was dropped - KindExec is per subprocess, not per target - and the row outlived the decision by several commits. It is recorded here because a taxonomy table that describes intentions as facts is the specific way this document went wrong.

What a first implementation covers

  1. types/streamevent.go - the envelope, the taxonomy, the per-type bodies. This is the contract, and it is the deliverable that has to be right.
  2. internal/eventstream - the adapter mapping journal records onto StreamEvent, plus the cross-process follower over the run-log directory. Journal is the ONLY producer adapted; the table above says what the others cost. The existing producers keep their on-disk schemas; nothing is rewritten.
  3. magus events - replay plus --follow, --type, --limit.
  4. internal/proc - the events.subscribe frame and the daemon-side bus. NOT built: the run-log directory turned out to serve as the bus without it, so this is a latency optimization rather than a requirement.
  5. A reference client living beside docs/guides/integrations/ the way the OpenCode plugin does - a template the reader owns and edits, per the "the host wiring is yours" entry in docs/doctrine.md. Shipped as POSIX sh; see Reference clients for why, and for what is still held back.

Open questions

  • Retention. magus events with no --follow replays recent events; from where? The journal store is per-invocation and already rotated; the trail caps at 10000 and rotates on a schedule. A replay window that spans producers needs one answer, and inventing a sixth durable log to get it would be the wrong one.
  • target.started was dropped: journal emits KindExec per subprocess, not per target, so there was no producer to adapt. A consumer that wants per-target progress infers start from the first target.output. Reopen this only if the engine grows a per-target start event for its own reasons.
  • Windows. SockDir has a Windows variant but the socket bus needs checking there; the CLI front door is unaffected.

Decision: the daemon is an accelerant, never a capability gate

Recorded 2026-08-27, after the transport question surfaced a wider one.

The complaint that prompted it: some capabilities are daemon-only and some are not, which is hard to support and impossible to document without a fork in every paragraph. The proposed fix was to auto-start the daemon on any command.

That was rejected, and a narrower rule adopted instead:

No capability is daemon-only. The daemon is only ever an accelerant.

Amended 2026-09-02: machine-wide admission is daemon-owned

One capability is now daemon-only, by an explicit decision rather than by drift: a machine-wide budget for concurrency and declared memory. A run routes its admission through the user's daemon, and starts one if none is up. Everything else about the daemon remains an accelerant, and the rule above still governs it.

The invariant could not survive this feature, and the honest thing is to say so rather than to claim a lease directory or a lock file counts. magus tried the directory (bae836df6, deleted 20 hours later): a passive registry can add up what its peers claim, and it cannot tell a waiter that its turn came, so every contended run had to be REFUSED. That is a capability magus could not offer without a process, which is the definition of daemon-only. What the rule protects against - documentation that forks on whether a daemon is running - is bought back by starting one, so no page says "if the daemon is up, X; otherwise Y".

Two things bound it:

  • Only a run pays. dispatchProfile.spawnsWork is true for run and affected and nothing else, so magus ls, describe, query, and every other question still spawns no background process. This is the same reading that lets graph export --follow start a daemon: asking for the machine's budget IS asking for the thing that holds it.
  • It fails open, loudly. A daemon that will not start, or that dies mid-run, leaves the run unarbitrated and finishing, having said once that it is. A scheduler that can fail a build it was only supposed to order is worse than no scheduler.

A related correction landed with it. An adopted run executes in the daemon's process, where its console output goes to the daemon's log and the caller's terminal shows nothing - measured, not theoretical. That was survivable while nobody started a daemon for an ordinary build, and it is not now that a run starts one. So a TOP-LEVEL run/affected no longer forwards to the stable daemon: it runs here, prints here, and asks the daemon only for admission. A NESTED call still forwards to the socket its parent exported, which is its parent's own process, and prints where the parent does.

Why not global auto-start

  • It trades a legible failure for an illegible one. "Daemon off" is visible today and one command fixes it. A daemon that fails to launch, wedges, or is a stale binary serving a newer CLI turns EVERY command into a hang with no obvious cause - the same failure class MGS1021's stale-binary explainer exists for.
  • magus ls inside a docker build layer would leave an orphaned background process. A build tool that silently spawns long-lived processes is a surprise.
  • docs/scope.md promises the daemon "carries an asterisk". Global auto-start makes it a de facto runtime requirement, which is a doctrine change rather than a feature, and it should be made deliberately if it is made at all.

Why the invariant fixes the documentation

Documentation forks because capabilities fork, not because the daemon is sometimes down. With the invariant, no page ever says "if the daemon is running, X; otherwise Y". It says X, and the daemon makes X faster.

The residual set, and why it is not a violation

/mcp and the console are network surfaces BY DEFINITION - something connects to them over a socket. The shared concurrency pool, machine-wide admission, and background jobs are cross-process by definition. Nobody is surprised that asking for a server needs a server, so these are not a capability split; they are the daemon's own surface.

For those, a command SHOULD auto-start the daemon, because asking for the console IS asking for the daemon and starting it is doing what was asked rather than a side effect.

Amended 2026-09-02. This paragraph used to end "CI never spawns one under this rule - not by a special case, but because CI never asks for a console. That absence of a conditional is the point." That is no longer true, and the honest correction is to say so rather than to add the special case it was praising. magus run and magus affected now start a daemon for machine-wide admission, and CI runs exactly those. A CI-only exemption would be precisely the conditional this passage argued against, and it would also be wrong on the merits: a self-hosted runner executing several jobs at once is the machine the budget is for.

What bounds it instead is a lifetime, not a caller. A daemon started for admission exits on its own after ten minutes with no claims held, no work in flight, and no client asking (watchAdmissionIdle); a daemon a person started with magus server start has no such bound, because they said what they wanted. That keeps the docker build objection answered - a layer that runs one magus command leaves a process that reaps itself, rather than one that lives as long as the machine.

graph export --open --follow does this, via ensureConsoleDaemon. A first implementation was reverted before merge and rebuilt, and the three failures that review found are the specification for anyone touching it again:

  • The child must not inherit MAGUS_DAEMON_SOCKET. spawnDetachedDaemon was safe only because its one caller ran under a dispatch profile that never hosts a per-process proc server. Called from one that does, the child decided it was already adopted, bound no socket, and reported its PARENT's - leaving a daemon magus server stop could not find and only kill could remove. daemonChildEnv scrubs it.
  • Every failure path must reap what it spawned. With console.enabled=false the wait times out by construction, so each attempt leaked one more process. reapDaemon kills it on both the timeout and the cancellation path.
  • It must say which tree it came up in. One socket per user serves every workspace, so a daemon started here is authoritative for whoever connects next. This is the same hazard servingSuffix exists for on the server start path.

--print is exempt: the scriptable "just give me the URL" form must not leave a background process behind.

Still open: promoting one worktree's binary to a long-lived per-user service is what the checkout guard exists to prevent, by a route the guard cannot see because there is no cd in the command line.

This was already the de facto rule

Two sites decided it independently before it was stated:

  • internal/doctor/checks_mcp.go degrades the console check when no daemon is running, reasoning that "a check that is red by default is a check people learn to ignore, taking the real failures with it".
  • cmd/magus/graph.go refuses --follow with hint.ServerStart rather than starting one, under a comment reading "magus never auto-starts a daemon".

The second is the site this decision REVERSES: --follow is a plain request for the console, so it starts the daemon rather than refusing. The first stays as it is and becomes the worked example of the rule.

The event stream needs none of this

magus events reads the run-log directory, which every magus process already writes to. It has no daemon dependency and no tier split, and this decision does not change it. A daemon-side push would lower latency below the poll interval and is an optimization, not a second implementation.

How the invariant is enforced

A rule that lives only in prose is a rule with roughly even odds (CLAUDE.md says so, and measured it). It needs a test in the shape of TestNoHostSpecificBehaviorInCode: a gate that fails when a command's failure path reports a capability as unavailable because no daemon is running, rather than degrading or starting one.

What the implementation changed about the design

Five things only showed up once real events flowed. Each is recorded because the design as written would have shipped wrong without it.

The run-log directory is the bus. Every magus RUN already appends to <cacheDir>/runs/<inv>.jsonl, so a follower reading that directory sees runs from any terminal with no daemon, no socket, and no token. Only runs: commands that open no invocation write no log and produce no events. Verified end to end: a follower process picked up runs started by a separate process, live. This demotes the proc events.subscribe frame from REQUIRED to a latency optimization, and it is what lets the stream satisfy the daemon invariant above rather than violating it.

The journal buffered, so a naive tail could not fire. journal.FileHandler wrote into a bufio.Writer flushed only at run end, so a follower would have lagged by up to a page and a short run would have delivered nothing until it finished - shipped, green, and unable to work. It now flushes every kind EXCEPT output: lifecycle and result are one per run and one per target, so the syscall is free at that rate, while output stays buffered as the one high-volume kind. That is a hot path changed for a new feature's benefit and deserves review.

jsonv2 does not omit zero numbers. The repo builds with the jsonv2 codec, where omitempty omits only empty JSON values (null, "", [], {}) and NOT 0. Every duration_ms,omitempty therefore shipped "duration_ms":0 on events where the field does not apply - telling a subscriber a cached replay took no time rather than that it never ran. The numeric fields carry omitzero, and a test pins the distinction. Any new numeric field on this contract has the same trap.

"Is a daemon running" cannot be answered by looking at a socket. A magus invocation hosts its own proc server on the stable socket AND exports MAGUS_DAEMON_SOCKET into its own environment so children inherit adoption. Both of the obvious checks therefore report a process as being served by itself, and the warm-graph hint never fired. The question is whether the PID on the other end is somebody else. Three implementations were wrong before the fourth worked; the comment on servedByAnotherDaemon records why.

A relative --root shipped a relative workspace. The contract promises an absolute root because a subscriber watching two workspaces routes on it, and a relative path resolves against the SUBSCRIBER's cwd rather than the producer's. magus events absolutizes.

--limit 0 means replay NOTHING

Found by running the shell watcher: it announced the previous day's failure the moment it started. --limit originally read 0 as "replay everything", so there was no way to ask for "only what happens next" - Follower.Skip existed in the library and the CLI could not reach it.

The semantics are now the ones a subscriber actually needs:

--limit on attach
0 nothing; only what happens from now on
N > 0 the last N invocations, then follow
< 0 every retained invocation, then follow

A notifier wants 0; a statusline wants 1. The library's Replay still reads 0 as "no cap", and the CLI maps - changing the method would have altered what it means for every other caller to fix a flag's ergonomics.

Reference clients: shell first

The first draft led with an editor package. That was the wrong shape, and using the thing is what showed it.

The repo's existing reference integrations (docs/guides/integrations/agents/*.sh) are POSIX sh for a reason doctrine states: the contract should be small enough to hold in your head. An editor package buries the ten lines that teach the contract under a couple of hundred lines of mode, hook, and buffer plumbing. magus-events-watch.sh is the whole contract, and an editor client is visibly that loop plus a way to draw on a screen.

Editor clients for Vim and Emacs are drafted but deliberately not shipped yet: they are the part of this work that most needs a human to read it, and an unreviewed plugin in the docs tree is a promise magus has not checked.

Two findings from writing them are worth keeping even so:

  • Vimscript reaches more editors than Lua and is shorter here. Nothing in this contract needs Neovim - it is a subprocess pipe, and Vim 8 has had job_start() and json_decode() for years. The deciding detail runs opposite to the usual assumption: Vim's out_mode: 'nl' delivers exactly one complete line per callback, so the Vim path needs no line buffering, while Neovim's on_stdout hands over a list whose last element may be partial.
  • VS Code is the widest audience and the worst reference. Its extension scaffolding (package.json, tsconfig, a bundler) dwarfs the fifteen lines that matter, and readline.createInterface hides the partial-line problem rather than teaching it. docs/scope.md already names the OpenCode plugin's upkeep as more than an example should need.
eventsintegrationdesigndecisionseditorplugindaemon
Last updated (cc3d21b1)
Earlier changes on this page (3)

Full history ↗ · Blame source ↗

Glossary

Workspace

The magus root directory that owns a set of projects and shared config; the unit magus operates over. See workspace.

Magusfile

The magusfile.buzz that declares a project's targets (as export funs) and binds its spells. See targets.

Target

A named operation (build, test, ...) you invoke with magus run <target>; it may compose a spell's tool-native operations and depend on other targets. See targets.

Op

A single tool-native command a target composes (long form: operation); the middle of the work hierarchy (Spell to Op to Target). See operations.

Spell

A language/runtime adapter (e.g. go, md) that maps generic targets onto a toolchain's real commands. See spells.

Charm

An execution modifier attached with : (lint:rw) that changes how a target runs, not which one; the built-in rw flips a check-only target to mutate in place, and ci always strips it. See charms.

Ward

A coded diagnostic that inspects a resolved op and nudges or blocks an anti-pattern before it runs. See wards.

Buzz

The language magusfiles are written in (the .buzz engine). See engines.

Engine

The interpreter a magusfile runs on; magus embeds the Buzz engine. See engines.

Cache

The content-addressed store magus consults before running a target, so unchanged work is skipped. See cache.

Affected

The set of projects touched by a change; magus affected <target> runs a target only over them. See affected.

Service

A long-running or shared process magus manages across runs, distinct from a one-shot target. See services.

Daemon

The background magus host that owns shared state such as services and the warm knowledge graph. See daemon.

CI

An ordinary magusfile-defined target you compose yourself with magus\needs - magus does not hardcode its stages. Magus.RunCI treats it specially only in that it strips the rw charm, it is the anchor magus affected ci keys off, and a selected scope with no project declaring it is a load error rather than a silent no-op. See targets.

Span

OpenTelemetry's name for one unit of work under a trace - a target execution, whose sub-operations are child spans. An output reference points at a span's captured output. See telemetry.

Pool

The concurrency pool: the shared set of slots that caps how many targets run in parallel on one machine. Its capacity defaults to MAGUS_CONCURRENCY, then 4 on GitHub-hosted runners, then min(NumCPU, 8); magus status and the dashboard report it live. See daemon.

Concurrency

How many targets run at once. It is bounded by the pool's capacity and set with --concurrency, MAGUS_CONCURRENCY, or the concurrency config key. See daemon.

Latency

How long an operation takes. magus records latency as OpenTelemetry histograms per family - target execution, cache op, pool wait, and graph query - and reports each as a count, sum, and percentiles. See telemetry.

Session

One magus process's recorded facts - the targets it finished, their outcomes, and the lease it acted as - kept in a repo-scoped store every worktree shares. magus session lists them; the store prunes itself by last-fact age.

Lease

One row of the lease ledger: a piece of work an orchestrating agent handed out, with its goal, the checkpoint it was cut against, and the paths it owns or must not touch. The ledger records; the agent guard is what reads those facts back when grading a write. See doctrine.

Conventions

Placeholders

Angle brackets mark a value you replace with your own - never type the brackets:

magus run <target>
magus completion <shell>    # e.g. bash, zsh, fish

<target>, <path>, <shell>, <name> and the like are stand-ins, not literal text.