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

The event stream

magus events emits one JSON object per line describing what is happening in a workspace. It is the surface an integration builds against: an editor plugin, a status bar, a notifier, a dashboard.

magus events --follow

Every magus RUN in the workspace feeds the stream, so a build started in another terminal shows up here. Commands that are not runs - ls, query, doctor - write no run log and so produce no events. It needs no daemon, no token, and no loadable magusfile: an editor can attach to a repository whose magusfile is mid-edit, which is exactly when someone wants it.

The line shape

Envelope fields and the event's own fields sit at one level:

{"schema":1,"type":"target.result","ts":1756312800000,"workspace":"/repo","inv":"inv1a2b3c","project":"cmd/magus","target":"build","status":"failed","cache_hit":false,"ref":"out267cbdc9baba","duration_ms":1200,"error":"exit status 2"}
field meaning
schema envelope version; see Compatibility
type what kind of fact this is
ts unix milliseconds
workspace absolute repository root, so a subscriber can watch more than one
inv groups every event of one invocation; absent outside a run

The types

type when key fields
run.started an invocation opens phase, command, trigger, magus_version
run.finished it closes phase, status
target.result one target finishes project, target, status, cache_hit, ref, error
target.output a subprocess writes a line stream, text - opt-in, see below

Four types, and that is the whole taxonomy today. Diagnostics, file changes, attention requests and guard verdicts are all facts magus records somewhere, and each is a candidate - but a type you can name and never receive is worse than one that does not exist, because a silent stream and a wrong filter look identical. They arrive when their producer does. Adding a type is additive and does not bump schema, so nothing you write today breaks when they land.

run.finished carries no duration. Correlate it with its run.started by inv and subtract the two ts values; you are holding the pair anyway.

status and cache_hit are separate questions

status says whether the target SUCCEEDED. cache_hit says whether it actually RAN. A replay is {"status":"ok","cache_hit":true}. Rendering a replay as a fresh build is the common misreading, so the two axes are kept apart rather than folded into one three-valued field.

target.output is off by default

It is the one type that scales with build size rather than project count: a busy affected ci emits tens of thousands. Ask for it explicitly:

magus events --follow --type target.output

Usually you do not want to. A target.result carries ref, the address of that execution's captured log, so a subscriber fetches the one log it cares about:

magus query output out267cbdc9baba

Filtering

magus events --follow --type target.result,run.finished

An unknown type name is an error, not an empty match. A typo would otherwise present as a workspace where nothing ever happens, which is indistinguishable from a broken integration.

Replay

Without --follow, magus events prints recent history and exits. --limit bounds how many invocations it reaches back through; --limit 1 is "what did the last run do".

magus events --limit 1

--limit also decides what a --follow subscriber sees on attach:

--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, so it has something to show before the next run starts.

Compatibility

A subscriber must ignore types and fields it does not recognize. That rule is what lets magus add an event type without breaking clients built against an earlier release, and it is the only thing asked of an integration.

schema is bumped when a field is renamed or removed, or when an existing field changes meaning. Adding a type, or adding an optional field to an existing type, is additive and does not bump it.

What the stream is not

It is outbound only, and there is no reply channel. Nothing a subscriber does can change what magus decides - the engine, the cache, the graph schema, and the guard's evaluation are sealed (see scope), and an extension seam "may change what magus does, never what a verdict means".

The inbound counterpart is magus session hook, which is request/reply and returns a verdict. A guard denial reaches this stream as a guard.verdict event AFTER the fact, so a status bar can show it and nothing can influence it.

To change what a build DOES, the extension points are the ones that already exist: spells, charms, and the magusfile - declarations the sealed engine evaluates, auditable in a diff.

Latency

--follow polls the run log. Lifecycle and result events are flushed as they happen; target.output is buffered and arrives in chunks, or at the end of the run. Tune the poll with --interval.

A reference client

magus-events-watch.sh, in docs/guides/integrations/editor/, prints every target failure as it happens, anywhere in the workspace:

./magus-events-watch.sh

It is a TEMPLATE you own, not a package magus releases - the same arrangement as the agent guard templates, and for the same reason ("The host wiring is yours" in doctrine).

POSIX sh and jq, nothing else. Reduced to its core it is one pipeline:

magus events --follow --limit 0 --type target.result | jq -r --unbuffered '
  select(.status == "failed") | "FAIL \(.project):\(.target)"
'

--limit 0 replays nothing, which is what a notifier wants: waking up and announcing yesterday's failure is worse than staying quiet.

The shipped script is that plus two things the reduction leaves out, and both are worth copying. It also handles run.finished, so a run that fails without any single target failing still reports. And it does not let the pipeline swallow the exit status: a shell pipeline exits with its LAST stage, so a magus events that dies reaches jq as a clean EOF and the script would otherwise exit 0 - a supervisor would see a healthy subscriber that had stopped subscribing. POSIX sh has no pipefail, so the producer records its own status and the script exits with that.

That loop is the whole contract. An editor plugin is the same thing plus a way to draw on a screen.

Writing your own

Spawn magus events --follow, buffer partial lines, decode each complete one, switch on type. That is the entire client.

The partial-line handling is the one thing worth getting right rather than discovering. A process filter or job callback is handed arbitrary BYTES, not lines, so a JSON object routinely arrives split across two callbacks. Hold whatever follows the last newline until the rest of it turns up.

Two runtimes do it for you, and it is worth knowing which:

  • Vim: job_start() with out_mode: 'nl' delivers exactly one complete line per callback.
  • Node / VS Code: child_process.spawn plus readline.createInterface.

Everything else needs the pending buffer - Neovim's on_stdout hands over a list whose last element may be partial, and an Emacs process filter gets raw chunks.

For editing magusfiles and spells - completion, hover, signature help - see Editor setup, which wires magus buzz lsp. The two are independent: the language server is edit time, this stream is run time.

See also

  • design: why the stream is shaped this way - outbound only, no daemon, and what building the first clients changed about it.
  • editor: the language server for *.buzz files.
  • daemon: the daemon, and why this stream does not need one.
  • console: the browser surface reading the same underlying state. Note it documents a route called /api/v1/events, which is NOT this stream and shares nothing with it: that one is the console's own server-sent-events feed, bearer-gated, carrying base64 protobuf status and metrics frames for the dashboard. It is daemon-internal. This page is the integration surface; do not build against the route because the names match.

Stopping a follower

--follow runs until you stop it. A signal ends it the way it ends any long-running magus command: the process exits 128+N (143 on SIGTERM, 130 on Ctrl-C), which is the shell convention and NOT a crash. A plugin's process sentinel should treat those two as a normal stop.

Known limits

  • Poll cost scales with retained runs. --follow re-reads the run-log directory every --interval, and each poll stats the logs it finds. Those logs are pruned by a daemon maintenance job, so a workspace running without a daemon accumulates them. On a busy repo left running for weeks, raise --interval or start a daemon. This has not been measured; the shape of the cost is stated so you can recognize it rather than as a claim about when it bites.
  • target.output arrives in chunks. Only that type is buffered (see Latency), so a subscriber to it sees bursts rather than a smooth tail. Fetch a finished target's log by ref when you want it whole.
eventsintegrationeditorpluginemacsvimjsonlsubscribestream
Last updated (2bb9e1fd)
Glossary

Workspace

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

Project

A directory magus recognizes as a unit of work (it has a magusfile); the unit of caching, scheduling, and dependency tracking. 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.

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.

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.

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.

Health

The at-a-glance daemon state derived from the pool: healthy when the pool is reporting, degraded when it reports an error, down when there is no pool. The dashboard color-codes each state. See daemon.

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.

Attention request

A durable "an agent is blocked" record, opened when a magus session notify event carries the waiting or permission outcome and held until a person disposes it. magus session attention lists what is open. Nothing closes one on its own - see doctrine.

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

This page uses none of the site's convention markers. The full set is on the conventions page.