/zstack-pane (or /zs) opens a live pane of the current zstack run: agents, tasks, recent blockers and questions, and a field to steer all agents.

/zstack-pane (or /zs) opens a pane with the current zstack run. It answers "status?", "is it stuck?" and "any hanging tasks?" without asking the manager.
blocker and question messages from zstack msg log.zstack msg send --as user --to all --kind steer. It is sent as user, not manager, so the manager also gets the steer: zstack never delivers a message back to its sender.Limits: it reads only the default run ($ZSTACK_RUN or the last zstack init). With no run it shows no zstack run — start one with zstack. Mobile has no input field, so it has no steer field there. If zstack fails, the pane shows the error and keeps polling. It never blocks anything, so it has no fail-closed path.
hooks/register.tsx 332 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { ZstackPaneMessage, ZstackPaneRun, ZstackPaneView } from '../types'
5
6const PANE = 'zstack'
7const EVERY_MS = 3000
8const ALERTS = 5
9export const NO_RUN = 'no zstack run — start one with `zstack`'
10
11const view = atom({ plugin: 'zstack-pane', key: 'view' } as const, { kind: 'loading' })
12const draft = atom({ plugin: 'zstack-pane', key: 'draft' } as const, '')
13const notice = atom({ plugin: 'zstack-pane', key: 'notice' } as const, '')
14
15type Json = Record<string, unknown>
16const obj = (v: unknown): Json => (typeof v === 'object' && v !== null ? (v as Json) : {})
17const str = (v: unknown, or = ''): string => (typeof v === 'string' ? v : typeof v === 'number' ? String(v) : or)
18const num = (v: unknown): number => (typeof v === 'number' ? v : 0)
19const firstLine = (s: string): string => s.trim().split('\n')[0] ?? ''
20
21export function clip(s: string, width: number): string {
22 if (width <= 0) return ''
23 return s.length <= width ? s : `${s.slice(0, Math.max(0, width - 1))}…`
24}
25
26// `zstack status --json` plus the blocker/question messages, flattened for drawing.
27export function toRun(status: unknown, messages: unknown[]): ZstackPaneRun {
28 const s = obj(status)
29 const meta = obj(s.run)
30 const unread = obj(s.unread)
31 const agents = Object.values(obj(s.agents)).map(raw => {
32 const a = obj(raw)
33 const id = str(a.id)
34 return {
35 id,
36 role: str(a.role),
37 harness: str(a.harness),
38 model: str(a.model),
39 state: str(a.state),
40 task: typeof a.task === 'string' ? a.task : null,
41 unread: num(unread[id]),
42 }
43 })
44 const tasks = Object.values(obj(s.tasks)).map(raw => {
45 const t = obj(raw)
46 return {
47 id: str(t.id),
48 state: str(t.state),
49 owner: typeof t.owner === 'string' ? t.owner : null,
50 rounds: num(t.rounds),
51 title: str(t.title),
52 }
53 })
54 const alerts: ZstackPaneMessage[] = messages
55 .map(raw => {
56 const m = obj(raw)
57 return { seq: num(m.seq), kind: str(m.kind), from: str(m.from), to: str(m.to), body: str(m.body) }
58 })
59 .sort((a, b) => a.seq - b.seq)
60 .slice(-ALERTS)
61 return {
62 id: str(meta.id),
63 goal: str(meta.goal),
64 state: str(meta.state),
65 maxAgents: num(meta.max_agents),
66 maxParallel: num(meta.max_parallel),
67 agents,
68 tasks,
69 alerts,
70 }
71}
72
73const ACTIVE = new Set(['running', 'waiting'])
74
75async function zstack($: EngineInterface, args: string[]) {
76 return $.process.run(['zstack', ...args], { timeoutMs: 10_000 })
77}
78
79async function load($: EngineInterface): Promise<ZstackPaneView> {
80 let status, blockers, questions
81 try {
82 ;[status, blockers, questions] = await Promise.all([
83 zstack($, ['status', '--json']),
84 zstack($, ['msg', 'log', '--json', '--kind', 'blocker', '--tail', String(ALERTS)]),
85 zstack($, ['msg', 'log', '--json', '--kind', 'question', '--tail', String(ALERTS)]),
86 ])
87 } catch (err) {
88 return { kind: 'error', text: `zstack did not run (${firstLine(String(err)) || 'not on PATH?'})` }
89 }
90 if (status.exitCode !== 0) {
91 return /no run/.test(status.stderr) ? { kind: 'none' } : { kind: 'error', text: firstLine(status.stderr) || `zstack status exited ${status.exitCode}` }
92 }
93 try {
94 const parse = (r: { exitCode: number; stdout: string }): unknown[] => {
95 const v: unknown = r.exitCode === 0 ? JSON.parse(r.stdout) : []
96 return Array.isArray(v) ? v : []
97 }
98 const run = toRun(JSON.parse(status.stdout), [...parse(blockers), ...parse(questions)])
99 return { kind: 'run', run, at: new Date().toTimeString().slice(0, 8) }
100 } catch {
101 return { kind: 'error', text: 'zstack status --json did not return JSON' }
102 }
103}
104
105// Module state: the poll timer dies with the module on reload, and session.start restarts it.
106let timer: Timer | undefined
107let isLoading = false
108
109async function refresh($: EngineInterface): Promise<void> {
110 if (isLoading) return
111 isLoading = true
112 try {
113 const next = await load($)
114 await update($, view, () => next)
115 } finally {
116 isLoading = false
117 }
118}
119
120function startPolling($: EngineInterface): void {
121 timer ??= $.clock.every(EVERY_MS, () => void refresh($))
122}
123
124function stopPolling(): void {
125 timer?.cancel()
126 timer = undefined
127}
128
129async function steer($: EngineInterface, text: string): Promise<void> {
130 const body = text.trim()
131 if (!body) return
132 // Sent as `user`, not `manager`: zstack skips a sender's own messages, so a
133 // manager-signed steer to `all` would never reach the manager itself.
134 const sent = await zstack($, ['msg', 'send', '--as', 'user', '--to', 'all', '--kind', 'steer', `--body=${body}`, '--json']).catch(
135 (err: unknown) => ({ exitCode: -1, stdout: '', stderr: String(err) }),
136 )
137 if (sent.exitCode === 0) {
138 let seq = ''
139 try {
140 seq = ` #${str(obj(JSON.parse(sent.stdout)).seq)}`
141 } catch {}
142 await update($, draft, () => '')
143 await update($, notice, () => `steer${seq} sent to all agents`)
144 await refresh($)
145 } else {
146 await update($, notice, () => `steer failed: ${firstLine(sent.stderr) || `exit ${sent.exitCode}`}`)
147 }
148}
149
150type Col<T> = { head: string; width: number; rank: number; flex?: true; cell: (row: T) => string }
151
152// Keeps the columns that fit `room` in rank order, shown in their own order;
153// the flex column takes whatever room is left.
154export function fit<T>(cols: Col<T>[], room: number): Col<T>[] {
155 const kept = new Set<Col<T>>()
156 let used = 0
157 for (const col of [...cols].sort((a, b) => a.rank - b.rank)) {
158 const need = col.width + (kept.size ? 1 : 0)
159 if (used + need <= room) {
160 kept.add(col)
161 used += need
162 }
163 }
164 return cols.filter(c => kept.has(c)).map(c => (c.flex ? { ...c, width: c.width + room - used } : c))
165}
166
167type Agent = ZstackPaneRun['agents'][number]
168type Task = ZstackPaneRun['tasks'][number]
169
170const AGENT_COLS: Col<Agent>[] = [
171 { head: 'agent', width: 12, rank: 0, cell: a => a.id },
172 { head: 'role', width: 8, rank: 5, cell: a => a.role },
173 { head: 'harness/model', width: 18, rank: 4, cell: a => `${a.harness}/${a.model}` },
174 { head: 'state', width: 8, rank: 1, cell: a => a.state },
175 { head: 'task', width: 5, rank: 2, cell: a => a.task ?? '-' },
176 { head: 'unread', width: 6, rank: 3, cell: a => String(a.unread) },
177]
178
179const TASK_COLS: Col<Task>[] = [
180 { head: 'task', width: 5, rank: 0, cell: t => t.id },
181 { head: 'state', width: 8, rank: 1, cell: t => t.state },
182 { head: 'owner', width: 12, rank: 3, cell: t => t.owner ?? '-' },
183 { head: 'rounds', width: 6, rank: 4, cell: t => String(t.rounds) },
184 { head: 'title', width: 10, rank: 2, flex: true, cell: t => t.title },
185]
186
187async function openPane($: EngineInterface) {
188 await $.ui.open({ id: PANE, title: 'zstack' })
189 await refresh($)
190 startPolling($)
191 return { text: 'zstack pane opened.' }
192}
193
194// Our own $.ui.close skips our ui.close hook, so stop the timer here too.
195async function closePane($: EngineInterface) {
196 stopPolling()
197 await $.ui.close({ id: PANE })
198}
199
200export const register: Register = on => {
201 on('session.start', async ($, e, next) => {
202 const description = 'Show the current zstack run: agents, tasks, blockers; steer all agents'
203 await $.command.register({ name: 'zstack-pane', description })
204 await $.command.register({ name: 'zs', description })
205 // A reload keeps the pane up but drops the timer: pick polling back up.
206 if ((await $.ui.panes()).some(p => p.id === PANE)) {
207 void refresh($)
208 startPolling($)
209 }
210 return next(e)
211 })
212
213 on('command.run', { command: 'zstack-pane' }, $ => openPane($))
214 on('command.run', { command: 'zs' }, $ => openPane($))
215
216 on('ui.close', { id: PANE }, ($, e, next) => {
217 stopPolling()
218 return next(e)
219 }).catch(($, e, next) => next(e))
220
221 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
222 const els = $.ui.resolve(e)
223 const { Box, Text, Button } = els
224 const Input = 'Input' in els ? els.Input : undefined // mobile draws no field
225 const width = Math.max(10, e.props.bodyColumns)
226 const v = await read($, view)
227 const said = await read($, notice)
228 const typed = await read($, draft)
229
230 const table = <T,>(key: string, cols: Col<T>[], rows: T[], rowKey: (row: T) => string) => {
231 const shown = fit(cols, width)
232 const line = (k: string, cells: string[], dim: boolean) => (
233 <Box key={k} flexDirection="row" columnGap={1}>
234 {shown.map((c, i) => (
235 <Box width={c.width} flexShrink={0}>
236 <Text dimColor={dim} wrap="truncate-end">
237 {clip(cells[i] ?? '', c.width)}
238 </Text>
239 </Box>
240 ))}
241 </Box>
242 )
243 return (
244 <Box key={key} flexDirection="column">
245 {line(`${key}:head`, shown.map(c => c.head), true)}
246 {rows.map(r => line(`${key}:${rowKey(r)}`, shown.map(c => c.cell(r)), false))}
247 </Box>
248 )
249 }
250
251 let body
252 if (v.kind === 'loading') {
253 body = <Text dimColor>reading zstack…</Text>
254 } else if (v.kind === 'none') {
255 body = (
256 <Box key="hint">
257 <Text>{NO_RUN}</Text>
258 </Box>
259 )
260 } else if (v.kind === 'error') {
261 body = (
262 <Box key="error">
263 <Text color="red">{clip(v.text, width * 2)}</Text>
264 </Box>
265 )
266 } else {
267 const r = v.run
268 const active = r.agents.filter(a => ACTIVE.has(a.state)).length
269 body = (
270 <Box flexDirection="column">
271 <Box key="goal">
272 <Text bold>{clip(r.goal, width * 2)}</Text>
273 </Box>
274 <Box key="header">
275 <Text dimColor>
276 {`run ${r.id} · ${r.state} · agents ${r.agents.length}/${r.maxAgents} · active ${active}/${r.maxParallel} · ${v.at}`}
277 </Text>
278 </Box>
279 <Box marginTop={1} flexDirection="column">
280 {r.agents.length ? table('agent', AGENT_COLS, r.agents, a => a.id) : <Text dimColor>no agents yet</Text>}
281 </Box>
282 <Box marginTop={1} flexDirection="column">
283 {r.tasks.length ? table('task', TASK_COLS, r.tasks, t => t.id) : <Text dimColor>no tasks yet</Text>}
284 </Box>
285 <Box marginTop={1} flexDirection="column">
286 <Text bold>blockers / questions</Text>
287 {r.alerts.length ? (
288 r.alerts.map(m => (
289 <Box key={`alert:${m.seq}`}>
290 <Text color={m.kind === 'blocker' ? 'red' : 'yellow'} wrap="truncate-end">
291 {clip(`#${m.seq} ${m.kind} ${m.from}→${m.to}: ${firstLine(m.body)}`, width)}
292 </Text>
293 </Box>
294 ))
295 ) : (
296 <Text dimColor>none</Text>
297 )}
298 </Box>
299 </Box>
300 )
301 }
302
303 return (
304 <Box flexDirection="column">
305 {body}
306 {said ? (
307 <Box key="notice">
308 <Text dimColor>{clip(said, width)}</Text>
309 </Box>
310 ) : null}
311 {Input && v.kind === 'run' ? (
312 <Box marginTop={1}>
313 <Input
314 key="steer"
315 label="steer all: "
316 placeholder="message every agent"
317 submitLabel="send"
318 value={typed}
319 onInput={(text: string) => void update($, draft, () => text)}
320 onSubmit={(text: string) => void steer($, text)}
321 />
322 </Box>
323 ) : null}
324 <Box flexDirection="row" columnGap={1}>
325 <Button key="refresh" label="Refresh" hotkey="r" onPress={() => refresh($)} />
326 <Button key="close" label="Close" role="dismiss" onPress={() => closePane($)} />
327 </Box>
328 </Box>
329 )
330 })
331}
332types/index.d.ts 38 lines1export type ZstackPaneAgent = {
2 id: string
3 role: string
4 harness: string
5 model: string
6 state: string
7 task: string | null
8 unread: number
9}
10
11export type ZstackPaneTask = { id: string; state: string; owner: string | null; rounds: number; title: string }
12
13export type ZstackPaneMessage = { seq: number; kind: string; from: string; to: string; body: string }
14
15export type ZstackPaneRun = {
16 id: string
17 goal: string
18 state: string
19 maxAgents: number
20 maxParallel: number
21 agents: ZstackPaneAgent[]
22 tasks: ZstackPaneTask[]
23 alerts: ZstackPaneMessage[]
24}
25
26// What the pane draws: a run, the "no run" hint, or why zstack could not be read.
27export type ZstackPaneView =
28 | { kind: 'loading' }
29 | { kind: 'none' }
30 | { kind: 'error'; text: string }
31 | { kind: 'run'; run: ZstackPaneRun; at: string }
32
33declare module 'claude-code' {
34 interface PluginState {
35 'zstack-pane': { view: ZstackPaneView; draft: string; notice: string }
36 }
37}
38