magus v0.4.3 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 what 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 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 the design principles).

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's job_start() with out_mode: 'nl' delivers exactly one complete line per callback.
  • Node and VS Code have 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.
  • server: the daemon, and why this stream does not need one.
  • console: the browser console 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 contract; 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 (2df714e8)
Earlier changes on this page (7)

Full history ↗ · Blame source ↗

Glossary

Workspace

The magus root directory that owns a set of projects and shared config; the unit magus operates over. Workspace in full.

Project

A directory magus recognizes as a unit of work (it has a magusfile); the unit of caching, scheduling, and dependency tracking. Project in full.

Magusfile

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

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. Target in full.

Op

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

Spell

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

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. Charm in full.

Buzz

The language magusfiles are written in (the .buzz engine). Buzz in full.

Engine

The interpreter a magusfile runs on; magus embeds the Buzz engine. Engine in full.

Cache

The content-addressed store magus consults before running a target, so unchanged work is skipped. Cache in full.

Affected

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

Daemon

The background process a person starts with magus server start. It serves MCP, the console, background jobs and the warm knowledge graph, and adopts nested magus calls into one pool. Daemon in full.

CI

An ordinary target you compose yourself with ctx.needs; magus does not hardcode its stages. It is the anchor magus affected ci keys off, and ci strips the rw charm. CI in full.

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. Latency in full.

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. Health in full.

Agent

A model working through an agent host such as Claude Code, Codex, Cursor or OpenCode. magus treats an agent as one more caller of its CLI, MCP tools and console, held to the same checks as a person. Agent in full.

Session

An agent host's conversation, by the id the host delivers to its hooks. magus never mints one: a record with no session is unattributed, and the OS user it carries says whose account ran it. Session in full.

Invocation

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

Attention request

A durable record that an agent is blocked, opened by a magus session notify event and held until a person disposes it. Nothing closes one on its own. Attention request in full.

Job

The unit of delegated work, and one row of the job store: what an orchestrating agent handed out, with its goal, the checkpoint it was cut against, the paths it may write or must not touch, and the one check it runs. A job's holder is either a session, for work an orchestrator handed out, or the daemon, for its own maintenance. The store records; the agent guard is what reads those facts back when grading a write. Job in full.

Run

One target executing under one magus invocation, such as magus run test web or magus affected ci. A run keeps its captured output behind an output reference. Every magus run is a run whether or not any job asked for it; see Job for how the two relate. Run in full.

Lease

The grant a holder takes on a job: the write and read paths that job declared, enforced in the checkout that took it with magus job exec. A job is the piece of work; a lease is permission over it. Lease in full.

Conventions

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