Give every parallel coding agent its own local stack and develop with guardrails.

The local stack your inner loop runs against — databases, brokers, proxies and services as fast, disposable host processes, declared once and supervised together. No containers.
Zordon is a supervisor for the local stack your code runs against — the databases, brokers, proxies and services that have to be alive for your code to be exercised for real. It runs them as ordinary host processes instead of containers, declared once in an Alphasfile and supervised together, so standing the stack up, duplicating it, and throwing it away are all cheap.
Three goals shape every decision. Low latency — standing the stack up and re-running sit inside the loop, not around it; iteration shouldn't wait on infrastructure. Resourcefulness — per-stack overhead stays small enough that many agents run side by side, each with a stack real enough to evaluate against rather than a wall of mocks. Guardrails — the Alphasfile doubles as a contract, so the MCP server hands an agent only a narrow, declared surface: bring the stack up, inspect it, run a provision you named, and work through that interface instead of improvising against the host.
Why not containers? Code is increasingly written in a loop — change something, run the real stack against it, read the result, go again, hundreds of times an hour. Containers buy isolation by paying full cold-start and idle-overhead cost on every run: right for production, wasteful for a loop. Zordon takes the other side, recovering isolation from per-run workspaces instead of images — so a second stack beside yours, or a tenth, is a non-event.
Declare the whole stack in one Alphasfile:
service "go" "nats" {
git {
url = "github.com/nats-io/nats-server"
tag = "v2.14.0"
}
vars = { client = net::pickport() } # grab a free port, no clashes
arguments { values = { main = { p = self.vars.client } } }
}
zordon start # resolve, build, bring it up, stream logs until everything is READY
Alphasfile.net::pickport() picks a free port, fs::tmp()/fs::hash() give per-run paths, and services reference each other (service.go.caddy.vars.http) with zero hardcoded wiring. → docszordon workspace create x stands up a second, fully isolated copy of the entire stack — own ports, own dirs — an agent's sandbox beside yours, no port-mapping. → docszordon start returns the moment every service is READY, then keeps the stack alive in the background.zordon clean for teardown.--agent emits terse, structured logs; output is kept signal-dense because when a model reads your logs, tokens are the budget.zordon mcp exposes every command — and every provision — as MCP tools, so an agent can drive the whole stack. → docsAlphasfile plus the MCP surface form a narrow, declared interface between agent and stack — it runs the named provisions you defined, not an open shell, so it can't improvise its way around your setup.zordon, alpha, tommy — and nothing else to install, image, or register.Full docs: <https://zordon.io> (source in docs/, built with MkDocs Material).
self, cross-service refszordon sudo/plugin installHomebrew (macOS):
brew install piotrkowalczuk/tap/zordon
A release tarball (macOS and Linux, arm64 and amd64) from Releases:
tar -xzf zordon_<version>_<os>_<arch>.tar.gz -C ~/.local/bin zordon alpha tommy
Or from source, if you have a Go toolchain:
go install github.com/piotrkowalczuk/zordon/cmd/...@latest
Every variant installs the same three binaries — zordon, alpha, and the tommy reaper. Keep them in one directory and make sure that directory is on your $PATH: zordon finds alpha via $PATH, and alpha finds tommy only as a sibling of its own executable. See Binaries and layout.
Create an Alphasfile (see examples/simple), then:
zordon start # spawn alpha, push config, stream bringup logs
zordon status # what's running across the whole chain right now?
zordon stop # ask alpha to shut its children down and exit
zordon clean # run each provision's clean teardown (stack stopped)
zordon workspace create x # a parallel, isolated copy of the stack
See the docs for everything else.
zordon mcp runs an MCP server, over stdio by default or over HTTP with --transport=http. It exposes every zordon command — and every provision — as a tool, so an agent can drive your stack and run provisions on demand.
The fastest path is the Claude Code plugin — no manual config:
/plugin marketplace add piotrkowalczuk/zordon
/plugin install zordon@zordon
This registers the zordon mcp server and a skill nudging the agent toward it automatically; see the install how-to.
For other MCP clients, or without the plugin:
The MCP client launches the server; you don't run zordon mcp yourself. With Claude Code, register it from your project directory:
claude mcp add zordon -- zordon mcp
Or add it to your client's MCP config (e.g. .mcp.json):
{ "mcpServers": { "zordon": { "command": "zordon", "args": ["mcp"] } } }
The server resolves the chain from its working directory, so launch the client from the project tree (or pass -e ZORDON_HOME=…). Provisions run inside the live alpha, so zordon start first — or let the agent call the start tool.
To let an agent confined to a container or sandbox drive a host-side stack, serve it over HTTP instead — the client then needs only a URL, with no zordon binary inside the box:
zordon mcp --transport=http --listen 127.0.0.1:7391
See the how-to; note the endpoint is not authenticated yet.
The server advertises its purpose to the agent via MCP instructions (when to reach for these tools), so it should pick them up on its own. To nudge it harder in a zordon-managed repo, add a line to your CLAUDE.md (or AGENTS.md): "this project's local stack is managed by zordon — use the zordon MCP tools to bring it up, inspect it, and run provisions."
See the zordon mcp reference and how-to.
hooks/register.tsx 865 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderChildren, UiOpenResult } from 'claude-code'
3
4import { registerDiff } from './diff'
5import type { Checkout, McpCall, ZordonService, ZordonSnapshot, ZordonWorkspace } from '../types'
6
7const POLL_MS = 5000
8const SIZE_EVERY_MS = 60_000
9
10const snapshot = atom(
11 { plugin: 'zordon', key: 'snapshot' } as const,
12 { kind: 'loading' } as ZordonSnapshot,
13)
14const isCollapsed = atom({ plugin: 'zordon', key: 'isCollapsed' } as const, false)
15const isScopeCollapsed = atom({ plugin: 'zordon', key: 'isScopeCollapsed' } as const, false)
16const isRuntimeCollapsed = atom({ plugin: 'zordon', key: 'isRuntimeCollapsed' } as const, false)
17const isLogCollapsed = atom({ plugin: 'zordon', key: 'isLogCollapsed' } as const, false)
18const calls = atom({ plugin: 'zordon', key: 'calls' } as const, [] as McpCall[])
19const checkouts = atom({ plugin: 'zordon', key: 'checkouts' } as const, [] as Checkout[])
20// The Runtime row opened to its details: lines are cut to the box's width,
21// and a service's details show its print and the rest whole, wrapped.
22const openService = atom({ plugin: 'zordon', key: 'openService' } as const, null as string | null)
23const openCall = atom({ plugin: 'zordon', key: 'openCall' } as const, null as string | null)
24
25let sessionCwd = '.'
26
27// The zordon MCP tools: this plugin's own server (mcp__plugin_zordon_zordon__*)
28// or one the person configured as `zordon` (mcp__zordon__*).
29const MCP_TOOL = /^mcp__(?:plugin_zordon_)?zordon__(.+)$/
30const MAX_CALLS = 30
31const MAX_OUTPUT = 4000
32
33const PANE = 'zordon'
34const DASHBOARD = 'zordon:dashboard'
35
36async function current($: EngineInterface): Promise<ZordonSnapshot> {
37 return read($, snapshot)
38}
39
40async function openPane($: EngineInterface, focus: boolean): Promise<UiOpenResult> {
41 return $.ui.open({ id: PANE, title: 'zordon', columns: 56, ...(focus ? { focus: true as const } : {}) })
42}
43
44const HEADER_LINE = /^# \[[0-9a-f]+\] (.+?) \(invocation, workspace=([^)]+)\)$/
45const SERVICE_LINE = /^\s+- \[([^\]]*)\] (.+?) — (.+)$/
46const DETAIL_LINE = /^ {8}(.*)$/
47const ALPHA_LINE = /^\s+alpha pid=(\d+)/
48const ERROR_LINE = /\[ERROR\]\s*-?\s*(.*)$/
49
50function dirname(path: string): string {
51 return path.slice(0, path.lastIndexOf('/')) || '/'
52}
53
54function parseWorkspace(stdout: string): ZordonWorkspace | null {
55 for (const line of stdout.split('\n')) {
56 const m = HEADER_LINE.exec(line)
57 if (m) {
58 const [, alphasfile = '', name = 'main'] = m
59 return { name, alphasfile, stateDir: `${dirname(alphasfile)}/workspaces/${name}`, sizeKB: null }
60 }
61 }
62
63 return null
64}
65
66const LEVEL_LINE = /^# \[[0-9a-f]+\] /
67const CHECKOUT_LINE = /^checkout: (.+?) \((?:branch (\S+)[^)]*|detached @ [^)]+)\)$/
68
69// A service is picked (in the workspace's scope) when its checkout is the
70// workspace's own worktree, workspaces/<ws>/src/<svc>; main picks nothing.
71function isPickedCheckout(checkout: string | null, name: string, workspace: string): boolean {
72 if (checkout === null || workspace === 'main') return false
73
74 return checkout === `src/${name}` || checkout.endsWith(`/workspaces/${workspace}/src/${name}`)
75}
76
77function parseServices(stdout: string): { services: ZordonService[]; isAlphaUp: boolean } {
78 const services: ZordonService[] = []
79 let isAlphaUp = false
80 let isLeaf = false
81
82 for (const line of stdout.split('\n')) {
83 if (LEVEL_LINE.test(line)) {
84 isLeaf = HEADER_LINE.test(line)
85 continue
86 }
87
88 const alpha = ALPHA_LINE.exec(line)
89 if (alpha) {
90 isAlphaUp ||= isLeaf && Number(alpha[1] ?? 0) > 0
91 continue
92 }
93
94 const svc = SERVICE_LINE.exec(line)
95 if (svc) {
96 const [, toolchain = '', name = '', rawState = ''] = svc
97 const state = rawState.trim()
98 const isFailed = state === 'failed' || state.includes('[unhealthy')
99 services.push({
100 toolchain,
101 name,
102 state,
103 isFailed,
104 isUp: state.startsWith('running') && state.includes('[ready]'),
105 print: [],
106 checkout: null,
107 branch: null,
108 sourceDir: null,
109 isShared: !isLeaf,
110 isPicked: false,
111 })
112 continue
113 }
114
115 const text = DETAIL_LINE.exec(line)?.[1]
116 const last = services[services.length - 1]
117 if (text === undefined || !last) continue
118 const co = CHECKOUT_LINE.exec(text)
119 if (co) {
120 last.checkout = co[1] ?? null
121 last.branch = co[2] ?? null
122 } else if (!text.startsWith('checkout:')) {
123 last.print.push(text)
124 }
125 }
126
127 return { services, isAlphaUp }
128}
129
130export function parseStatus(exitCode: number, stdout: string, stderr: string, cwd = '/'): ZordonSnapshot {
131 const all = `${stdout}\n${stderr}`
132 if (all.includes('no Alphasfile')) {
133 return { kind: 'inactive' }
134 }
135
136 const workspace = parseWorkspace(stdout)
137 const parsed = parseServices(stdout)
138 const isAlphaUp = parsed.isAlphaUp
139 // The text report writes a checkout beneath the working directory relative to it.
140 const absolute = (path: string | null) => (path === null || path.startsWith('/') ? path : `${cwd.replace(/\/$/, '')}/${path}`)
141 const services = parsed.services.map(svc => ({
142 ...svc,
143 checkout: absolute(svc.checkout),
144 isPicked: !svc.isShared && isPickedCheckout(svc.checkout, svc.name, workspace?.name ?? 'main'),
145 }))
146 if (exitCode !== 0 && !all.includes('no alpha running')) {
147 const errors = stderr
148 .split('\n')
149 .map(l => ERROR_LINE.exec(l)?.[1]?.trim())
150 .filter((l): l is string => !!l)
151 const fallback = stderr.trim().split('\n').pop() || `zordon status exited ${exitCode}`
152
153 return { kind: 'error', message: errors.length > 0 ? errors.join('\n') : fallback, workspace }
154 }
155
156 return { kind: isAlphaUp ? 'running' : 'stopped', services, workspace }
157}
158
159type AgentService = {
160 name: string
161 state: string
162 health?: string
163 picked?: boolean
164 shared?: boolean
165 print?: string
166 checkout?: string
167 checkout_path?: string
168 branch?: string
169 source_dir?: string
170}
171
172type AgentStatus = {
173 workspace?: string
174 alphasfile?: string
175 state_dir?: string
176 state: string
177 error?: string
178 services?: AgentService[]
179}
180
181// `zordon status --format=json`: one JSON object; null when this zordon predates it
182// and printed its text report instead.
183export function parseAgentStatus(stdout: string): ZordonSnapshot | null {
184 let st: AgentStatus
185 try {
186 st = JSON.parse(stdout) as AgentStatus
187 } catch {
188 return null
189 }
190 if (typeof st !== 'object' || st === null || typeof st.state !== 'string') return null
191
192 if (st.state === 'no-alphasfile') return { kind: 'inactive' }
193 const workspace: ZordonWorkspace | null =
194 st.workspace && st.alphasfile && st.state_dir
195 ? { name: st.workspace, alphasfile: st.alphasfile, stateDir: st.state_dir, sizeKB: null }
196 : null
197 if (st.state === 'error') return { kind: 'error', message: st.error ?? 'zordon failed', workspace }
198
199 const services = (st.services ?? []).map(
200 (x): ZordonService => ({
201 name: x.name,
202 toolchain: '',
203 state: x.health ? `${x.state}: ${x.health}` : x.state,
204 isUp: x.state === 'ready',
205 isFailed: x.state === 'failed' || x.state === 'unhealthy',
206 print: x.print ? [x.print] : [],
207 checkout: x.checkout_path ?? null,
208 branch: x.branch ?? null,
209 sourceDir: x.source_dir ?? null,
210 isShared: x.shared === true,
211 isPicked: x.picked === true,
212 }),
213 )
214
215 return { kind: st.state === 'running' ? 'running' : 'stopped', services, workspace }
216}
217
218export function formatSize(kb: number): string {
219 const units = ['KB', 'MB', 'GB', 'TB']
220 let value = kb
221 let unit = 0
222 while (value >= 1024 && unit < units.length - 1) {
223 value /= 1024
224 unit += 1
225 }
226
227 return `${value < 10 && unit > 0 ? value.toFixed(1) : Math.round(value)} ${units[unit]}`
228}
229
230function summary(s: ZordonSnapshot): string | undefined {
231 switch (s.kind) {
232 case 'loading':
233 case 'inactive':
234 return undefined
235 case 'error':
236 return 'zordon ✗ error'
237 case 'stopped':
238 return 'zordon stopped'
239 case 'running': {
240 const up = s.services.filter(x => x.isUp).length
241 const failed = s.services.filter(x => x.isFailed).length
242
243 return `zordon ${up}/${s.services.length} up${failed > 0 ? ` · ${failed} failed` : ''}`
244 }
245 }
246}
247
248// One glyph per state, colored where drawn: the list stays quiet when all is
249// well and a word appears only for what is not ready.
250// Runtime's order: what needs a look first (failed or unhealthy), then what
251// is coming up, then what runs, then what is stopped; by name within each.
252// Read off the same flags as the dots, so a text report's states sort too.
253function stateRank(x: ZordonService): number {
254 if (x.isFailed) return 0
255 if (x.isUp) return 2
256 if (x.state === 'stopped') return 3
257
258 return 1
259}
260
261export function byStateThenName(a: ZordonService, b: ZordonService): number {
262 return stateRank(a) - stateRank(b) || a.name.localeCompare(b.name)
263}
264
265export function serviceMark(svc: ZordonService): { glyph: string; color: string; note: string | null } {
266 if (svc.isFailed) return { glyph: '●', color: 'red', note: svc.state.includes('unhealthy') ? 'unhealthy' : 'failed' }
267 if (svc.isUp) return { glyph: '●', color: 'green', note: null }
268 if (svc.state === 'stopped') return { glyph: '○', color: 'gray', note: 'stopped' }
269
270 return { glyph: '◐', color: 'yellow', note: svc.state.includes('probing') ? 'probing' : 'starting' }
271}
272
273// The services a workspace picked (its own worktrees) and the rest its run
274// brings up: unpicked services and the federation's shared levels.
275export function splitScope(services: ZordonService[]): { scope: ZordonService[]; runtime: ZordonService[] } {
276 return { scope: services.filter(x => x.isPicked), runtime: services.filter(x => !x.isPicked) }
277}
278
279// Opens a file in the desktop's default app: open on macOS, xdg-open elsewhere.
280async function openFile($: EngineInterface, path: string): Promise<void> {
281 for (const opener of ['open', 'xdg-open']) {
282 try {
283 if ((await $.process.run([opener, path], { timeoutMs: 5_000 })).exitCode === 0) return
284 } catch {
285 // not this platform's opener
286 }
287 }
288 $.ui.toast(`zordon: cannot open ${path}`)
289}
290
291function workspaceOf(s: ZordonSnapshot): ZordonWorkspace | null {
292 return s.kind === 'loading' || s.kind === 'inactive' ? null : s.workspace
293}
294
295function failedNames(s: ZordonSnapshot): Set<string> {
296 return new Set(s.kind === 'running' ? s.services.filter(x => x.isFailed).map(x => x.name) : [])
297}
298
299async function diskUsageKB($: EngineInterface, dir: string): Promise<number | null> {
300 try {
301 const ran = await $.process.run(['du', '-sk', dir], { timeoutMs: 20_000 })
302 const kb = Number(ran.stdout.trim().split(/\s+/)[0])
303
304 return ran.exitCode === 0 && Number.isFinite(kb) ? kb : null
305 } catch {
306 return null
307 }
308}
309
310type SizeCache = Map<string, { kb: number | null; at: number }>
311type PrCache = Map<string, { pr: Checkout['pr']; at: number }>
312
313const PR_EVERY_MS = 60_000
314
315// The workspace's code as zordon reports it: the services of this level
316// whose code is in its src/, grouped by the git tree they build from, each
317// tree with its branch and that branch's latest pull request, each service
318// with its directory in the tree. A tree inside the workspace reads relative
319// to it, a monorepo enclosing it by its name.
320export function groupCheckouts(services: ZordonService[], root: string): Omit<Checkout, 'pr'>[] {
321 // Only what lives in the workspace's src/: its own checkouts, or in main
322 // the project's src/; a service built from elsewhere belongs to Runtime.
323 const src = `${root}/src/`
324 const inSrc = (p: string | null) => p !== null && p.startsWith(src)
325 const byPath = new Map<string, Omit<Checkout, 'pr'>>()
326 for (const svc of services) {
327 if (svc.isShared || svc.checkout === null) continue
328 if (!inSrc(svc.checkout) && !inSrc(svc.sourceDir)) continue
329 const path = svc.checkout
330 let group = byPath.get(path)
331 if (!group) {
332 const label = path.startsWith(`${root}/`) ? path.slice(root.length + 1) : (path.split('/').pop() ?? path)
333 group = { path, label, branch: svc.branch, apps: [] }
334 byPath.set(path, group)
335 }
336 const dir = svc.sourceDir !== null && svc.sourceDir.startsWith(`${path}/`) ? svc.sourceDir.slice(path.length + 1) : null
337 group.apps.push({ name: svc.name, dir })
338 }
339
340 return [...byPath.values()].sort((a, b) => a.label.localeCompare(b.label))
341}
342
343async function withPullRequests($: EngineInterface, groups: Omit<Checkout, 'pr'>[], prs: PrCache): Promise<Checkout[]> {
344 const out: Checkout[] = []
345 for (const g of groups) {
346 out.push({ ...g, pr: g.branch === null ? null : await pullRequest($, g.path, g.branch, prs) })
347 }
348
349 return out
350}
351
352// gh answers for the checkout's own remote; without gh, or with no PR for
353// the branch, there is none. Asked once a minute per branch.
354async function pullRequest($: EngineInterface, dir: string, branch: string, prs: PrCache): Promise<Checkout['pr']> {
355 const now = await $.clock.now()
356 const key = `${dir}\0${branch}`
357 const hit = prs.get(key)
358 if (hit && now - hit.at < PR_EVERY_MS) return hit.pr
359
360 let pr: Checkout['pr'] = null
361 try {
362 const ran = await $.process.run(
363 ['gh', 'pr', 'list', '--head', branch, '--state', 'all', '--json', 'url,number', '--limit', '1'],
364 { cwd: dir, timeoutMs: 15_000 },
365 )
366 const found = ran.exitCode === 0 ? (JSON.parse(ran.stdout) as { url: string; number: number }[])[0] : undefined
367 if (found && found.url.startsWith('https://')) pr = { url: new URL(found.url).href, number: found.number }
368 } catch {
369 // no gh, or not a GitHub remote
370 }
371 prs.set(key, { pr, at: now })
372
373 return pr
374}
375
376async function refresh($: EngineInterface, sizes: SizeCache, prs: PrCache): Promise<ZordonSnapshot> {
377 let next: ZordonSnapshot
378 try {
379 // The session's directory now, so a /cd moves what the pane watches.
380 const cwd = await $.session.cwd()
381 sessionCwd = cwd
382 const report = await $.process.run(['zordon', 'status', '--format=json'], { cwd, timeoutMs: 10_000 })
383 const parsed = parseAgentStatus(report.stdout)
384 if (parsed !== null) {
385 next = parsed
386 } else {
387 const ran = await $.process.run(['zordon', 'status'], { cwd, timeoutMs: 10_000 })
388 next = parseStatus(ran.exitCode, ran.stdout, ran.stderr, cwd)
389 }
390 } catch (err) {
391 // Not running zordon is no answer about the tree: an error, never "Not a
392 // workspace".
393 next = { kind: 'error', message: `cannot run zordon: ${String(err)}`, workspace: null }
394 }
395
396 const ws0 = workspaceOf(next)
397 if (ws0 && next.kind !== 'loading' && next.kind !== 'inactive') {
398 const dir = ws0.stateDir
399 const now = await $.clock.now()
400 let cached = sizes.get(dir)
401 if (!cached || now - cached.at > SIZE_EVERY_MS) {
402 cached = { kb: await diskUsageKB($, dir), at: now }
403 sizes.set(dir, cached)
404 }
405 next = { ...next, workspace: { ...ws0, sizeKB: cached.kb } }
406
407 const services = next.kind === 'running' || next.kind === 'stopped' ? next.services : []
408 const root = ws0.name === 'main' ? dirname(ws0.alphasfile) : dir
409 const found = await withPullRequests($, groupCheckouts(services, root), prs)
410 if (JSON.stringify(found) !== JSON.stringify(await read($, checkouts))) {
411 await update($, checkouts, () => found)
412 }
413 }
414
415 const prev = await read($, snapshot)
416 if (JSON.stringify(prev) === JSON.stringify(next)) {
417 return next
418 }
419
420 const prevFailed = failedNames(prev)
421 const newlyFailed = [...failedNames(next)].filter(n => !prevFailed.has(n))
422 const isNewError = next.kind === 'error' && prev.kind !== 'error'
423 if (isNewError || newlyFailed.length > 0) {
424 $.ui.toast(isNewError ? 'zordon failed' : `zordon: ${newlyFailed.join(', ')} failed`)
425 await openPane($, false)
426 }
427
428 await update($, snapshot, () => next)
429 $.ui.status(summary(next))
430
431 return next
432}
433
434// Band colors per theme: header and footer a step off the pane's background,
435// sub-headers half a step, so both read on a dark and a light terminal.
436const PALETTE = {
437 dark: { bar: '#2b3040', sub: '#232733' },
438 light: { bar: '#dde1ea', sub: '#eceef3' },
439} as const
440
441async function palette($: EngineInterface): Promise<(typeof PALETTE)[keyof typeof PALETTE]> {
442 try {
443 const theme = (await $.config.list()).find(row => row.key === 'theme')?.value
444
445 return typeof theme === 'string' && theme.includes('light') ? PALETTE.light : PALETTE.dark
446 } catch {
447 return PALETTE.dark
448 }
449}
450
451// The zordon MCP tools take `{ args: [...] }`; shown as the CLI would read.
452function describeArgs(input: Record<string, unknown>): string {
453 const args = input.args
454 if (Array.isArray(args)) return args.map(String).join(' ')
455 const rest = Object.keys(input).length > 0 ? JSON.stringify(input) : ''
456
457 return rest === '{}' ? '' : rest
458}
459
460// What a call answered: the text the model read, else the raw result.
461function callOutput(ran: object): string {
462 const r = ran as { deny?: string; text?: string; result?: unknown }
463 if (r.deny !== undefined) return `denied: ${r.deny}`
464 if (typeof r.text === 'string') return r.text
465 if (r.result === undefined) return ''
466
467 return typeof r.result === 'string' ? r.result : JSON.stringify(r.result, null, 2)
468}
469
470function formatDuration(ms: number | null): string {
471 if (ms === null) return '…'
472
473 return ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(1)}s`
474}
475
476type RenderEvent = Parameters<EngineInterface['ui']['resolve']>[0]
477
478// The pane's column: a Zordon header over three boxes, each folding on its
479// own: the workspace and the git checkouts under its src/, every service its run brings up,
480// and the zordon MCP calls Claude made this session. With no workspace a
481// single box says so instead.
482async function stackView($: EngineInterface, e: RenderEvent, s: ZordonSnapshot) {
483 const { Box, Text, Link, Markdown, Button, Code } = $.ui.resolve(e)
484 const colors = await palette($)
485 const collapsed = await read($, isCollapsed)
486 const isStackFolded = await read($, isScopeCollapsed)
487 const isRuntimeFolded = await read($, isRuntimeCollapsed)
488 const isLogFolded = await read($, isLogCollapsed)
489 const log = await read($, calls)
490 const opened = await read($, openCall)
491
492 const panel = (p: {
493 key: string
494 title: string
495 aside?: RenderChildren
496 border: string
497 isFolded: boolean
498 onToggle: () => void
499 body: RenderChildren
500 footer?: { left: RenderChildren; right: RenderChildren }
501 }) => (
502 <Box key={p.key} flexDirection="column" borderStyle="round" borderColor={p.border} marginTop={1}>
503 <Box justifyContent="space-between" backgroundColor={colors.sub} paddingX={1}>
504 <Text bold>{p.title}</Text>
505 <Box gap={1}>
506 {p.aside ?? null}
507 <Button key={`fold-${p.key}`} plain label={p.isFolded ? '+' : '−'} onPress={p.onToggle} />
508 </Box>
509 </Box>
510 {p.isFolded ? null : (
511 <Box flexDirection="column" paddingX={1}>
512 {p.body}
513 </Box>
514 )}
515 {/* Left and right as the row's own two children: handed over as one
516 fragment they were laid out as one, one under the other. */}
517 {p.footer ? (
518 <Box justifyContent="space-between" backgroundColor={colors.sub} paddingX={1}>
519 {p.footer.left}
520 {p.footer.right}
521 </Box>
522 ) : null}
523 </Box>
524 )
525
526 const openedService = await read($, openService)
527 const rows = (list: ZordonService[]) => (
528 <Box flexDirection="column">
529 {list.map(svc => {
530 const mark = serviceMark(svc)
531 const tags = [mark.note, svc.isShared ? 'shared' : null].filter(Boolean).join(' · ')
532 const isOpen = openedService === svc.name
533 const details: [string, string | null][] = [
534 ['state', svc.state],
535 ['print', svc.print.join('\n') || null],
536 ['checkout', svc.checkout],
537 ['branch', svc.branch],
538 ['source', svc.sourceDir],
539 ]
540
541 return (
542 <Box key={`svc-${svc.name}`} flexDirection="column">
543 <Box>
544 <Text color={mark.color}>{mark.glyph} </Text>
545 <Button
546 key={`details-${svc.name}`}
547 plain
548 label={`${isOpen ? '▾' : '▸'} ${svc.name}`}
549 onPress={() => update($, openService, o => (o === svc.name ? null : svc.name))}
550 />
551 {tags ? <Text dimColor wrap="truncate-end"> {tags}</Text> : null}
552 </Box>
553 {isOpen ? (
554 <Box flexDirection="column" paddingLeft={2}>
555 {details
556 .filter((d): d is [string, string] => d[1] !== null)
557 .map(([k, v]) => (
558 <Text key={`detail-${svc.name}-${k}`} wrap="wrap">
559 <Text dimColor>{k} </Text>
560 {v}
561 </Text>
562 ))}
563 </Box>
564 ) : (
565 svc.print.map((line, i) => (
566 <Text key={`print-${svc.name}-${String(i)}`} dimColor wrap="truncate-end">
567 {' '}
568 {line}
569 </Text>
570 ))
571 )}
572 </Box>
573 )
574 })}
575 </Box>
576 )
577 const border = (list: ZordonService[]) =>
578 list.some(x => x.isFailed) ? 'red' : list.length > 0 && list.every(x => x.state === 'stopped') ? 'gray' : 'green'
579
580 const header = (
581 <Box justifyContent="space-between" backgroundColor={colors.bar} paddingX={1}>
582 <Text bold>
583 Zordon
584 {collapsed && summary(s) ? <Text dimColor> · {summary(s)?.replace(/^zordon /, '')}</Text> : null}
585 </Text>
586 <Box gap={1}>
587 <Link href="https://zordon.io/" label="zordon.io" />
588 <Button key="collapse" plain label={collapsed ? '+' : '−'} onPress={() => update($, isCollapsed, c => !c)} />
589 </Box>
590 </Box>
591 )
592 if (collapsed) {
593 return <Box flexDirection="column">{header}</Box>
594 }
595
596 const ws = workspaceOf(s)
597 const services = s.kind === 'running' || s.kind === 'stopped' ? s.services : []
598 const checkedOut = await read($, checkouts)
599
600 // No workspace: one box saying so, in place of Workspace, Runtime and Logs.
601 // Before zordon first answered: no verdict on the tree yet.
602 if (s.kind === 'loading') {
603 return (
604 <Box flexDirection="column">
605 {header}
606 <Box borderStyle="round" borderColor="gray" marginTop={1} paddingX={1}>
607 <Text dimColor>Reading the stack…</Text>
608 </Box>
609 </Box>
610 )
611 }
612
613 if (s.kind === 'inactive') {
614 return (
615 <Box flexDirection="column">
616 {header}
617 <Box flexDirection="column" borderStyle="round" borderColor="gray" marginTop={1} paddingX={1}>
618 <Text>
619 <Text color="gray">○ </Text>
620 <Text bold>Not a workspace</Text>
621 </Text>
622 <Text dimColor wrap="wrap">
623 No Alphasfile anywhere in this tree
624 </Text>
625 <Text dimColor>
626 Learn more at <Link href="https://zordon.io/" label="zordon.io" />
627 </Text>
628 </Box>
629 </Box>
630 )
631 }
632
633 const checkoutRows = (
634 <Box flexDirection="column">
635 {checkedOut.map(c => (
636 <Box key={`co-${c.path}`} flexDirection="column">
637 <Box gap={1}>
638 <Text bold wrap="truncate-end">
639 {c.label}
640 </Text>
641 {c.pr ? (
642 <Link href={c.pr.url} label={c.branch ?? ''} />
643 ) : (
644 <Text dimColor wrap="truncate-end">
645 {c.branch ?? 'detached'}
646 </Text>
647 )}
648 {c.pr ? <Text dimColor>#{c.pr.number}</Text> : null}
649 </Box>
650 {c.apps.map(a => (
651 <Text key={`app-${c.path}-${a.name}`} wrap="truncate-end">
652 {' '}
653 {a.name}
654 {a.dir ? <Text dimColor> {a.dir}</Text> : null}
655 </Text>
656 ))}
657 </Box>
658 ))}
659 </Box>
660 )
661 const stackBody =
662 s.kind === 'error' ? (
663 <Markdown text={['❌ **zordon error**', '', '```', s.message, '```'].join('\n')} />
664 ) : checkedOut.length > 0 ? (
665 checkoutRows
666 ) : (
667 <Text dimColor>
668 {ws ? `nothing in ${ws.name === 'main' ? dirname(ws.alphasfile) : ws.stateDir}/src` : 'no src/'}
669 </Text>
670 )
671
672 const callRows = (
673 <Box flexDirection="column">
674 {log.map(c => {
675 const isOpen = c.id === opened
676 const mark = c.durationMs === null ? '◐' : c.isError ? '✕' : '✓'
677 const color = c.durationMs === null ? 'yellow' : c.isError ? 'red' : 'green'
678
679 return (
680 <Box key={`call-${c.id}`} flexDirection="column">
681 <Box>
682 <Text color={color}>{mark} </Text>
683 <Button
684 key={`open-${c.id}`}
685 plain
686 label={`${c.command}${c.args ? ` ${c.args}` : ''}`}
687 onPress={() => update($, openCall, o => (o === c.id ? null : c.id))}
688 />
689 <Text dimColor> {formatDuration(c.durationMs)}</Text>
690 </Box>
691 {isOpen ? (
692 <Box flexDirection="column" paddingLeft={2}>
693 <Text dimColor>
694 {new Date(c.startedAt).toLocaleTimeString()} · zordon {c.command}
695 {c.args ? ` ${c.args}` : ''}
696 </Text>
697 {c.output ? (
698 <Code
699 source={c.output}
700 language={c.output.trimStart().startsWith('{') ? 'json' : 'text'}
701 wrap="truncate-end"
702 />
703 ) : (
704 <Text dimColor>no output yet</Text>
705 )}
706 </Box>
707 ) : null}
708 </Box>
709 )
710 })}
711 </Box>
712 )
713
714 return (
715 <Box flexDirection="column">
716 {header}
717 {panel({
718 key: 'workspace',
719 title: 'Workspace',
720 aside: ws ? <Text dimColor>{ws.name}</Text> : null,
721 border: s.kind === 'error' ? 'red' : checkedOut.length > 0 ? 'green' : 'gray',
722 isFolded: isStackFolded,
723 onToggle: () => update($, isScopeCollapsed, f => !f),
724 body: stackBody,
725 footer: ws
726 ? {
727 left: <Text dimColor>{ws.sizeKB != null ? formatSize(ws.sizeKB) : '…'}</Text>,
728 right: <Button key="open-alphasfile" plain label="Alphasfile" onPress={() => openFile($, ws.alphasfile)} />,
729 }
730 : undefined,
731 })}
732 {services.length === 0
733 ? null
734 : panel({
735 key: 'runtime',
736 title: 'Runtime',
737 border: border(services),
738 isFolded: isRuntimeFolded,
739 onToggle: () => update($, isRuntimeCollapsed, f => !f),
740 body: rows([...services].sort(byStateThenName)),
741 })}
742 {log.length === 0 || ws === null
743 ? null
744 : panel({
745 key: 'log',
746 title: `Logs · ${log.length}`,
747 border: 'gray',
748 isFolded: isLogFolded,
749 onToggle: () => update($, isLogCollapsed, f => !f),
750 body: callRows,
751 })}
752 </Box>
753 )
754}
755
756export const register: Register = on => {
757 const sizes: SizeCache = new Map()
758 const prs: PrCache = new Map()
759
760 registerDiff(on)
761
762 on('tool.call', async ($, e, next) => {
763 const command = MCP_TOOL.exec(e.tool)?.[1]
764 if (command === undefined) return next(e)
765
766 const { tool, tool_use_id: toolUseId, consent: _consent, ...input } = e as Record<string, unknown> & {
767 tool: string
768 tool_use_id?: string
769 consent?: string
770 }
771 const startedAt = await $.clock.now()
772 const call: McpCall = {
773 id: toolUseId ?? `${tool}-${startedAt}`,
774 command,
775 args: describeArgs(input),
776 startedAt,
777 durationMs: null,
778 isError: false,
779 output: '',
780 }
781 await update($, calls, list => [call, ...list].slice(0, MAX_CALLS))
782
783 const ran = await next(e)
784 const durationMs = (await $.clock.now()) - startedAt
785 const output = callOutput(ran)
786 const isError = ('deny' in ran && ran.deny !== undefined) || ('isError' in ran && ran.isError === true)
787 await update($, calls, list =>
788 list.map(c => (c.id === call.id ? { ...c, durationMs, isError, output: output.slice(0, MAX_OUTPUT) } : c)),
789 )
790
791 return ran
792 })
793
794 on('session.start', async ($, e, next) => {
795 const started = await next(e)
796 sessionCwd = e.cwd
797
798 let isPolling = false
799 const tick = async () => {
800 if (isPolling) return
801 isPolling = true
802 try {
803 await refresh($, sizes, prs)
804 } finally {
805 isPolling = false
806 }
807 }
808 void tick().then(async () => {
809 if (workspaceOf(await current($)) !== null) await openPane($, false)
810 })
811 $.clock.every(POLL_MS, tick)
812
813 return started
814 })
815
816 // /zordon:dashboard is the plugin's commands/dashboard.md; answering it here
817 // toggles the pane instead of sending that file's fallback prompt.
818 on('command.run', { command: DASHBOARD }, async $ => {
819 // Opened unasked on a narrow terminal the pane is open but not shown: the
820 // person asking for it wants it seated, not closed.
821 if ((await $.ui.panes()).some(p => p.id === PANE && p.isShown)) {
822 await $.ui.close({ id: PANE })
823
824 return { text: 'zordon dashboard hidden' }
825 }
826
827 await refresh($, sizes, prs)
828 const s = await current($)
829 const opened = await openPane($, true)
830 const where = opened.isPlaced ? 'shown' : `waits: ${opened.reason}`
831
832 return { text: `zordon dashboard ${where}: ${summary(s) ?? `no Alphasfile at or above ${sessionCwd}`}` }
833 })
834
835 // The same view inline, as the /zordon:dashboard row in the transcript: what a
836 // surface that seats no panes (a phone over Remote Control) still shows.
837 on('ui.render', { component: 'CommandOutput', props: { command: DASHBOARD } }, async ($, e, next) => {
838 if (e.props.isErrored || e.props.text.endsWith('hidden')) return next(e)
839
840 return stackView($, e, await current($))
841 })
842
843 // The stack's view: a pane, docked beside the transcript in fullscreen
844 // (inline above the prompt otherwise); the arrows scroll it once focused.
845 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => stackView($, e, await current($)))
846
847 // Above the prompt only while something is wrong, one line pointing at the pane.
848 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
849 const s = await current($)
850 const isBad = s.kind === 'error' || (s.kind === 'running' && s.services.some(x => x.isFailed))
851 if (e.props.hasSurvey || !isBad) {
852 return next(e)
853 }
854
855 const { Box, Button, Text } = $.ui.resolve(e)
856
857 return (
858 <Box>
859 <Text color="red">{summary(s)} </Text>
860 <Button key="open" label="Show" onPress={() => openPane($, true)} />
861 </Box>
862 )
863 })
864}
865hooks/diff.tsx 167 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On } from 'claude-code'
3
4import type { DiffApp, DiffFile, DiffView } from '../types'
5
6const PANE = 'zordon-diff'
7const SRC = 'src'
8const CODE_LIMIT = 10_000
9const MAX_FILES = 40
10
11const view = atom(
12 { plugin: 'zordon', key: 'diff' } as const,
13 { apps: [], app: null, files: [], more: 0, isLoading: false } as DiffView,
14)
15
16const FILE_HEADER = /^diff --git a\/.+? b\/(.+)$/
17
18// Cuts a file's hunks to what one Code element takes, at a hunk boundary so
19// what is left still parses as a diff.
20function fitHunks(hunks: string): string | null {
21 if (hunks.length <= CODE_LIMIT) return hunks
22 const cut = hunks.lastIndexOf('\n@@', CODE_LIMIT)
23
24 return cut > 0 ? hunks.slice(0, cut) : null
25}
26
27export function parseGitDiff(out: string): DiffFile[] {
28 const files: DiffFile[] = []
29 let current: { path: string; lines: string[] } | null = null
30
31 const flush = () => {
32 if (current === null) return
33 const start = current.lines.findIndex(l => l.startsWith('@@'))
34 const body = start < 0 ? [] : current.lines.slice(start)
35 const added = body.filter(l => l.startsWith('+')).length
36 const removed = body.filter(l => l.startsWith('-')).length
37 const isBinary = current.lines.some(l => l.startsWith('Binary files'))
38 const hunks = body.length > 0 ? fitHunks(body.join('\n').trimEnd()) : null
39 const note = isBinary
40 ? 'binary file'
41 : body.length === 0
42 ? 'mode or rename only'
43 : hunks === null
44 ? 'diff too large to show'
45 : null
46 files.push({ path: current.path, added, removed, hunks, note })
47 }
48
49 for (const line of out.split('\n')) {
50 const header = FILE_HEADER.exec(line)
51 if (header) {
52 flush()
53 current = { path: header[1] ?? '?', lines: [] }
54 } else if (current !== null) {
55 current.lines.push(line)
56 }
57 }
58 flush()
59
60 return files
61}
62
63async function git($: EngineInterface, app: string, args: string[]): Promise<string> {
64 const ran = await $.process.run(['git', '-C', `${SRC}/${app}`, ...args], { timeoutMs: 20_000 })
65
66 return ran.exitCode === 0 ? ran.stdout : ''
67}
68
69async function discoverApps($: EngineInterface): Promise<DiffApp[]> {
70 if (!(await $.fs.exists(SRC))) return []
71 const dirs = (await $.fs.list(SRC)).filter(d => d.kind === 'dir').map(d => d.name)
72 const apps: DiffApp[] = []
73 for (const name of dirs.sort()) {
74 if (!(await $.fs.exists(`${SRC}/${name}/.git`))) continue
75 const status = await git($, name, ['status', '--porcelain'])
76 apps.push({ name, changes: status.split('\n').filter(Boolean).length })
77 }
78
79 return apps
80}
81
82async function loadApp($: EngineInterface, app: string): Promise<void> {
83 await update($, view, v => ({ ...v, app, isLoading: true }))
84
85 const tracked = parseGitDiff(await git($, app, ['diff', 'HEAD', '--no-color', '--no-ext-diff']))
86 const untracked = (await git($, app, ['ls-files', '--others', '--exclude-standard']))
87 .split('\n')
88 .filter(Boolean)
89 .map((path): DiffFile => ({ path, added: 0, removed: 0, hunks: null, note: 'untracked' }))
90 const all = [...tracked, ...untracked]
91
92 await update($, view, v =>
93 v.app === app
94 ? { ...v, files: all.slice(0, MAX_FILES), more: Math.max(0, all.length - MAX_FILES), isLoading: false }
95 : v,
96 )
97}
98
99export function registerDiff(on: On): void {
100 on('command.run', { command: 'diff' }, async ($, e, next) => {
101 const apps = await discoverApps($)
102 if (apps.length === 0) {
103 return next(e)
104 }
105
106 const asked = e.args.trim()
107 const app = asked === '' ? (apps.find(a => a.changes > 0) ?? apps[0])?.name : asked
108 if (app === undefined || !apps.some(a => a.name === app)) {
109 return { text: `No app "${asked}" under ${SRC}/. Known: ${apps.map(a => a.name).join(', ')}` }
110 }
111
112 await update($, view, v => ({ ...v, apps }))
113 await loadApp($, app)
114 await $.ui.open({ id: PANE, title: `diff · ${SRC}/${app}`, focus: true })
115 const files = (await read($, view)).files.length
116
117 return { text: `diff of ${SRC}/${app}: ${files} file(s) changed` }
118 })
119
120 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
121 const v = await read($, view)
122 const ui = $.ui.resolve(e)
123 const { Box, Text, Button, Code } = ui
124
125 const pick = async (app: string) => {
126 await loadApp($, app)
127 await $.ui.open({ id: PANE, title: `diff · ${SRC}/${app}` })
128 }
129 const options = v.apps.map(a => ({ value: a.name, label: `${a.name} · ${a.changes}` }))
130
131 // The phone has no Select: one Button per app there.
132 const picker =
133 'Select' in ui ? (
134 <ui.Select key="app" label="app " options={options} value={v.app ?? undefined} autoFocus onSelect={pick} />
135 ) : (
136 <Box flexWrap="wrap">
137 {v.apps.map(a => (
138 <Button
139 key={`app-${a.name}`}
140 label={`${a.name} · ${a.changes}`}
141 variant={a.name === v.app ? 'primary' : undefined}
142 onPress={() => pick(a.name)}
143 />
144 ))}
145 </Box>
146 )
147
148 return (
149 <Box flexDirection="column">
150 {v.apps.length > 1 ? picker : null}
151 {v.isLoading ? <Text dimColor>loading…</Text> : null}
152 {!v.isLoading && v.files.length === 0 ? <Text dimColor>No changes in {SRC}/{v.app}.</Text> : null}
153 {v.files.map(f => (
154 <Box key={`f-${f.path}`} flexDirection="column" marginTop={1}>
155 <Text bold>
156 {f.path} <Text color="green">+{f.added}</Text> <Text color="red">−{f.removed}</Text>
157 </Text>
158 {f.hunks !== null ? <Code source={f.hunks} format="diff" path={f.path} /> : null}
159 {f.note !== null ? <Text dimColor>{f.note}</Text> : null}
160 </Box>
161 ))}
162 {v.more > 0 ? <Text dimColor>… {v.more} more file(s)</Text> : null}
163 </Box>
164 )
165 })
166}
167types/index.d.ts 70 lines1export type ZordonService = {
2 name: string
3 toolchain: string
4 state: string
5 isUp: boolean
6 isFailed: boolean
7 print: string[]
8 checkout: string | null
9 branch: string | null
10 sourceDir: string | null
11 isShared: boolean
12 isPicked: boolean
13}
14
15export type ZordonWorkspace = {
16 name: string
17 alphasfile: string
18 stateDir: string
19 sizeKB: number | null
20}
21
22export type DiffFile = {
23 path: string
24 added: number
25 removed: number
26 hunks: string | null
27 note: string | null
28}
29
30export type DiffApp = { name: string; changes: number }
31
32export type DiffView = {
33 apps: DiffApp[]
34 app: string | null
35 files: DiffFile[]
36 more: number
37 isLoading: boolean
38}
39
40export type Checkout = {
41 path: string
42 label: string
43 branch: string | null
44 pr: { url: string; number: number } | null
45 apps: { name: string; dir: string | null }[]
46}
47
48export type McpCall = {
49 id: string
50 command: string
51 args: string
52 startedAt: number
53 durationMs: number | null
54 isError: boolean
55 output: string
56}
57
58export type ZordonSnapshot =
59 | { kind: 'loading' }
60 | { kind: 'inactive' }
61 | { kind: 'error'; message: string; workspace: ZordonWorkspace | null }
62 | { kind: 'stopped'; services: ZordonService[]; workspace: ZordonWorkspace | null }
63 | { kind: 'running'; services: ZordonService[]; workspace: ZordonWorkspace | null }
64
65declare module 'claude-code' {
66 interface PluginState {
67 'zordon': { snapshot: ZordonSnapshot; diff: DiffView; isCollapsed: boolean; isScopeCollapsed: boolean; isRuntimeCollapsed: boolean; isLogCollapsed: boolean; calls: McpCall[]; openCall: string | null; checkouts: Checkout[]; openService: string | null }
68 }
69}
70