SLOPSHOPPER

zstack-pane

/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.

newpanecommandprocesstimer
v0.1.0no licenseupdated 2026-10-09zainokta/zstack/mods/zstack-pane
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · zstack-pane
│ ┃ zstack ✕ › fix the failing auth test and add an audit log call │ ┃ zstack status --json did not return JSON │ ┃ [ Refresh ] [ Close ] ⏺ Read(src/auth.ts) │ ⎿ 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 │ │ › /zstack-pane │ ⎿ zstack-pane: zstack pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · zstack
zstack status --json did not return JSON [ Refresh ] [ Close ]
README

zstack-pane

/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.

  • Header: goal, run id, state, agents spawned/max, active/max_parallel, time of the last refresh.
  • Agents (id, role, harness/model, state, task, unread) and tasks (id, state, owner, rounds, title). When the pane is narrow, the least useful columns are dropped first.
  • The last 5 blocker and question messages from zstack msg log.
  • A field that sends 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.
  • Refreshes every 3 s while open. Closing it stops the timer.

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.

Source 2 files
hooks/register.tsx 332 lines
1import { 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}
332
types/index.d.ts 38 lines
1export 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