SLOPSHOPPER

magus

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

newpanebandcommandtoaststatus
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · magus
│ ┃ magus ✕ › fix the failing auth test and add an audit log call │ ┃ [ Jobs ] [ Runs ] [ Agents ] │ ┃ Session spend $0.08 (main $0.08, subagents … ⏺ Read(src/auth.ts) │ ┃ [ Live ] [ Today ] [ All ] reading… ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /magus │ ⎿ magus: magus pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ magus: cache 5m · $0.08

Draws

Pane · magus
[ Jobs ] [ Runs ] [ Agents ] Session spend $0.08 (main $0.08, subagents $0.00) [ Live ] [ Today ] [ All ] reading…
README

magus

<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">

Who this is for

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.

How it works

Five ideas carry most of the tool. Each has a deeper page; this is the short version.

Affected sets

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.

Content-addressed caching

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.

The knowledge graph

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.

One vocabulary

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.

There is a terminal UI. It stays out of your 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.

Non-goals

  • No remote execution. magus caches results and shares them. It does not run your work on someone else's machine.
  • No toolchain management. magus compares what ran against what you declared and stops. It does not select, install or switch a version.
  • No hermetic sandbox. The sandbox is a supply-chain defense, off by default, with no kernel layer on macOS. It does not fail a build on an undeclared read.
  • No container isolation. Steps run on the host. The container charm changes what a target produces, not where it runs.
  • Small ecosystem. magus is young and mostly one person's work, so you hit behavior nobody has hit before you. It is one Go binary and one Buzz file, both of which you can read.

Scope says why each line is there; Sandbox covers the third.

Getting started

Install

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.

A first look

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.

Architecture

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.

The HTTP routes

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">

The local path

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">

What keeps it warm

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">

Sharing to a phone

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.

The browser console

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

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>

  • Dashboard shows live daemon health, the concurrency pool, running targets, cache activity, and the live lease plan.[^app-dashboard]
  • Graph Explorer navigates targets, spells, and their dependency graph (magus graph export --open).[^app-graph]
  • Log Viewer reads or streams any past run's captured output (magus query output <ref> --open).[^app-logs]
  • Activity Trail shows recent MCP calls, agent-command observations, background jobs, and config changes.[^app-activity]
  • Diff annotates the working tree's uncommitted changes (generated vs source, blast radius, coverage) and hosts the human half of a paired review.

How it stays on your machine

These are add-ons, not a runtime you depend on. Two decisions keep them that way.

The binary embeds no UI

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.

Your data never leaves the loopback

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.

Working with AI agents

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

Source 8 files
hooks/register.tsx 862 lines
1import 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}
862
hooks/api.ts 118 lines
1import 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}
118
hooks/cost.ts 240 lines
1import 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}
240
hooks/health.ts 116 lines
1import 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}
116
hooks/rates.ts 19 lines
1import 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}
19
hooks/settings.ts 50 lines
1import 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}
50
hooks/tree.ts 83 lines
1import 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}
83
types/index.d.ts 128 lines
1export 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