magus in Claude Code: health of the binary, guard and server; a pane of jobs, runs and agents; prompt-cache expiry, price-step, subagent and budget cost gates

<img alt="magus gopher mascot" width="360" height="240" fetchpriority="high" src="./assets/gopher.png">
<!-- The coverage badges are measured and rendered by magus run coverage-render . in the CD site job on every push to main, and published with the docs site. Nothing commits them. -->
<a href="https://github.com/egladman/magus/actions/workflows/ci.yaml?query=branch%3Amain"><img alt="CI" src="https://github.com/egladman/magus/actions/workflows/ci.yaml/badge.svg?branch=main"></a> <a href="https://github.com/egladman/magus/actions/workflows/cd.yaml?query=branch%3Amain"><img alt="CD" src="https://github.com/egladman/magus/actions/workflows/cd.yaml/badge.svg?branch=main"></a> <img alt="Go coverage" src="https://eli.gladman.cc/magus/assets/go-coverage.svg"> <img alt="Buzz coverage" src="https://eli.gladman.cc/magus/assets/buzz-coverage.svg"> <img alt="TypeScript coverage" src="https://eli.gladman.cc/magus/assets/typescript-coverage.svg"> <a href="https://pkg.go.dev/github.com/egladman/magus"><img alt="Go Reference" src="https://pkg.go.dev/badge/github.com/egladman/magus.svg"></a>
A fast, cross-platform task orchestrator for polyglot monorepos. One binary, no second toolchain to install. Targets are programs, not YAML.
Change a file and magus works out which projects it reaches, rebuilds only those, and caches every result so the same work never runs twice.
magus informs; it never decides. It hands you everything it knows about your repository (what a change reaches, which files are generated, where a symbol is used) and the call stays yours. It was built for humans, not for agents: agents drive it well anyway, because an interface legible to a person is legible to anything, and that ordering is the design.
Prove it before you plan it. A hunch about what a change breaks is cheap to test, and a plan built on an untested one fails late, where it costs most. Run the smallest thing that settles it and point at the run: every run leaves an output ref (magus query output <ref>) that anyone can reopen, so a plan, a review comment or a job's goal cites evidence instead of asserting it.
<!-- README terminal recordings are rendered by magus run termcast-generate from tapes/core-loop.capture. Re-record that real CLI session with magus run termcast-record when its output changes; commit the capture and SVG together. The same tool, cmd/magus-termcast, has a second mode: magus run termshots-generate runs its shots subcommand, which renders the static interactive-view SVGs used in the documentation. -->
<img alt="Terminal recording: magus ls lists five projects, magus run ci runs lint, build and test across all of them reporting '0 cached, 5 ran', the same command run again reports '5 cached, 0 ran' having replayed every result from cache, and finally one file is edited and magus affected ci narrows to two projects, the edited library and the app that imports it, reporting '0 cached, 2 ran'." src="./assets/gen/core-loop.svg" width="820">
Repos with more than one language, or CI that reruns work a change never reached. One project works too.
The tradeoff: you declare what each target reads and writes, and magus stops when a run disagrees. A few lines in a magusfile buy a build you can debug without knowing how magus works inside.
More: the knowledge graph, targets, agents, and the longer argument in I think our tools are the problem.
Five ideas carry most of the tool. Each has a deeper page; this is the short version.
magus keeps a dependency graph of your projects and knows which files each target reads. Change a file and magus affected <target> runs only the projects that change can reach, in dependency order. magus affected ci runs the full pipeline over that set, so CI does the least work a change requires and still catches breakage in a project you never opened. See CI.
Every target declares its inputs and outputs. magus hashes the inputs, and if it has already seen that hash it replays the stored output instead of running the work again. The cache is a plain content-addressed store on disk (SHA-256): the input hash is the key, and the stored outputs are addressed by their own content hash, so a replay is a byte-for-byte reproduction of the recorded run.
Most tools that offer you a codebase graph are observers: a separate indexer scans the repo, infers the structure, and can be wrong in ways nothing warns you about. magus is not observing: it is the thing that builds the repo, so it already has to know every project, every target's declared inputs and outputs, and what a diff reaches, and getting any of that wrong breaks builds loudly. The graph is that same knowledge handed back: a byproduct of being the source of truth, never an inference about it. No LLM pass, no fuzzy linking; every edge traces to a declaration you can open.
magus query "kind=target lint" finds nodes, magus explain <node> shows a node's edges and what reaches it, and magus refs <symbol> lists where a symbol is defined and used from a SCIP index.[^scip] The same graph answers "is this file generated," "what does my diff touch," and "how do these two things relate" without grepping. See the knowledge graph, including what this graph deliberately is not.
magus names a thing once and reuses the name everywhere, in the CLI, the config, and the graph. A target is a unit of work such as build, test, or lint. A spell is a language adapter that supplies a target's operations (the go spell provides go-test; the buf spell provides buf-lint). A charm is a modifier applied to a run, like rw for read-write or cd for continuous delivery. An op is a single tool invocation. Learn the four words and the rest of the CLI reads the same way.
<!-- This README recording is captured by magus run termcast-showcase from a real interactive session driven by real keystrokes, then rendered by magus run termcast-generate. Commit its capture and SVG together. -->
<img alt="Terminal recording of an interactive magus session: a run draws a pinned box at the bottom of the terminal showing pool slots and a live count while ordinary output scrolls above it; a failing run pins its failures as a tree grouped by project, with the selected failure's captured test output shown in a second column beside it; tab swaps which of the two views is larger; and magus x opens a picker that searches the knowledge graph as the filter is typed." src="./assets/gen/showcase.svg" width="1080">
The band at the bottom holds still while your output scrolls past it. Nothing is cleared, the alternate screen is never touched, and your scrollback survives, so selection, copy and paste keep working the way they always did. Every one of these views degrades to plain text when there is no terminal to draw on.
container charm changes what a target produces, not where it runs.Scope says why each line is there; Sandbox covers the third.
magus ships as a single self-contained binary, so there is no second toolchain to install.
curl --proto '=https' --tlsv1.2 -sSf https://eli.gladman.cc/magus/install -o install.sh
less install.sh
sh install.sh
Reviewing the downloaded script before executing it lets you audit the URL, verification, and installation steps instead of piping an unreviewed network response directly to your shell. See the Install guide for platform details, verification, and updates.
magus targets are written in Buzz, a small typed scripting language it embeds. A magusfile.buzz at the repo root declares your targets as exported functions: each one composes operations from the spells you bind:[^playground]
<!-- magus-run-recorder -->
import "magus";
import "magus/spell/go";
magus\project({ "spells": [go] });
// Every exported function is a runnable target. It receives a magus\Context,
// the handle it uses to declare what it needs and hands to every op it runs.
// magus caches each target's result and runs it only when a change reaches
// this project.
export fun build(ctx: magus\Context, args: [str]) > void { go["go-build"](ctx); }
export fun test(ctx: magus\Context, args: [str]) > void { go["go-test"](ctx); }
export fun lint(ctx: magus\Context, args: [str]) > void { go["golangci-lint"](ctx); }
// format is read-only by default: go-fmt reports files that need formatting, and
// go-mod-tidy runs with --diff so it fails if go.mod/go.sum have drifted. They take
// DIFFERENT write charms, because they are not the same risk. gofmt is offline, so
// the same tree always yields the same bytes: `magus run format:rw` rewrites the
// code. Tidy resolves against the module proxy, so what it writes depends on what
// upstream serves today; that is a second, deliberate ask:
// magus run format:rw formatting only; go mod tidy still just reports
// magus run format:rw,update also let go mod tidy amend go.mod and go.sum
export fun format(ctx: magus\Context, args: [str]) > void {
go["go-fmt"](ctx);
go["go-mod-tidy"](ctx);
}
// 'ci' is the anchor `magus affected ci` keys off: it composes the pipeline
// by declaring the targets it needs.
export fun ci(ctx: magus\Context, args: [str]) > void {
ctx.needs(build, test, lint, format);
}
Point magus at that repo and each command returns an answer and stops:
magus ls # which projects exist
magus run test # run a target, cache the result
magus affected ci # the pipeline, over only what your diff reaches
magus query "kind=spell" # what the graph knows
magus describe file docs/gen/index.html # is this file generated, and by what
Nothing here plans a workflow or decides for you. magus describe file tells you a path is a generated output so you skip its diff; magus affected ci tells you which projects a change reaches so you run no more than that.
One process (magus server start) exposes the workspace through two standing listeners, one per audience, and every browser page is a separate static asset; the binary serves no HTML. A third listener is raised only on demand: "share to phone" opens a time-boxed LAN listener that serves the read-only console to a phone on the same network, then tears itself down.
Four figures, one subject each. They were one flowchart until it carried roughly thirty-five boxes, which is the point at which a diagram stops being read and starts being skipped.
Agents and the console share one guarded front door: a DNS-rebind check, a bearer token and a CORS policy sit in front of /mcp, /api/v1, the Connect services and the share endpoint. The health endpoints are the deliberate exception, so a probe never needs a credential.
<img alt="Clients reach the daemon's routes through a single guard; the health endpoints bypass it" src="docs/assets/gen/diagram-server-http.svg">
A CLI invocation never goes over HTTP. It dispatches across a private unix domain socket into the concurrency pool and the three registries the daemon keeps warm. Sharing a graph is the one case that spawns a separate short-lived loopback server.
<img alt="The magus CLI dispatching over a unix socket into the daemon's pool and registries" src="docs/assets/gen/diagram-server-socket.svg">
File watchers, the SCIP auto-indexer and a coalesced graph-build job all exist to keep one thing current: the workspace registry, which holds the daemon's only warm copy of the knowledge graph.
<img alt="File watchers, the SCIP indexer and the graph-build job all writing into the workspace registry" src="docs/assets/gen/diagram-server-jobs.svg">
The LAN listener is the only part of magus a second machine can reach. It is minted by a loopback-only endpoint, time-boxed (fifteen minutes by default), carries a read-only token, and serves no MCP, share or mutating route at all.
<img alt="A loopback endpoint minting a time-boxed read-only LAN listener for a phone" src="docs/assets/gen/diagram-server-share.svg">
The colors group the system by role, and each region is tagged with the Go package or project that owns it, so the diagram doubles as a code map: the runtime is the root module (cmd/magus plus internal/*), the browser console is the docs/ project, and the wire contracts are the proto/magus protobufs.
Green is the Unix domain socket, the local control plane: it dispatches magus run/magus affected into one shared concurrency pool, answers magus status, and adopts nested magus calls. Fast and private (0700); the local CLI and the liveness/readiness probes use it.
Orange is the HTTP server on mcp.address, for clients that cannot reach a Unix socket. It carries MCP for agents at /mcp, the read-only /api/v1 console routes, Connect services for metrics and the activity trail, and one bearer-gated job-control service for maintenance jobs (the daemon's only mutating endpoint). Its request and response types are the proto/magus protobufs, generated by buf and served over Connect/JSON.
Red is the guard chain every HTTP route but health passes through: a DNS-rebind host check, a bearer token (the cli token plus named connector tokens), and CORS scoped to the site and loopback origins.
Yellow is the health routes, left unguarded so a kubelet can probe them; they answer by querying the same socket. See container probes.
Purple is shared, warm daemon state: the knowledge graph and SCIP index in the workspace registry, plus the runs, services, metrics, and trail registries, and the graph's own declared inputs and exports.
Indigo is the background jobs that keep that state fresh without a foreground command: file watchers invalidate the warm graph and push an SSE event to the console, a throttled SCIP indexer keeps symbols current, and a branch switch fires the git hook, which submits one coalesced graph-build job over the socket.
Teal is the browser console, five static apps on the daemon, covered in The browser console below.
The graph itself is assembled from declared sources as shards (the magusfile registry, docs, @symbols from SCIP, @vcs from git history, CODEOWNERS). magus graph export -o json writes the graph data copied into the console's offline demo, and magus describe graph -o markdown writes the MAGUS.md routing index; live, the daemon serves the same graph byte-identical at /api/v1/graph.
Because the two listeners are separate, they can diverge: the socket can be healthy while the HTTP/MCP endpoint failed to bind, which is why magus status reports each one on its own line.
magus is fully featured from the terminal, so everything here is optional. Alongside the CLI, the daemon can drive a set of read-only browser apps.
Want to see it first? Open the live demo: no install, no daemon. It fills the dashboard with synthesized activity, streams a build into the log viewer, and lets you jump between every app in demo mode. Everything below runs against your own daemon instead.
The apps ship as one console; each link below opens it on the matching app.
<!-- The architecture figures take their design system from the same project's diagram-design skill (MIT), which magus/figure renders. --> <!-- Grid layout after cathrynlavery/diagram-design's README gallery. Screenshots are regenerated by ./hack/docs/screenshots.sh against each console app's #demo showcase. Two of them are MODES of the dashboard rather than apps of their own, which is why they carry the key that reaches them; Big Picture takes the full width because losing the chrome is the whole point of it. The last row is the same console on a phone (375x812, device emulation on): a different design rather than the same one squeezed, and with the larger touch targets that come with it. --> <table> <tr> <td align="center" width="33%"><img src="./assets/screenshots/console-dashboard.png" alt="Dashboard"><br><b>Dashboard</b><br><sub>Pool, cache and daemon health, live</sub></td> <td align="center" width="33%"><img src="./assets/screenshots/console-graph.png" alt="Graph Explorer"><br><b>Graph Explorer</b><br><sub>Targets, spells and their dependencies</sub></td> <td align="center" width="33%"><img src="./assets/screenshots/console-logs.png" alt="Log Viewer"><br><b>Log Viewer</b><br><sub>Any run's captured output, streamed or replayed</sub></td> </tr> <tr> <td align="center" width="33%"><img src="./assets/screenshots/console-activity.png" alt="Activity Trail"><br><b>Activity Trail</b><br><sub>What agents did, and when</sub></td> <td align="center" width="33%"><img src="./assets/screenshots/console-diff.png" alt="Diff"><br><b>Diff</b><br><sub>Review the working tree, paired with an agent</sub></td> <td align="center" width="33%"><img src="./assets/screenshots/console-dashboard-plan.png" alt="The dashboard's work plan"><br><b>Work plan</b><br><sub>Who holds which lease, and where two of them overlap (press <code>p</code>)</sub></td> </tr> <tr> <td align="center" colspan="3"><img src="./assets/screenshots/console-dashboard-bigpicture.png" alt="The dashboard in Big Picture"><br><b>Big Picture</b><br><sub>The same dashboard with the console's own chrome gone, for a wall display (press <code>b</code>)</sub></td> </tr> <tr> <td align="center" width="33%"><img src="./assets/screenshots/console-diff-mobile.png" alt="Diff on a phone"><br><b>Diff, mobile</b><br><sub>The file index folds to a rail; split view falls back to unified</sub></td> <td align="center" width="33%"><img src="./assets/screenshots/console-logs-mobile.png" alt="Log Viewer on a phone"><br><b>Log Viewer, mobile</b><br><sub>The waterfall keeps its drawn size and scrolls, rather than shrinking its labels away</sub></td> <td align="center" width="33%"><img src="./assets/screenshots/console-dashboard-mobile.png" alt="Dashboard on a phone"><br><b>Dashboard, mobile</b><br><sub>Tiles stack, the rail gives way to the launcher grid, and controls take a 44pt touch target</sub></td> </tr> </table>
magus graph export --open).[^app-graph]magus query output <ref> --open).[^app-logs]These are add-ons, not a runtime you depend on. Two decisions keep them that way.
magus never embeds a web server that ships a UI, and a released binary carries no pages: the console is a separate static site (rendered from docs/ at deploy time, hosted at eli.gladman.cc/magus, or self-hosted from any file server). What the daemon exposes over loopback is a small API: read-only views (/api/v1/...), one bearer-gated job-control service for maintenance jobs, and the MCP endpoint. It also serves a console build you point it at on disk (console/gen, or MAGUS_CONSOLE_DIR) so you can host your own copy, but it ships none and 404s /console/ until one is built.
The hosted page talks only to 127.0.0.1/[::1], a loopback lock it enforces before any request, or it receives your graph inline through a URL fragment. Nothing is uploaded. You can drop the UI entirely: set console.enabled: false and the daemon runs fine without it, serving no browser API at all. See the Console reference.
A tighter feedback loop does more for an agent working in your code base than a larger model does, and most of that loop already exists: the build, test, lint, format, and cache scripts you wrote so developers could set up an environment. magus is the hookup. Those same scripts become what the agent runs, deterministic and fast.
magus treats an AI agent an
hooks/register.tsx 862 lines1import type { AgentSpawnInput, EngineInterface, ProcessRunResult, Register, TurnUsage } from 'claude-code'
2
3import type { AgentRow, CacheState, Filter, Report, Spend, Tab, Ttl } from '../types'
4import {
5 candidateJobs,
6 connectError,
7 LIST_ACTIVITY,
8 LIST_INVOCATIONS,
9 LIST_JOBS,
10 parseActivity,
11 parseJobs,
12 parseRuns,
13 socketFromStatus,
14} from './api'
15import {
16 budgetNotice,
17 cacheNotice,
18 cacheStatus,
19 formatTokens,
20 formatUsd,
21 frontmatterModel,
22 inferTtl,
23 overSpawnCap,
24 parseRates,
25 priceStepNotice,
26 pricedModel,
27 rateAgeDays,
28 requestUsd,
29 sessionUsd,
30 spawnRefusal,
31 spawnWarning,
32 spendBreakdown,
33 spendStatus,
34 totalTokens,
35} from './cost'
36import type { RateTable, Usage } from './cost'
37import { assess, signature, statusText } from './health'
38import type { BinaryInfo, Probe, ServerInfo } from './health'
39import { DEFAULT_RATES } from './rates'
40import { readSettings } from './settings'
41import type { Gate, Settings } from './settings'
42import { age, COLOR, GLYPH, leaf, treeLines } from './tree'
43
44const PANE = 'magus'
45const HEALTH_GAP_MS = 10_000
46const HEALTH_POLL_MS = 120_000
47const JOBS_POLL_MS = 15_000
48const RUNS_POLL_MS = 5_000
49const WATCH_POLL_MS = 3_000
50const TICK_MS = 30_000
51const MAX_LINES = 300
52
53const REPORT = { plugin: 'magus', key: 'report' } as const
54// The signature of the issue set the person hid; the band returns when the set changes.
55const DISMISSED = { plugin: 'magus', key: 'dismissed' } as const
56const TAB = { plugin: 'magus', key: 'tab' } as const
57const LISTING = { plugin: 'magus', key: 'listing' } as const
58const RUNS = { plugin: 'magus', key: 'runs' } as const
59const ERROR = { plugin: 'magus', key: 'error' } as const
60const FILTER = { plugin: 'magus', key: 'filter' } as const
61const SELECTED = { plugin: 'magus', key: 'selected' } as const
62const WATCHING = { plugin: 'magus', key: 'watching' } as const
63const WATCH_LINES = { plugin: 'magus', key: 'watchLines' } as const
64const AGENTS = { plugin: 'magus', key: 'agents' } as const
65const CACHE = { plugin: 'magus', key: 'cache' } as const
66const HELD_FOR = { plugin: 'magus', key: 'heldFor' } as const
67const SPEND = { plugin: 'magus', key: 'spend' } as const
68const RATES_ERROR = { plugin: 'magus', key: 'ratesError' } as const
69const TICK = { plugin: 'magus', key: 'tick' } as const
70
71// Module state, rebuilt by register on every load.
72let settings: Settings = readSettings({})
73let rates: RateTable = DEFAULT_RATES
74// The TTL Claude Code last reported (on a model switch), which outranks an observation.
75let engineTtl: Ttl | null = null
76let healthInFlight: Promise<void> | null = null
77let healthAt = 0
78// The server's socket, found once and found again after a call through it fails.
79let socket: string | null = null
80
81const NO_SPEND: Spend = { mainUsd: 0, agentUsd: 0, isPartial: false }
82
83const usageOf = (u: TurnUsage): Usage => ({
84 input: u.input_tokens,
85 output: u.output_tokens,
86 cacheRead: u.cache_read_input_tokens,
87 cacheCreation: u.cache_creation_input_tokens,
88})
89
90// ---------- health ----------
91
92async function run($: EngineInterface, argv: string[], cwd: string, timeoutMs = 10_000): Promise<ProcessRunResult | null> {
93 try {
94 return await $.process.run(argv, { cwd, timeoutMs })
95 } catch {
96 return null
97 }
98}
99
100async function binaryInfo($: EngineInterface, path: string, root: string): Promise<BinaryInfo | null> {
101 const r = await run($, [path, 'version', '-o', 'json'], root, 5_000)
102 if (r?.exitCode !== 0) return null
103 try {
104 const v = JSON.parse(r.stdout) as { version?: string; commit?: string }
105 return { path, version: v.version ?? '', commit: v.commit ?? '' }
106 } catch {
107 return null
108 }
109}
110
111async function serverInfo($: EngineInterface, bin: string, root: string): Promise<ServerInfo | null> {
112 // Exits 1 with no server running, and still prints the report.
113 const r = await run($, [bin, '-s', 'server', 'status', '-o', 'json'], root)
114 if (r === null) return null
115 try {
116 const s = JSON.parse(r.stdout) as {
117 server?: unknown
118 pool?: { version?: string }
119 mcp_endpoint?: { state?: string; note?: string }
120 }
121 return {
122 isRunning: s.server != null,
123 version: s.pool?.version ?? '',
124 mcpState: s.mcp_endpoint?.state ?? '',
125 mcpNote: s.mcp_endpoint?.note ?? '',
126 }
127 } catch {
128 return null
129 }
130}
131
132export async function probe($: EngineInterface, root: string): Promise<Probe | null> {
133 if (!(await $.fs.exists(`${root}/magusfile.buzz`))) return null
134 let goMod = ''
135 try {
136 goMod = await $.fs.read(`${root}/go.mod`)
137 } catch {
138 // No go.mod, or one that will not read: either way not the magus source tree.
139 }
140 const isMagusSource = /^module github\.com\/egladman\/magus\s*$/m.test(goMod)
141 const hasLocalBinary = await $.fs.exists(`${root}/magus`)
142
143 const which = await run($, ['/bin/sh', '-c', 'command -v magus'], root, 5_000)
144 const pathBinary = which?.exitCode === 0 && which.stdout.trim() !== '' ? which.stdout.trim() : null
145
146 const bin = hasLocalBinary ? `${root}/magus` : pathBinary
147 const binary = bin === null ? null : await binaryInfo($, bin, root)
148
149 const rev = await run($, ['git', 'rev-parse', 'HEAD'], root, 5_000)
150 const head = rev?.exitCode === 0 ? rev.stdout.trim() : null
151
152 let behind: number | null = null
153 if (binary?.commit && head && binary.commit !== head) {
154 const anc = await run($, ['git', 'merge-base', '--is-ancestor', binary.commit, head], root, 5_000)
155 if (anc?.exitCode === 0) {
156 const count = await run($, ['git', 'rev-list', '--count', `${binary.commit}..${head}`], root, 5_000)
157 behind = count?.exitCode === 0 ? Number(count.stdout.trim()) : null
158 }
159 }
160
161 const server = bin === null ? null : await serverInfo($, bin, root)
162 return { isMagusSource, hasLocalBinary, pathBinary, binary, head, behind, server }
163}
164
165// Runs from a timer so the probes (a few seconds of subprocesses) outlive the dispatch.
166function scheduleHealth($: EngineInterface): void {
167 $.clock.after(0, () => void checkHealth($))
168}
169
170async function checkHealth($: EngineInterface): Promise<void> {
171 healthInFlight ??= checkHealthOnce($).finally(() => {
172 healthInFlight = null
173 })
174 return healthInFlight
175}
176
177async function checkHealthOnce($: EngineInterface): Promise<void> {
178 const p = await probe($, await $.session.root())
179 healthAt = await $.clock.now()
180 const next: Report | null = p === null ? null : { issues: assess(p), checkedAt: healthAt }
181 await $.state.set(REPORT, next)
182 await refreshStatus($)
183}
184
185// ---------- status line ----------
186
187// One entry for the whole mod, which the engine labels with the mod's name: the health
188// issues, the cache countdown and the session's spend, or nothing when there is none.
189async function refreshStatus($: EngineInterface): Promise<void> {
190 const { value: report = null } = await $.state.get(REPORT)
191 const { value: cache = null } = await $.state.get(CACHE)
192 const { value: spend = NO_SPEND } = await $.state.get(SPEND)
193 const parts = [
194 report ? statusText(report.issues) : undefined,
195 cacheStatus(cache, await $.clock.now()),
196 spendStatus(spend, settings.sessionBudgetUsd),
197 ]
198 const text = parts.filter(Boolean).join(' · ')
199 $.ui.status(text || undefined)
200}
201
202// ---------- cost ----------
203
204async function loadRates($: EngineInterface): Promise<void> {
205 if (settings.ratesFile === '') {
206 rates = DEFAULT_RATES
207 await $.state.set(RATES_ERROR, null)
208 return
209 }
210 try {
211 rates = parseRates(await $.fs.read(settings.ratesFile))
212 await $.state.set(RATES_ERROR, null)
213 } catch (err) {
214 rates = DEFAULT_RATES
215 const why = err instanceof Error ? err.message : String(err)
216 await $.state.set(RATES_ERROR, `${settings.ratesFile}: ${why}; using the built-in rates`)
217 }
218}
219
220function resolveTtl(observed: Ttl | null, previous: CacheState | null): Pick<CacheState, 'ttl' | 'ttlSource'> {
221 if (settings.cacheTtl !== 'auto') return { ttl: settings.cacheTtl, ttlSource: 'setting' }
222 if (engineTtl !== null) return { ttl: engineTtl, ttlSource: 'engine' }
223 if (observed !== null) return { ttl: observed, ttlSource: 'observed' }
224 if (previous && previous.ttlSource === 'observed') return { ttl: previous.ttl, ttlSource: 'observed' }
225 return { ttl: '5m', ttlSource: 'assumed' }
226}
227
228// Records the main conversation's response: when it arrived, what it re-sends next, and
229// what its cache hit or miss says about the TTL.
230async function recordMainResponse($: EngineInterface, usage: TurnUsage | undefined): Promise<void> {
231 const now = await $.clock.now()
232 const { value: prev = null } = await $.state.get(CACHE)
233 const u = usage ? usageOf(usage) : null
234 const observed = u && prev && prev.lastResponseAt > 0 ? inferTtl(now - prev.lastResponseAt, u) : null
235 const context = (await $.session.usage()).context.tokens ?? (u ? totalTokens(u) : 0)
236 const next: CacheState = {
237 lastResponseAt: now,
238 contextTokens: context,
239 model: usage?.model ?? (await $.session.model()),
240 ...resolveTtl(observed, prev),
241 }
242 await $.state.set(CACHE, next)
243 if (usage && u) await addSpend($, 'mainUsd', requestUsd(rates, usage.model, u, next.ttl))
244 await refreshStatus($)
245}
246
247// Adds one response's cost to the session's total; a null cost is a model the rates do not
248// price, which marks the total as a floor.
249async function addSpend($: EngineInterface, who: 'mainUsd' | 'agentUsd', usd: number | null): Promise<void> {
250 const { value: spend = NO_SPEND } = await $.state.get(SPEND)
251 await $.state.set(SPEND, usd === null ? { ...spend, isPartial: true } : { ...spend, [who]: spend[who] + usd })
252}
253
254async function recordAgentUsage($: EngineInterface, agentId: string, usage: TurnUsage): Promise<void> {
255 const { value: rows = [] } = await $.state.get(AGENTS)
256 const { value: cache = null } = await $.state.get(CACHE)
257 const u = usageOf(usage)
258 const usd = requestUsd(rates, usage.model, u, cache?.ttl ?? '5m')
259 // Counted before the row lookup: the list keeps 50 agents, the session's spend keeps all.
260 await addSpend($, 'agentUsd', usd)
261 await refreshStatus($)
262 const row = rows.find(r => r.agentId === agentId)
263 if (!row) return
264 const tokens = row.tokens + totalTokens(u)
265 const budget = settings.subagentTokenBudget
266 if (budget > 0 && row.tokens <= budget && tokens > budget) {
267 $.ui.toast(`Subagent "${row.description}" passed its ${formatTokens(budget)}-token budget (${formatTokens(tokens)}).`)
268 }
269 await $.state.set(
270 AGENTS,
271 rows.map(r => (r.agentId === agentId ? { ...r, tokens, usd: usd === null || r.usd === null ? null : r.usd + usd } : r)),
272 )
273}
274
275// The model a spawn will run on, as the table prices it, read before the engine resolves
276// one. The engine's order is the Agent call's model, the agent definition's, then
277// CLAUDE_CODE_SUBAGENT_MODEL, then the parent's. null when it cannot be known here: the
278// definition is a built-in or a plugin's, which no API exposes.
279async function spawnModel($: EngineInterface, e: AgentSpawnInput): Promise<string | null> {
280 if (e.fork) return pricedModel(rates, e.parentModel, e.parentModel)
281 let model = e.model
282 if (!model) {
283 const def = await definitionModel($, e.subagentType)
284 if (def === null) return null
285 model = def || (await $.env.get('CLAUDE_CODE_SUBAGENT_MODEL')) || e.parentModel
286 }
287 return pricedModel(rates, model, e.parentModel)
288}
289
290// The model line of the agent's markdown definition: '' when it sets none, null when no
291// file by that name exists. `general-purpose` has no file and sets none. The file name
292// stands for the agent's name, which is how agent files are written.
293async function definitionModel($: EngineInterface, type: string): Promise<string | null> {
294 if (type === 'general-purpose') return ''
295 if (!/^[\w.-]+$/.test(type)) return null
296 const home = await $.env.get('HOME')
297 for (const dir of [`${await $.session.root()}/.claude/agents`, home ? `${home}/.claude/agents` : '']) {
298 if (dir === '') continue
299 try {
300 return frontmatterModel(await $.fs.read(`${dir}/${type}.md`)) ?? ''
301 } catch {
302 // No definition by that name here; the next directory may have it.
303 }
304 }
305 return null
306}
307
308// ---------- jobs pane ----------
309
310async function magusBin($: EngineInterface, root: string): Promise<string> {
311 return (await $.fs.exists(`${root}/magus`)) ? `${root}/magus` : 'magus'
312}
313
314// Asks magus where its server listens, the one CLI call the pane makes: the address
315// honors server.address, which no client should resolve on its own.
316async function discover($: EngineInterface): Promise<string | null> {
317 const root = await $.session.root()
318 try {
319 const r = await $.process.run([await magusBin($, root), '-s', 'server', 'status', '-o', 'json'], {
320 cwd: root,
321 timeoutMs: 15_000,
322 })
323 return socketFromStatus(r.stdout)
324 } catch {
325 return null
326 }
327}
328
329// One unary Connect call over the server's socket, as the console's transport makes it.
330// Resolves the response text, or rejects with a line a person can act on.
331async function rpc($: EngineInterface, procedure: string, body: unknown): Promise<string> {
332 socket ??= await discover($)
333 if (socket === null) {
334 throw new Error('No magus server is running. Start one with `magus server start`.')
335 }
336 try {
337 const r = await $.http.fetch(`http://magus${procedure}`, {
338 method: 'POST',
339 socketPath: socket,
340 headers: { 'Content-Type': 'application/json', 'Connect-Protocol-Version': '1' },
341 body: JSON.stringify(body),
342 })
343 if (!r.ok) throw new Error(connectError(r.status, r.text))
344 return r.text
345 } catch (err) {
346 socket = null
347 throw err
348 }
349}
350
351async function isPaneOpen($: EngineInterface): Promise<boolean> {
352 return (await $.ui.panes()).some(p => p.id === PANE)
353}
354
355async function loadJobs($: EngineInterface): Promise<void> {
356 try {
357 const text = await rpc($, LIST_JOBS, {})
358 await $.state.set(LISTING, parseJobs(text, await $.clock.now()))
359 await $.state.set(ERROR, null)
360 } catch (err) {
361 await $.state.set(ERROR, err instanceof Error ? err.message : String(err))
362 }
363}
364
365async function loadRuns($: EngineInterface): Promise<void> {
366 try {
367 const text = await rpc($, LIST_INVOCATIONS, { pageSize: 25 })
368 await $.state.set(RUNS, parseRuns(text))
369 await $.state.set(ERROR, null)
370 } catch (err) {
371 await $.state.set(ERROR, err instanceof Error ? err.message : String(err))
372 }
373}
374
375async function loadWatch($: EngineInterface): Promise<void> {
376 const { value: id = null } = await $.state.get(WATCHING)
377 if (id === null) return
378 try {
379 const text = await rpc($, LIST_ACTIVITY, { pageSize: 40, filter: { units: [id] } })
380 await $.state.set(WATCH_LINES, parseActivity(text))
381 } catch (err) {
382 await $.state.set(ERROR, err instanceof Error ? err.message : String(err))
383 }
384}
385
386async function pollJobs($: EngineInterface): Promise<void> {
387 const { value: tab = 'jobs' } = await $.state.get(TAB)
388 if (tab === 'jobs' && (await isPaneOpen($))) await loadJobs($)
389}
390
391async function pollRuns($: EngineInterface): Promise<void> {
392 const { value: tab = 'jobs' } = await $.state.get(TAB)
393 if (tab === 'runs' && (await isPaneOpen($))) await loadRuns($)
394}
395
396async function pollWatch($: EngineInterface): Promise<void> {
397 if (await isPaneOpen($)) await loadWatch($)
398}
399
400async function tick($: EngineInterface): Promise<void> {
401 const { value: cache = null } = await $.state.get(CACHE)
402 if (cache === null) return
403 await $.state.set(TICK, await $.clock.now())
404 await refreshStatus($)
405}
406
407async function showTab($: EngineInterface, tab: Tab): Promise<void> {
408 await $.state.set(TAB, tab)
409 if (tab === 'jobs') await loadJobs($)
410 if (tab === 'runs') await loadRuns($)
411}
412
413async function recordSpawn($: EngineInterface, row: AgentRow): Promise<void> {
414 const { value: rows = [] } = await $.state.get(AGENTS)
415 await $.state.set(AGENTS, [row, ...rows].slice(0, 50))
416}
417
418async function recordFinish($: EngineInterface, agentId: string, outcome: string): Promise<void> {
419 const { value: rows = [] } = await $.state.get(AGENTS)
420 if (!rows.some(r => r.agentId === agentId && r.finishedAt === 0)) return
421 const at = await $.clock.now()
422 await $.state.set(
423 AGENTS,
424 rows.map(r => (r.agentId === agentId && r.finishedAt === 0 ? { ...r, finishedAt: at, outcome } : r)),
425 )
426}
427
428// Opens the first of candidates the job store holds, after a fresh read so a job forked
429// since the last poll is found.
430async function openJob($: EngineInterface, candidates: string[]): Promise<void> {
431 await showTab($, 'jobs')
432 const { value: listing = null } = await $.state.get(LISTING)
433 const id = candidates.find(c => listing?.rows.some(r => r.id === c)) ?? candidates[0] ?? null
434 await $.state.set(SELECTED, id)
435 await $.state.set(FILTER, 'all')
436}
437
438export const register: Register = (on, options) => {
439 settings = readSettings(options)
440
441 on('session.start', async ($, e, next) => {
442 await $.command.register({ name: 'magus', description: "Show magus's jobs, runs and this session's agents" })
443 await loadRates($)
444 scheduleHealth($)
445 $.clock.every(HEALTH_POLL_MS, () => void checkHealth($))
446 $.clock.every(JOBS_POLL_MS, () => void pollJobs($))
447 $.clock.every(RUNS_POLL_MS, () => void pollRuns($))
448 $.clock.every(WATCH_POLL_MS, () => void pollWatch($))
449 $.clock.every(TICK_MS, () => void tick($))
450 return next(e)
451 })
452
453 on('command.run', { command: 'magus' }, async $ => {
454 await $.ui.open({ id: PANE, title: 'magus', focus: true })
455 $.clock.after(0, () => void loadJobs($))
456 return { text: 'magus pane opened.' }
457 })
458
459 on('turn.complete', async ($, e, next) => {
460 if (e.agentId) {
461 await recordFinish($, e.agentId, e.isAborted ? 'aborted' : e.reason)
462 if (e.usage) await recordAgentUsage($, e.agentId, e.usage)
463 } else {
464 await recordMainResponse($, e.usage)
465 // Catches the rebuild or server restart a turn just made.
466 if ((await $.clock.now()) - healthAt >= HEALTH_GAP_MS) scheduleHealth($)
467 }
468 return next(e)
469 })
470
471 on('classic.PostModelSwitch', async ($, e, next) => {
472 engineTtl = e.cache_ttl
473 const { value: cache = null } = await $.state.get(CACHE)
474 if (cache !== null && settings.cacheTtl === 'auto') {
475 await $.state.set(CACHE, { ...cache, ttl: e.cache_ttl, ttlSource: 'engine', model: e.to_model })
476 }
477 return next(e)
478 })
479
480 on('classic.SessionStart', async ($, e, next) => {
481 if (e.prompt_cache_likely_expired && e.context_tokens) {
482 const usd = e.estimated_cache_write_usd
483 $.ui.toast(
484 `Resumed with an expired prompt cache: the first prompt re-caches ${formatTokens(e.context_tokens)} tokens` +
485 (usd === undefined ? '.' : ` (≈${formatUsd(usd)}, Claude Code's estimate).`),
486 )
487 }
488 return next(e)
489 })
490
491 on('prompt.submit', async ($, e, next) => {
492 if (e.origin && e.origin.kind !== 'composer') return next(e)
493 const { value: cache = null } = await $.state.get(CACHE)
494 if (cache === null) return next(e)
495 const { value: spend = NO_SPEND } = await $.state.get(SPEND)
496
497 const found: { mode: Gate; text: string }[] = []
498 if (settings.cacheGate !== 'off') {
499 const n = cacheNotice(cache, await $.clock.now(), 0, settings.gateMinTokens, rates)
500 if (n?.isExpired) found.push({ mode: settings.cacheGate, text: n.text })
501 }
502 if (settings.priceStepGate !== 'off') {
503 const n = priceStepNotice(rates, cache.model, cache.contextTokens, settings.priceStepMarginTokens)
504 if (n) found.push({ mode: settings.priceStepGate, text: n.text })
505 }
506 if (settings.budgetGate !== 'off') {
507 const text = budgetNotice(spend, settings.sessionBudgetUsd)
508 if (text) found.push({ mode: settings.budgetGate, text })
509 }
510 for (const g of found) if (g.mode === 'warn') $.ui.toast(g.text)
511
512 const holds = found.filter(g => g.mode === 'confirm')
513 if (holds.length === 0) return next(e)
514 // One hold per response covers every gate: each reads what that response left.
515 const { value: heldFor = 0 } = await $.state.get(HELD_FOR)
516 if (heldFor === cache.lastResponseAt) return next(e)
517 await $.state.set(HELD_FOR, cache.lastResponseAt)
518 const text = e.text
519 $.clock.after(0, () => void $.prompt.fill({ text }))
520 return { drop: `Held: ${holds.map(g => g.text).join(' ')} Submit again to send it.` }
521 })
522
523 on('agent.spawn', async ($, e, next) => {
524 const cap = settings.spawnMaxInputUsdPerMTok
525 const isGated = settings.spawnGate !== 'off'
526 // A failed read must not stop the spawn from being recorded below.
527 const model = isGated ? await spawnModel($, e).catch(() => null) : null
528 const over = model === null ? null : overSpawnCap(rates, model, cap)
529 if (over && settings.spawnGate === 'deny') return { deny: spawnRefusal(over, cap, e.fork) }
530 if (over) $.ui.toast(spawnWarning(over, cap))
531
532 const r = await next(e)
533 // An unreadable definition leaves the model unknown until the engine resolves it. The
534 // spawn has started by then, so even deny mode can only say so.
535 if (isGated && model === null && !r.deny) {
536 const late = overSpawnCap(rates, pricedModel(rates, r.model, e.parentModel) ?? '', cap)
537 if (late) $.ui.toast(spawnWarning(late, cap))
538 }
539 if (!r.deny && r.agentId) {
540 await recordSpawn($, {
541 agentId: r.agentId,
542 description: e.description,
543 model: r.model,
544 isBackground: e.background,
545 jobs: candidateJobs(e.description),
546 startedAt: await $.clock.now(),
547 finishedAt: 0,
548 outcome: '',
549 tokens: 0,
550 usd: 0,
551 })
552 }
553 return r
554 })
555
556 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
557 if (e.props.hasSurvey) return next(e)
558 await $.state.get(TICK)
559 const { value: report = null } = await $.state.get(REPORT)
560 const { value: hidden = null } = await $.state.get(DISMISSED)
561 const { value: cache = null } = await $.state.get(CACHE)
562 const { value: ratesError = null } = await $.state.get(RATES_ERROR)
563 const now = await $.clock.now()
564
565 const issues = report && report.issues.length && hidden !== signature(report.issues) ? report.issues : []
566 const notice = cacheNotice(cache, now, settings.cacheWarnMs, settings.gateMinTokens, rates)
567 const ageDays = rateAgeDays(rates, now)
568 const staleRates = notice !== null && ageDays !== null && ageDays > settings.ratesStaleDays
569 if (issues.length === 0 && notice === null && ratesError === null) return next(e)
570
571 const { Box, Button, Text } = $.ui.resolve(e)
572 const room = Math.max(1, e.props.maxRows - 1)
573 const shown = issues.slice(0, room)
574
575 return (
576 <Box flexDirection="column">
577 {notice !== null ? (
578 <Text key="cache" color={notice.isExpired ? 'red' : 'yellow'} wrap="truncate-end">
579 ⏳ {notice.text}
580 {staleRates ? ` Rates checked ${ageDays} days ago.` : ''}
581 </Text>
582 ) : null}
583 {ratesError !== null ? (
584 <Text key="rates" color="red" wrap="truncate-end">
585 ✖ {ratesError}
586 </Text>
587 ) : null}
588 {shown.map(i => (
589 <Box key={`row-${i.id}`} gap={1}>
590 <Box flexShrink={1}>
591 <Text color={i.level === 'error' ? 'red' : 'yellow'} wrap="truncate-end">
592 {i.level === 'error' ? '✖' : '⚠'} {i.title}
593 </Text>
594 </Box>
595 {i.fix !== undefined ? (
596 <Button
597 key={`copy-${i.id}`}
598 label="Copy fix"
599 onPress={p => {
600 void $.ui.copy({ text: i.fix ?? '', surface: p.surface })
601 $.ui.toast(`Copied: ${i.fix}`)
602 }}
603 />
604 ) : null}
605 </Box>
606 ))}
607 {issues.length ? (
608 <Box gap={1}>
609 <Button key="recheck" label="Recheck" onPress={() => scheduleHealth($)} />
610 <Button key="hide" label="Hide" onPress={() => void $.state.set(DISMISSED, signature(issues))} />
611 </Box>
612 ) : null}
613 </Box>
614 )
615 })
616
617 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
618 const { Box, Button, Text } = $.ui.resolve(e)
619 const { value: tab = 'jobs' } = await $.state.get(TAB)
620 const { value: error = null } = await $.state.get(ERROR)
621 const now = await $.clock.now()
622
623 const tabButton = (t: Tab, label: string, hotkey: string) => (
624 <Button
625 key={`tab-${t}`}
626 label={label}
627 hotkey={hotkey}
628 variant={tab === t ? 'primary' : 'secondary'}
629 onPress={() => $.clock.after(0, () => void showTab($, t))}
630 />
631 )
632 const { value: spend = NO_SPEND } = await $.state.get(SPEND)
633 const header = (
634 <Box flexDirection="column">
635 <Box gap={1}>
636 {tabButton('jobs', 'Jobs', '1')}
637 {tabButton('runs', 'Runs', '2')}
638 {tabButton('agents', 'Agents', '3')}
639 </Box>
640 {sessionUsd(spend) > 0 || spend.isPartial ? (
641 <Text key="spend" dimColor wrap="truncate-end">
642 Session spend {spendStatus(spend, settings.sessionBudgetUsd)} ({spendBreakdown(spend)})
643 </Text>
644 ) : null}
645 </Box>
646 )
647 const errorLine = error !== null ? <Text color="red" wrap="wrap">{error}</Text> : null
648
649 if (tab === 'agents') {
650 const { value: agents = [] } = await $.state.get(AGENTS)
651 const budget = settings.subagentTokenBudget
652 return (
653 <Box flexDirection="column">
654 {header}
655 {agents.length === 0 ? <Text dimColor>No subagents spawned in this session yet.</Text> : null}
656 {agents.map(a => {
657 const over = budget > 0 && a.tokens > budget
658 return (
659 <Box key={`agent-${a.agentId}`} gap={1}>
660 <Text color={a.finishedAt === 0 ? 'cyan' : a.outcome === 'answer' ? 'green' : 'yellow'}>
661 {a.finishedAt === 0 ? '●' : a.outcome === 'answer' ? '✓' : '◐'}
662 </Text>
663 <Text wrap="truncate-end">{a.description}</Text>
664 <Text dimColor>
665 {[
666 a.model,
667 a.isBackground ? 'background' : '',
668 age(((a.finishedAt || now) - a.startedAt) / 1000),
669 a.tokens ? `${formatTokens(a.tokens)} tokens` : '',
670 a.tokens && a.usd !== null ? formatUsd(a.usd) : '',
671 ]
672 .filter(Boolean)
673 .join(' · ')}
674 </Text>
675 {over ? <Text color="yellow">over {formatTokens(budget)} budget</Text> : null}
676 {a.jobs.length ? (
677 <Button
678 key={`agent-job-${a.agentId}`}
679 plain
680 label={`job ${a.jobs[0]}`}
681 onPress={() => $.clock.after(0, () => void openJob($, a.jobs))}
682 />
683 ) : null}
684 </Box>
685 )
686 })}
687 </Box>
688 )
689 }
690
691 if (tab === 'runs') {
692 const { value: runs = null } = await $.state.get(RUNS)
693 return (
694 <Box flexDirection="column">
695 {header}
696 {errorLine}
697 {runs === null && error === null ? <Text dimColor>reading…</Text> : null}
698 {(runs ?? []).map(r => {
699 const running = r.endedAt === 0
700 const failed = r.status === 'fail'
701 return (
702 <Box key={`run-${r.id}`} gap={1}>
703 <Text color={running ? 'cyan' : failed ? 'red' : 'green'}>{running ? '●' : failed ? '✗' : '✓'}</Text>
704 <Text wrap="truncate-end">magus {r.command}</Text>
705 <Text dimColor>
706 {running
707 ? `${age((now - r.startedAt) / 1000)} so far`
708 : `${age((r.endedAt - r.startedAt) / 1000)} · ${age((now - r.endedAt) / 1000)} ago`}
709 </Text>
710 <Button key={`copy-${r.id}`} plain label={r.id} onPress={p => void $.ui.copy({ text: r.id, surface: p.surface })} />
711 </Box>
712 )
713 })}
714 </Box>
715 )
716 }
717
718 const { value: listing = null } = await $.state.get(LISTING)
719 const { value: filter = 'live' } = await $.state.get(FILTER)
720 const { value: selected = null } = await $.state.get(SELECTED)
721 const { value: watching = null } = await $.state.get(WATCHING)
722 const { value: watchLines = [] } = await $.state.get(WATCH_LINES)
723 const { value: agents = [] } = await $.state.get(AGENTS)
724
725 const lines = listing ? treeLines(listing.rows, filter, now / 1000, new Set(listing.stale)) : []
726 const hiddenStale = filter === 'live' && listing ? listing.stale.length : 0
727 const shown = lines.slice(0, MAX_LINES)
728 const picked = listing?.rows.find(r => r.id === selected) ?? null
729 const flags = (id: string): string[] => {
730 if (!listing) return []
731 const out: string[] = []
732 if (listing.overdue.includes(id)) out.push('overdue')
733 if (listing.stale.includes(id)) out.push('stale')
734 if (listing.orphans.includes(id)) out.push('orphaned')
735 const b = listing.blocked.find(x => x.job === id)
736 if (b) out.push(`blocked on ${b.on}`)
737 if (agents.some(a => a.jobs.includes(id) && a.finishedAt === 0)) out.push('agent running')
738 return out
739 }
740
741 const filterButton = (f: Filter, label: string, hotkey: string) => (
742 <Button
743 key={`filter-${f}`}
744 label={label}
745 hotkey={hotkey}
746 variant={filter === f ? 'primary' : 'secondary'}
747 onPress={() => void $.state.set(FILTER, f)}
748 />
749 )
750
751 return (
752 <Box flexDirection="column">
753 {header}
754 <Box gap={1}>
755 {filterButton('live', 'Live', 'l')}
756 {filterButton('recent', 'Today', 't')}
757 {filterButton('all', 'All', 'a')}
758 <Text dimColor wrap="truncate-end">
759 {listing
760 ? [
761 `${lines.length} shown of ${listing.rows.length}`,
762 hiddenStale ? `${hiddenStale} stale hidden` : '',
763 `${age((now - listing.fetchedAt) / 1000)} ago`,
764 ]
765 .filter(Boolean)
766 .join(' · ')
767 : 'reading…'}
768 </Text>
769 </Box>
770 {errorLine}
771 {listing && lines.length === 0 ? <Text dimColor>{filter === 'live' ? 'No live jobs.' : 'No jobs match.'}</Text> : null}
772 {shown.map(({ row, depth, isContext }) => {
773 const f = flags(row.id)
774 return (
775 <Box key={`line-${row.id}`} gap={1}>
776 <Text>{' '.repeat(depth)}</Text>
777 <Text color={COLOR[row.state]} dimColor={isContext}>
778 {GLYPH[row.state]}
779 </Text>
780 <Button
781 key={`sel-${row.id}`}
782 plain
783 dimColor={isContext}
784 label={leaf(row.id, row.parent)}
785 onPress={() => void $.state.set(SELECTED, selected === row.id ? null : row.id)}
786 />
787 <Text dimColor wrap="truncate-end">
788 {[row.state, row.model, row.holder, `${age(now / 1000 - row.updated)} ago`].filter(Boolean).join(' · ')}
789 </Text>
790 {f.length ? <Text color="yellow">{f.join(' · ')}</Text> : null}
791 </Box>
792 )
793 })}
794 {lines.length > shown.length ? <Text dimColor>… {lines.length - shown.length} more; narrow the filter</Text> : null}
795 {picked ? (
796 <Box flexDirection="column" marginTop={1} borderStyle="round" paddingX={1}>
797 <Text bold wrap="truncate-end">
798 {GLYPH[picked.state]} {picked.id}
799 </Text>
800 {picked.criteria ? <Text wrap="wrap">{picked.criteria}</Text> : null}
801 {picked.check ? <Text dimColor wrap="truncate-end">check: {picked.check}</Text> : null}
802 {picked.writePaths.length ? (
803 <Text dimColor wrap="truncate-end">
804 writes: {picked.writePaths.slice(0, 6).join(', ')}
805 {picked.writePaths.length > 6 ? ` +${picked.writePaths.length - 6}` : ''}
806 </Text>
807 ) : null}
808 {picked.goals.length ? <Text dimColor wrap="truncate-end">goals: {picked.goals.join(', ')}</Text> : null}
809 {picked.proof || picked.baseVerdict ? (
810 <Text dimColor>
811 {[picked.proof && `write proof: ${picked.proof}`, picked.baseVerdict && `base: ${picked.baseVerdict}`]
812 .filter(Boolean)
813 .join(' · ')}
814 </Text>
815 ) : null}
816 {picked.endReason ? <Text color="yellow" wrap="wrap">ended: {picked.endReason}</Text> : null}
817 <Box gap={1}>
818 {watching === picked.id ? (
819 <Button key="unwatch" label="Stop watching" hotkey="w" onPress={() => void $.state.set(WATCHING, null)} />
820 ) : (
821 <Button
822 key="watch"
823 label="Watch"
824 hotkey="w"
825 onPress={() =>
826 $.clock.after(0, async () => {
827 await $.state.set(WATCHING, picked.id)
828 await $.state.set(WATCH_LINES, [])
829 await loadWatch($)
830 })
831 }
832 />
833 )}
834 <Button
835 key="copy-describe"
836 label="Copy describe"
837 onPress={p => void $.ui.copy({ text: `magus describe job ${picked.id}`, surface: p.surface })}
838 />
839 </Box>
840 {watching === picked.id ? (
841 watchLines.length ? (
842 watchLines.map((l, i) => (
843 <Text
844 key={`w-${i}`}
845 dimColor={l.outcome !== 'error'}
846 color={l.outcome === 'error' ? 'red' : undefined}
847 wrap="truncate-end"
848 >
849 {new Date(l.at).toLocaleTimeString()} {l.kind} {l.action} {l.preview}
850 </Text>
851 ))
852 ) : (
853 <Text dimColor>No activity recorded under this job yet.</Text>
854 )
855 ) : null}
856 </Box>
857 ) : null}
858 </Box>
859 )
860 })
861}
862hooks/api.ts 118 lines1import type { ActivityLine, AgentRow, JobBlock, JobRow, JobState, Listing, RunRow } from '../types'
2
3export type { ActivityLine, AgentRow, JobBlock, JobRow, Listing, RunRow }
4
5// The Connect procedures the pane calls, the same ones the console's PWA calls.
6export const LIST_JOBS = '/magus.job.v1alpha1.JobService/ListJobs'
7export const LIST_INVOCATIONS = '/magus.viewer.v1alpha1.ViewerService/ListInvocations'
8export const LIST_ACTIVITY = '/magus.activity.v1alpha1.ActivityService/ListActivityEvents'
9
10// The server's socket from `magus server status -o json`: pool.socket with its unix://
11// scheme dropped, or null when no server answered.
12export function socketFromStatus(stdout: string): string | null {
13 try {
14 const s = JSON.parse(stdout) as { server?: unknown; pool?: { socket?: string } }
15 const socket = s.server != null ? (s.pool?.socket ?? '') : ''
16 return socket ? socket.replace(/^unix:\/\//, '') : null
17 } catch {
18 return null
19 }
20}
21
22// A Connect error body ({ code, message }) as one line; the raw text when it is not one.
23export function connectError(status: number, text: string): string {
24 try {
25 const e = JSON.parse(text) as { code?: string; message?: string }
26 if (e.code || e.message) return `${e.code ?? status}: ${e.message ?? ''}`.trim()
27 } catch {
28 // not JSON
29 }
30 return `HTTP ${status}${text ? `: ${text.slice(0, 200)}` : ''}`
31}
32
33// protojson writes int64 as a string and leaves zero values out.
34const int = (v: unknown): number => (typeof v === 'number' ? v : typeof v === 'string' ? Number(v) || 0 : 0)
35const time = (v: unknown): number => (typeof v === 'string' ? Date.parse(v) || 0 : 0)
36const strs = (v: unknown): string[] => (Array.isArray(v) ? v.map(String) : [])
37const enumWord = (v: unknown, prefix: string): string =>
38 typeof v === 'string' ? v.replace(prefix, '').toLowerCase() : ''
39
40type RawJob = Record<string, unknown> & { goals?: { id?: string; kind?: string }[] }
41
42export function parseJobs(text: string, fetchedAt: number): Listing {
43 const doc = JSON.parse(text) as {
44 jobs?: RawJob[]
45 overdue?: string[]
46 orphans?: string[]
47 stale?: string[]
48 blocked?: { job?: string; on?: string; state?: string }[]
49 }
50 const rows: JobRow[] = (doc.jobs ?? []).map(j => {
51 const holder = enumWord(j.holder, 'JOB_HOLDER_')
52 return {
53 id: String(j.id ?? ''),
54 parent: String(j.parent ?? ''),
55 state: (String(j.state ?? '') || 'declared') as JobState,
56 model: String(j.model ?? ''),
57 holder: holder === 'session' ? '' : holder,
58 criteria: String(j.criteria ?? j.description ?? ''),
59 check: String(j.check ?? ''),
60 writePaths: strs(j.writePaths),
61 goals: (j.goals ?? []).map(g => `${g.kind ?? '?'} ${g.id ?? ''}`.trim()),
62 proof: String(j.writeProof ?? ''),
63 endReason: String(j.endReason ?? ''),
64 baseVerdict: String(j.baseVerdict ?? ''),
65 updated: int(j.updated),
66 }
67 })
68 const blocked: JobBlock[] = (doc.blocked ?? []).map(b => ({
69 job: b.job ?? '',
70 on: b.on ?? '',
71 state: b.state ?? '',
72 }))
73 return {
74 rows,
75 overdue: strs(doc.overdue),
76 orphans: strs(doc.orphans),
77 stale: strs(doc.stale),
78 blocked,
79 fetchedAt,
80 }
81}
82
83export function parseRuns(text: string): RunRow[] {
84 const doc = JSON.parse(text) as {
85 invocations?: { id?: string; command?: { arguments?: string[] }; startTime?: string; endTime?: string; status?: string }[]
86 }
87 return (doc.invocations ?? []).map(i => ({
88 id: i.id ?? '',
89 command: (i.command?.arguments ?? []).join(' '),
90 startedAt: time(i.startTime),
91 endedAt: time(i.endTime),
92 status: enumWord(i.status, 'STATUS_'),
93 }))
94}
95
96export function parseActivity(text: string): ActivityLine[] {
97 const doc = JSON.parse(text) as {
98 events?: { time?: string; kind?: string; action?: string; outcome?: string; actor?: string; preview?: string }[]
99 }
100 return (doc.events ?? []).map(e => ({
101 at: time(e.time),
102 kind: enumWord(e.kind, 'KIND_'),
103 action: e.action ?? '',
104 outcome: enumWord(e.outcome, 'OUTCOME_'),
105 actor: e.actor ?? '',
106 preview: e.preview ?? '',
107 }))
108}
109
110// The jobs a spawn description `<parent>/<role> <job>` can name, in the order the guard
111// resolves them: `<job>`, else `<parent>/<job>`. Empty for any other description.
112export function candidateJobs(description: string): string[] {
113 const words = description.trim().split(/\s+/)
114 if (words.length !== 2 || !words[0]!.includes('/')) return []
115 const parent = words[0]!.slice(0, words[0]!.lastIndexOf('/'))
116 return [words[1]!, `${parent}/${words[1]!}`]
117}
118hooks/cost.ts 240 lines1import type { CacheState, Spend, Ttl } from '../types'
2
3export type { CacheState, Spend, Ttl }
4
5// USD per million tokens. cacheRead absent means the table's default multiplier of input.
6export type Tier = { abovePromptTokens: number; input: number; output: number; cacheRead?: number }
7export type ModelRates = { match: string; input: number; output: number; cacheRead?: number; tiers?: Tier[] }
8export type RateTable = {
9 checked: string
10 source: string
11 cacheWriteMultiplier: Record<Ttl, number>
12 defaultCacheReadMultiplier: number
13 models: ModelRates[]
14}
15
16// The rates one request pays, USD per million tokens, after model and tier are chosen.
17export type Rates = { input: number; output: number; cacheRead: number; cacheWrite: Record<Ttl, number> }
18
19export type Usage = { input: number; output: number; cacheRead: number; cacheCreation: number }
20
21export const TTL_MS: Record<Ttl, number> = { '5m': 5 * 60_000, '1h': 60 * 60_000 }
22
23const isNum = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v) && v >= 0
24
25// Reads a rates file, throwing a message that names the first field it could not use, so
26// an edit that breaks the table says where rather than silently pricing at zero.
27export function parseRates(text: string): RateTable {
28 const t = JSON.parse(text) as Partial<RateTable>
29 if (!t.cacheWriteMultiplier || !isNum(t.cacheWriteMultiplier['5m']) || !isNum(t.cacheWriteMultiplier['1h'])) {
30 throw new Error('rates: cacheWriteMultiplier needs numeric "5m" and "1h"')
31 }
32 if (!isNum(t.defaultCacheReadMultiplier)) throw new Error('rates: defaultCacheReadMultiplier must be a number')
33 if (!Array.isArray(t.models)) throw new Error('rates: models must be a list')
34 t.models.forEach((m, i) => {
35 if (typeof m.match !== 'string' || !m.match) throw new Error(`rates: models[${i}].match must name a model id prefix`)
36 if (!isNum(m.input) || !isNum(m.output)) throw new Error(`rates: models[${i}] (${m.match}) needs numeric input and output`)
37 m.tiers?.forEach((tier, j) => {
38 if (!isNum(tier.abovePromptTokens) || !isNum(tier.input) || !isNum(tier.output)) {
39 throw new Error(`rates: models[${i}].tiers[${j}] needs numeric abovePromptTokens, input and output`)
40 }
41 })
42 })
43 return { ...(t as RateTable), checked: String(t.checked ?? ''), source: String(t.source ?? '') }
44}
45
46// The table entry for model: the longest matching prefix wins, so `claude-opus-5-5` beats
47// `claude-opus`, and a bracketed variant suffix (`[1m]`) is ignored.
48function entryFor(table: RateTable, model: string): ModelRates | undefined {
49 const id = model.replace(/\[.*\]$/, '')
50 return table.models.filter(m => id.startsWith(m.match)).sort((a, b) => b.match.length - a.match.length)[0]
51}
52
53// The rates for model at a prompt of promptTokens, or null when the table has no entry.
54export function ratesFor(table: RateTable, model: string, promptTokens: number): Rates | null {
55 const entry = entryFor(table, model)
56 if (!entry) return null
57 const tier = (entry.tiers ?? [])
58 .filter(t => promptTokens > t.abovePromptTokens)
59 .sort((a, b) => b.abovePromptTokens - a.abovePromptTokens)[0]
60 const input = tier?.input ?? entry.input
61 const output = tier?.output ?? entry.output
62 const cacheRead = tier?.cacheRead ?? (tier ? undefined : entry.cacheRead) ?? input * table.defaultCacheReadMultiplier
63 return {
64 input,
65 output,
66 cacheRead,
67 cacheWrite: { '5m': input * table.cacheWriteMultiplier['5m'], '1h': input * table.cacheWriteMultiplier['1h'] },
68 }
69}
70
71// What re-caching tokens of context costs on model, or null when the model is not priced.
72export function recacheUsd(table: RateTable, model: string, tokens: number, ttl: Ttl): number | null {
73 const r = ratesFor(table, model, tokens)
74 return r ? (tokens * r.cacheWrite[ttl]) / 1e6 : null
75}
76
77// What one request cost, priced at its own prompt size. Cache writes are priced at ttl
78// because the API reports writes without saying which TTL each token took.
79export function requestUsd(table: RateTable, model: string, u: Usage, ttl: Ttl): number | null {
80 const prompt = u.input + u.cacheRead + u.cacheCreation
81 const r = ratesFor(table, model, prompt)
82 if (!r) return null
83 return (u.input * r.input + u.output * r.output + u.cacheRead * r.cacheRead + u.cacheCreation * r.cacheWrite[ttl]) / 1e6
84}
85
86// Milliseconds left before the cache written by the last response expires; null before
87// any response. The entry's clock starts when its request started, so measuring from the
88// response's end overstates what is left by that request's generation time.
89export function cacheRemainingMs(cache: CacheState | null, now: number): number | null {
90 if (!cache || cache.lastResponseAt === 0) return null
91 return cache.lastResponseAt + TTL_MS[cache.ttl] - now
92}
93
94// What one response's usage says about the TTL: a cache hit after a gap longer than 5
95// minutes means the entry lives for an hour; a full rewrite after a gap of 5 to 60 minutes
96// means it lived for 5. null when the response settles nothing.
97export function inferTtl(gapMs: number, u: Usage): Ttl | null {
98 const reread = u.cacheRead > 0 && u.cacheRead >= u.cacheCreation
99 if (gapMs > TTL_MS['5m'] && reread) return '1h'
100 if (gapMs > TTL_MS['5m'] && gapMs < TTL_MS['1h'] && u.cacheRead === 0 && u.cacheCreation > 0) return '5m'
101 return null
102}
103
104export type CacheNotice = { isExpired: boolean; usd: number | null; text: string }
105
106// The warning for the band and the gate: null while the cache has more than warnMs left,
107// or the context is under minTokens, or nothing has answered yet.
108export function cacheNotice(
109 cache: CacheState | null,
110 now: number,
111 warnMs: number,
112 minTokens: number,
113 table: RateTable,
114): CacheNotice | null {
115 const left = cacheRemainingMs(cache, now)
116 if (cache === null || left === null || cache.contextTokens < minTokens || left > warnMs) return null
117 const usd = recacheUsd(table, cache.model, cache.contextTokens, cache.ttl)
118 const cost = usd === null ? '' : ` (≈${formatUsd(usd)})`
119 const tokens = formatTokens(cache.contextTokens)
120 const ttl = `TTL ${cache.ttl}, ${cache.ttlSource}`
121 const isExpired = left <= 0
122 const text = isExpired
123 ? `Cache expired ${formatDuration(-left)} ago: your next prompt re-caches ${tokens} tokens${cost} (${ttl}).`
124 : `Cache expires in ${formatDuration(left)}: after that your next prompt re-caches ${tokens} tokens${cost} (${ttl}).`
125 return { isExpired, usd, text }
126}
127
128// The cache's part of the status line; undefined before any response.
129export function cacheStatus(cache: CacheState | null, now: number): string | undefined {
130 const left = cacheRemainingMs(cache, now)
131 if (left === null) return undefined
132 return left > 0 ? `cache ${formatDuration(left)}` : 'cache expired'
133}
134
135export type StepNotice = { isPast: boolean; text: string }
136
137// The price step the next request meets, or null while the context is more than marginTokens
138// under every step of model's tiers, or the model has none. A request re-sends the whole
139// context, so past a step every request pays the higher rate until the context shrinks.
140export function priceStepNotice(table: RateTable, model: string, contextTokens: number, marginTokens: number): StepNotice | null {
141 const tier = (entryFor(table, model)?.tiers ?? [])
142 .filter(t => contextTokens > t.abovePromptTokens - marginTokens)
143 .sort((a, b) => b.abovePromptTokens - a.abovePromptTokens)[0]
144 if (!tier) return null
145 const low = ratesFor(table, model, tier.abovePromptTokens)
146 const high = ratesFor(table, model, tier.abovePromptTokens + 1)
147 if (!low || !high) return null
148 const step = formatTokens(tier.abovePromptTokens)
149 const tokens = formatTokens(contextTokens)
150 const isPast = contextTokens > tier.abovePromptTokens
151 const text = isPast
152 ? `Context is ${tokens} tokens, past the ${step} step: every request on ${model} now pays ${formatUsd(high.input)} per MTok input and ${formatUsd(high.output)} output, up from ${formatUsd(low.input)} and ${formatUsd(low.output)}. Consider /compact.`
153 : `Context is ${tokens} tokens, ${formatTokens(tier.abovePromptTokens - contextTokens)} under the ${step} step where ${model} rises from ${formatUsd(low.input)} to ${formatUsd(high.input)} per MTok input and ${formatUsd(low.output)} to ${formatUsd(high.output)} output. Consider /compact before it crosses.`
154 return { isPast, text }
155}
156
157// The table id a subagent's model setting prices as. `inherit` is the parent's model; an
158// alias (`haiku`) is the table's first entry of that family, since the table is the only
159// source of prices. null when the table prices nothing under it.
160export function pricedModel(table: RateTable, model: string, parentModel: string): string | null {
161 const m = model.replace(/\[.*\]$/, '').trim().toLowerCase()
162 const id = m === '' || m === 'inherit' ? parentModel : m
163 if (entryFor(table, id)) return id
164 return table.models.find(e => e.match.split('-')[1] === id)?.match ?? null
165}
166
167// The `model:` value of a markdown agent definition's frontmatter, if it sets one.
168export function frontmatterModel(text: string): string | undefined {
169 const head = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text)?.[1]
170 const value = head && /^model:\s*(.+?)\s*$/m.exec(head)?.[1]
171 return value ? value.replace(/^["']|["']$/g, '') : undefined
172}
173
174export type SpawnCost = { model: string; input: number }
175
176// The spawn's model and its input rate when that rate is over capUsd per MTok. null when
177// the cap is off, the model is unpriced, or the rate is within the cap.
178export function overSpawnCap(table: RateTable, model: string, capUsd: number): SpawnCost | null {
179 const r = capUsd > 0 ? ratesFor(table, model, 0) : null
180 return r && r.input > capUsd ? { model, input: r.input } : null
181}
182
183export function spawnWarning(c: SpawnCost, capUsd: number): string {
184 return `Subagent will run on ${c.model} at ${formatUsd(c.input)} per MTok input, over your ${formatUsd(capUsd)} cap.`
185}
186
187export function spawnRefusal(c: SpawnCost, capUsd: number, isFork: boolean): string {
188 const route = isFork
189 ? 'A fork always inherits the session model: use the read-only magus-scout agent for a lookup or proof, or a non-fork agent with model: haiku.'
190 : 'Use the read-only magus-scout agent for a lookup or proof, or pass model: haiku.'
191 return `${spawnWarning(c, capUsd)} ${route}`
192}
193
194export const sessionUsd = (s: Spend): number => s.mainUsd + s.agentUsd
195
196// The status line's part: the total, against the budget when there is one. undefined
197// before anything is spent.
198export function spendStatus(s: Spend, budgetUsd: number): string | undefined {
199 if (sessionUsd(s) === 0 && !s.isPartial) return undefined
200 const total = `${formatUsd(sessionUsd(s))}${s.isPartial ? '+' : ''}`
201 return budgetUsd > 0 ? `${total}/${formatUsd(budgetUsd)}` : total
202}
203
204export function spendBreakdown(s: Spend): string {
205 return `main ${formatUsd(s.mainUsd)}, subagents ${formatUsd(s.agentUsd)}`
206}
207
208// The budget gate's warning; null while the budget is off or not yet passed.
209export function budgetNotice(s: Spend, budgetUsd: number): string | null {
210 if (budgetUsd <= 0 || sessionUsd(s) <= budgetUsd) return null
211 return `Session spend ${formatUsd(sessionUsd(s))} has passed your ${formatUsd(budgetUsd)} budget (${spendBreakdown(s)}).`
212}
213
214export function totalTokens(u: Usage): number {
215 return u.input + u.output + u.cacheRead + u.cacheCreation
216}
217
218export function rateAgeDays(table: RateTable, now: number): number | null {
219 const t = Date.parse(table.checked)
220 return Number.isFinite(t) ? Math.floor((now - t) / 86_400_000) : null
221}
222
223export function formatTokens(n: number): string {
224 if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
225 if (n >= 1_000) return `${Math.round(n / 1_000)}K`
226 return String(n)
227}
228
229export function formatUsd(usd: number): string {
230 if (usd > 0 && usd < 0.01) return '<$0.01'
231 return `$${usd.toFixed(2)}`
232}
233
234export function formatDuration(ms: number): string {
235 const s = Math.max(0, Math.round(ms / 1000))
236 if (s < 60) return `${s}s`
237 if (s < 3600) return `${Math.floor(s / 60)}m`
238 return `${Math.floor(s / 3600)}h${Math.floor((s % 3600) / 60)}m`
239}
240hooks/health.ts 116 lines1import type { Issue } from '../types'
2
3export type { Issue }
4
5export type BinaryInfo = { path: string; version: string; commit: string }
6
7export type ServerInfo = {
8 isRunning: boolean
9 version: string
10 mcpState: string
11 mcpNote: string
12}
13
14export type Probe = {
15 isMagusSource: boolean
16 hasLocalBinary: boolean
17 pathBinary: string | null
18 // The binary the hooks run: ./magus when present, else the one on PATH.
19 binary: BinaryInfo | null
20 head: string | null
21 // Commits from binary.commit to head; null when the commit is not an ancestor of head.
22 behind: number | null
23 // null when the server could not be asked at all.
24 server: ServerInfo | null
25}
26
27export const BOOTSTRAP =
28 'GOEXPERIMENT=jsonv2 go run -trimpath ./cmd/magus run go-build --no-cache .'
29
30const short = (sha: string) => sha.slice(0, 9)
31
32export function assess(p: Probe): Issue[] {
33 const issues: Issue[] = []
34
35 if (!p.hasLocalBinary && p.pathBinary === null) {
36 issues.push({
37 id: 'guard-off',
38 level: 'error',
39 label: 'guard off',
40 title: 'No magus binary: every hook exits 127 and the guard fails open',
41 fix: p.isMagusSource ? BOOTSTRAP : undefined,
42 })
43 return issues
44 }
45
46 if (p.isMagusSource && !p.hasLocalBinary) {
47 const built = p.binary ? ` (built at ${short(p.binary.commit)})` : ''
48 issues.push({
49 id: 'borrowed-binary',
50 level: 'warn',
51 label: 'no ./magus',
52 title: `No ./magus here: hooks run ${p.pathBinary}${built}, with another tree's guard rules`,
53 fix: BOOTSTRAP,
54 })
55 } else if (p.isMagusSource && p.binary && p.head && p.binary.commit !== p.head) {
56 const where =
57 p.behind === null
58 ? `built from ${short(p.binary.commit)}, HEAD is ${short(p.head)}`
59 : `${p.behind} commit${p.behind === 1 ? '' : 's'} behind HEAD`
60 issues.push({
61 id: 'stale-binary',
62 level: 'warn',
63 label: 'stale ./magus',
64 title: `./magus is ${where}`,
65 fix: './magus run go-build .',
66 })
67 }
68
69 const s = p.server
70 if (s === null) {
71 return issues
72 }
73 if (!s.isRunning) {
74 issues.push({
75 id: 'server-down',
76 level: 'warn',
77 label: 'server down',
78 title: 'No magus server: MCP tools, the warm graph and the console are off',
79 fix: 'magus server start',
80 })
81 return issues
82 }
83 if (p.binary && s.version !== '' && s.version !== p.binary.version) {
84 issues.push({
85 id: 'server-skew',
86 level: 'error',
87 label: 'server skew',
88 title: `Server runs ${s.version}, binary is ${p.binary.version}: the older build drops fields it cannot decode`,
89 fix: 'magus server stop && magus server start',
90 })
91 }
92 if (s.mcpState !== 'serving' && s.mcpState !== 'disabled' && s.mcpState !== '') {
93 issues.push({
94 id: 'mcp',
95 level: 'warn',
96 label: `mcp ${s.mcpState}`,
97 title: `MCP endpoint is ${s.mcpState}${s.mcpNote ? `: ${s.mcpNote}` : ''}`,
98 })
99 }
100 return issues
101}
102
103// The status line entry, which the engine already labels with this mod's name: nothing
104// while magus is healthy, the problems' short labels otherwise.
105export function statusText(issues: Issue[]): string | undefined {
106 if (issues.length === 0) {
107 return undefined
108 }
109 return `⚠ ${issues.map(i => i.label).join(', ')}`
110}
111
112// A stable key for the set of issues, so Hide lasts until the set changes.
113export function signature(issues: Issue[]): string {
114 return issues.map(i => `${i.id}:${i.title}`).join('|')
115}
116hooks/rates.ts 19 lines1import type { RateTable } from './cost'
2
3// The rates used when the ratesFile setting names none. Data only: a price change is an
4// edit here, or a JSON file in this same shape named by the ratesFile setting, which wins.
5// USD per million tokens; cache writes are input times cacheWriteMultiplier for the TTL.
6export const DEFAULT_RATES: RateTable = {
7 checked: '2026-10-10',
8 source: 'Anthropic first-party API list prices; confirm at https://docs.claude.com/en/docs/about-claude/pricing',
9 cacheWriteMultiplier: { '5m': 1.25, '1h': 2 },
10 defaultCacheReadMultiplier: 0.1,
11 // Only Haiku 5.5 has a prompt-size step; the reference states one rate card for the rest.
12 models: [
13 { match: 'claude-fable-5-1', input: 10, output: 50, cacheRead: 0.25 },
14 { match: 'claude-opus-5-5', input: 4, output: 20, cacheRead: 0.2 },
15 { match: 'claude-sonnet-5-5', input: 2, output: 10, cacheRead: 0.2 },
16 { match: 'claude-haiku-5-5', input: 0.1, output: 0.5, tiers: [{ abovePromptTokens: 100_000, input: 0.5, output: 2.5 }] },
17 ],
18}
19hooks/settings.ts 50 lines1import type { PluginOptions } from 'claude-code'
2
3import type { Ttl } from '../types'
4
5export type Gate = 'off' | 'warn' | 'confirm'
6export type SpawnGate = 'off' | 'warn' | 'deny'
7
8export type Settings = {
9 cacheGate: Gate
10 cacheWarnMs: number
11 gateMinTokens: number
12 cacheTtl: 'auto' | Ttl
13 priceStepGate: Gate
14 priceStepMarginTokens: number
15 spawnGate: SpawnGate
16 spawnMaxInputUsdPerMTok: number
17 sessionBudgetUsd: number
18 budgetGate: Gate
19 subagentTokenBudget: number
20 ratesFile: string
21 ratesStaleDays: number
22}
23
24const num = (v: unknown, fallback: number): number => (typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : fallback)
25
26// A confirm or deny that is misspelled reads as the default rather than as off, so a typo
27// never silently drops a guard.
28const pick = <T extends string>(v: unknown, allowed: readonly T[], fallback: T): T => (allowed.includes(v as T) ? (v as T) : fallback)
29
30// The manifest's userConfig values with its defaults, so a value the engine did not fill
31// still reads as the documented default rather than as zero.
32export function readSettings(o: PluginOptions): Settings {
33 const ttl = o.cacheTtl
34 return {
35 cacheGate: pick(o.cacheGate, ['off', 'warn', 'confirm'], 'warn'),
36 cacheWarnMs: num(o.cacheWarnMinutes, 2) * 60_000,
37 gateMinTokens: num(o.gateMinTokens, 50_000),
38 cacheTtl: ttl === '5m' || ttl === '1h' ? ttl : 'auto',
39 priceStepGate: pick(o.priceStepGate, ['off', 'warn', 'confirm'], 'warn'),
40 priceStepMarginTokens: num(o.priceStepMarginTokens, 10_000),
41 spawnGate: pick(o.spawnGate, ['off', 'warn', 'deny'], 'warn'),
42 spawnMaxInputUsdPerMTok: num(o.spawnMaxInputUsdPerMTok, 2),
43 sessionBudgetUsd: num(o.sessionBudgetUsd, 0),
44 budgetGate: pick(o.budgetGate, ['off', 'warn', 'confirm'], 'warn'),
45 subagentTokenBudget: num(o.subagentTokenBudget, 100_000),
46 ratesFile: typeof o.ratesFile === 'string' ? o.ratesFile.trim() : '',
47 ratesStaleDays: num(o.ratesStaleDays, 60),
48 }
49}
50hooks/tree.ts 83 lines1import type { Filter, JobRow, JobState } from '../types'
2
3export type { Filter, JobRow, JobState }
4
5const LIVE: ReadonlySet<string> = new Set(['declared', 'running', 'exited'])
6const RECENT_S = 24 * 60 * 60
7
8export type Line = { row: JobRow; depth: number; isContext: boolean }
9
10// The rows the filter keeps, in tree order. Ancestors of a kept row are drawn as
11// context so a live child never appears without the job it was forked from. live leaves
12// out the rows in stale: a live row nobody has touched within jobs.stale_after is not
13// what a person watching the plan is looking for.
14export function treeLines(rows: JobRow[], filter: Filter, nowS: number, stale: ReadonlySet<string> = new Set()): Line[] {
15 const byId = new Map(rows.map(r => [r.id, r]))
16 const matches = (r: JobRow) =>
17 filter === 'all' ||
18 (filter === 'live' ? LIVE.has(r.state) && !stale.has(r.id) : nowS - r.updated <= RECENT_S)
19
20 const kept = new Set<string>()
21 const context = new Set<string>()
22 for (const r of rows) {
23 if (!matches(r)) continue
24 kept.add(r.id)
25 for (let p = byId.get(r.parent); p && !kept.has(p.id); p = byId.get(p.parent)) {
26 context.add(p.id)
27 }
28 }
29
30 const children = new Map<string, JobRow[]>()
31 const roots: JobRow[] = []
32 for (const r of rows) {
33 if (!kept.has(r.id) && !context.has(r.id)) continue
34 // A parent missing from the store makes the row a root rather than hiding it.
35 const parentShown = byId.has(r.parent) && (kept.has(r.parent) || context.has(r.parent))
36 if (r.parent && parentShown) {
37 children.set(r.parent, [...(children.get(r.parent) ?? []), r])
38 } else {
39 roots.push(r)
40 }
41 }
42
43 const byRecent = (a: JobRow, b: JobRow) => b.updated - a.updated || a.id.localeCompare(b.id)
44 const out: Line[] = []
45 const walk = (r: JobRow, depth: number) => {
46 out.push({ row: r, depth, isContext: !kept.has(r.id) })
47 for (const c of (children.get(r.id) ?? []).sort(byRecent)) walk(c, depth + 1)
48 }
49 for (const r of roots.sort(byRecent)) walk(r, 0)
50 return out
51}
52
53export const GLYPH: Record<JobState, string> = {
54 declared: '○',
55 running: '●',
56 exited: '◐',
57 pass: '✓',
58 fail: '✗',
59 no_return: '⊘',
60}
61
62export const COLOR: Record<JobState, string> = {
63 declared: 'gray',
64 running: 'cyan',
65 exited: 'yellow',
66 pass: 'green',
67 fail: 'red',
68 no_return: 'gray',
69}
70
71export function age(seconds: number): string {
72 if (seconds < 60) return `${Math.max(0, Math.round(seconds))}s`
73 if (seconds < 3600) return `${Math.round(seconds / 60)}m`
74 if (seconds < 86400) return `${Math.round(seconds / 3600)}h`
75 return `${Math.round(seconds / 86400)}d`
76}
77
78// The last segment of an id: a child's full id repeats its parent's, which the
79// indentation already shows.
80export function leaf(id: string, parent: string): string {
81 return parent && id.startsWith(`${parent}/`) ? id.slice(parent.length + 1) : id
82}
83types/index.d.ts 128 lines1export type Level = 'error' | 'warn'
2
3export type Issue = {
4 id: string
5 level: Level
6 label: string
7 title: string
8 fix?: string
9}
10
11export type Report = { issues: Issue[]; checkedAt: number }
12
13export type JobState = 'declared' | 'running' | 'exited' | 'pass' | 'fail' | 'no_return'
14
15export type JobRow = {
16 id: string
17 parent: string
18 state: JobState
19 model: string
20 holder: string
21 criteria: string
22 check: string
23 writePaths: string[]
24 goals: string[]
25 proof: string
26 endReason: string
27 baseVerdict: string
28 // Unix seconds.
29 updated: number
30}
31
32export type JobBlock = { job: string; on: string; state: string }
33
34// One JobService.ListJobs answer, cut to what the pane draws.
35export type Listing = {
36 rows: JobRow[]
37 overdue: string[]
38 orphans: string[]
39 stale: string[]
40 blocked: JobBlock[]
41 // Unix milliseconds of the read.
42 fetchedAt: number
43}
44
45// One ViewerService invocation. Times are unix milliseconds; endedAt is 0 while running.
46export type RunRow = {
47 id: string
48 command: string
49 startedAt: number
50 endedAt: number
51 status: string
52}
53
54// One ActivityService event under a watched job's lease.
55export type ActivityLine = {
56 at: number
57 kind: string
58 action: string
59 outcome: string
60 actor: string
61 preview: string
62}
63
64// One subagent this session spawned. Times are unix milliseconds.
65export type AgentRow = {
66 agentId: string
67 description: string
68 model: string
69 isBackground: boolean
70 // The jobs the spawn description can name, in the order the guard resolves them.
71 jobs: string[]
72 startedAt: number
73 finishedAt: number
74 outcome: string
75 // input + output + cache read + cache write tokens across the subagent's turns.
76 tokens: number
77 // null while its model has no rates.
78 usd: number | null
79}
80
81export type Filter = 'live' | 'recent' | 'all'
82
83export type Tab = 'jobs' | 'runs' | 'agents'
84
85export type Ttl = '5m' | '1h'
86
87// What the mod knows about the main conversation's prompt cache.
88export type CacheState = {
89 // Unix milliseconds the last main-thread response arrived; 0 before the first.
90 lastResponseAt: number
91 ttl: Ttl
92 // setting: chosen in the mod's settings; engine: reported by Claude Code; observed:
93 // inferred from a cache hit or miss; assumed: nothing has said yet.
94 ttlSource: 'setting' | 'engine' | 'observed' | 'assumed'
95 // Prompt tokens the next request re-sends.
96 contextTokens: number
97 model: string
98}
99
100// What the session has spent, in USD, priced from the rates table. isPartial: a response
101// ran on a model the table does not price, so the totals are a floor.
102export type Spend = { mainUsd: number; agentUsd: number; isPartial: boolean }
103
104declare module 'claude-code' {
105 interface PluginState {
106 magus: {
107 report: Report | null
108 dismissed: string | null
109 tab: Tab
110 listing: Listing | null
111 runs: RunRow[] | null
112 error: string | null
113 filter: Filter
114 selected: string | null
115 watching: string | null
116 watchLines: ActivityLine[]
117 agents: AgentRow[]
118 cache: CacheState | null
119 // The lastResponseAt a held prompt was held for; the next submit for it goes through.
120 heldFor: number
121 spend: Spend
122 ratesError: string | null
123 // Bumped on a timer so the countdown in the band and status line redraws.
124 tick: number
125 }
126 }
127}
128