SLOPSHOPPER

workflow-watch

Live view of a running Workflow: each stage as it starts and finishes, with what it is doing right now, in a pane, a band above the prompt and the status line.

newpanebandcommandtoaststatus
★ 12v0.1.0MITupdated 2026-10-04alonbaron/claude-skills/mods/workflow-watch
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · workflow-watch
│ ┃ Workflow ✕ › fix the failing auth test and add an audit log call │ ┃ No workflow run yet. Start one with the │ ┃ Workflow tool; this pane follows the ● workflow-watch: workflow-watch: pane open │ ┃ newest run of this session. ⏺ 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 │ │ › /workflow-watch │ ⎿ workflow-watch: No workflow run found for this session yet; run │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Workflow
No workflow run yet. Start one with the Workflow tool; this pane follows the newest run of this session.
README

workflow-watch

A mod (a plugin of function hooks) that follows the newest Workflow run of the session and draws its stages live: a pane, a band above the prompt while stages run, the status line, and a toast as each stage finishes. /workflow-watch opens the pane and prints the same report as text, which is all the mobile app shows.

Each row is one subagent: phase, outcome, label, the model alias the script asked for and the id it resolved to, the effort level, output tokens, elapsed time, and either the tool it is calling right now or its result in one line. The build loop's stages get a result line each (plan, scout, spec, build, measure, refute, arbiter, fix, close); any other workflow gets a generic one.

It reads Claude Code's own files under ~/.claude/projects/<project>/<session>/subagents/workflows/<run>/ every two seconds: journal.jsonl for the stages, agent-<id>.meta.json for the alias, agent-<id>.jsonl for model, effort, tokens and tool calls. That layout is internal; when it moves, the mod shows nothing rather than guessing.

Install with the marketplace (/plugin install workflow-watch@alonbaron) or load it for one session with claude --plugin-dir <this folder>. The pane needs a terminal of 144 columns, or /workflow-watch to seat it narrower. scripts/tracker.mjs renders the same rows to HTML or PNG from outside a session.

Check it: claude plugin validate mods/workflow-watch, claude plugin test mods/workflow-watch, tsc -p mods/workflow-watch once a session has laid the types beside it.

Source 3 files
hooks/register.tsx 175 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Stage, Watch } from '../types'
5import { EMPTY, applyEntries, elapsed, finished, groupByPhase, parseLines, readMeta, readTranscript, report, running, stageLine, statusLine } from './journal'
6
7const PANE = 'workflow-watch'
8const watch = atom({ plugin: 'workflow-watch', key: 'watch' } as const, EMPTY)
9
10type Engine = Parameters<Parameters<Register>[0]>[2] extends (...a: infer A) => unknown ? A[0] : never
11type Listing = Awaited<ReturnType<Engine['fs']['list']>>
12
13const listOr = async ($: Engine, path: string): Promise<Listing> => {
14  try {
15    return await $.fs.list(path)
16  } catch {
17    return []
18  }
19}
20
21/** The newest journal.jsonl under <projects>/<any project>/<this session>/subagents/workflows/<run>/. */
22async function findJournal($: Engine): Promise<{ dir: string; path: string; run: string } | null> {
23  const home = await $.env.get('HOME')
24  if (!home) return null
25  const sid = await $.session.id()
26  const projects = `${home}/.claude/projects`
27  let best: { dir: string; run: string; mtime: number } | null = null
28  for (const d of await listOr($, projects)) {
29    if (d.kind !== 'dir') continue
30    const wf = `${projects}/${d.name}/${sid}/subagents/workflows`
31    if (!(await $.fs.exists(wf))) continue
32    for (const r of await listOr($, wf)) {
33      if (r.kind !== 'dir') continue
34      const j = (await listOr($, `${wf}/${r.name}`)).find(f => f.name === 'journal.jsonl')
35      if (j && (!best || j.mtimeMs > best.mtime)) best = { dir: `${wf}/${r.name}`, run: r.name, mtime: j.mtimeMs }
36    }
37  }
38  return best ? { dir: best.dir, path: `${best.dir}/journal.jsonl`, run: best.run } : null
39}
40
41/** Model, effort, tokens and tool calls from the stage's own transcript; a finished stage is read once more, then left alone. */
42async function enrich($: Engine, dir: string, s: Stage): Promise<Stage> {
43  if (!s.agentId || s.isSettled) return s
44  let next = s
45  if (next.alias === null) {
46    try {
47      next = { ...next, alias: readMeta(await $.fs.read(`${dir}/agent-${s.agentId}.meta.json`)) }
48    } catch {
49      /* not written yet */
50    }
51  }
52  try {
53    const facts = readTranscript(await $.fs.read(`${dir}/agent-${s.agentId}.jsonl`))
54    next = { ...next, ...facts, isSettled: next.endedAt !== null }
55  } catch {
56    /* not written yet, or over the read limit: keep what we have */
57  }
58  return next
59}
60
61/** Opens the pane unless it is already up; says in the transcript whether the surface placed it. */
62async function openPane($: Engine): Promise<void> {
63  const isUp = (await $.ui.panes()).some(p => p.id === PANE)
64  if (isUp) return
65  const opened = await $.ui.open({ id: PANE, title: 'Workflow' })
66  $.ui.log(opened.isPlaced ? 'workflow-watch: pane open' : `workflow-watch: pane not placed on this surface (${opened.reason}); /workflow-watch prints the report as text`)
67}
68
69async function tick($: Engine): Promise<void> {
70  const found = await findJournal($)
71  if (!found) return
72  const now = await $.clock.now()
73  let text = ''
74  try {
75    text = await $.fs.read(found.path)
76  } catch {
77    return
78  }
79  const prev = await read($, watch)
80  const sameRun = prev.journal === found.path
81  const base: Stage[] = sameRun ? prev.stages : []
82  const stages: Stage[] = []
83  for (const s of applyEntries(base, parseLines(text), now)) stages.push(await enrich($, found.dir, s))
84  const next: Watch = { journal: found.path, run: found.run, stages, now }
85
86  const wasDone = new Set(base.filter(s => s.endedAt !== null).map(s => s.key))
87  const justDone = stages.filter(s => s.endedAt !== null && !wasDone.has(s.key))
88  for (const s of justDone) $.ui.toast(`${s.isEmpty ? '✗' : '✓'} ${s.phase}: ${s.label} — ${s.summary ?? ''}`.slice(0, 120))
89  if (!sameRun && stages.length) {
90    $.ui.toast(`workflow-watch: following run ${found.run}`)
91    void openPane($)
92  }
93
94  const changed = !sameRun || JSON.stringify(prev.stages) !== JSON.stringify(stages) || running(next).length > 0
95  if (changed) await update($, watch, () => next)
96  $.ui.status(statusLine(next))
97}
98
99export const register: Register = on => {
100  on('session.start', async ($, e, next) => {
101    await $.command.register({
102      name: 'workflow-watch',
103      description: 'Live view of the running Workflow: each stage, its model, effort and tokens, what it is doing, its result',
104    })
105    $.clock.every(2000, () => void tick($))
106    void openPane($)
107    return next(e)
108  })
109
110  // The pane is drawn by the terminal and the desktop app only; the text report below is what
111  // every surface shows (the mobile app shows nothing else), so it carries the whole picture.
112  on('command.run', { command: 'workflow-watch' }, async $ => {
113    const w = await read($, watch)
114    const opened = await $.ui.open({ id: PANE, title: 'Workflow' })
115    if (!w.run || w.stages.length === 0) return { text: 'No workflow run found for this session yet; run one with the Workflow tool, then type /workflow-watch again.' }
116    const where = opened.isPlaced ? 'pane open' : 'no pane on this surface; this text is the view, type /workflow-watch again for a fresh one'
117    return { text: `${report(w)}\n(${where})` }
118  })
119
120  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
121    const { Box, Text } = $.ui.resolve(e)
122    const w = await read($, watch)
123    const width = Math.max(40, e.props.bodyColumns ?? e.viewport?.columns ?? 80)
124    if (!w.run || w.stages.length === 0) {
125      return (
126        <Box flexDirection="column" paddingX={1}>
127          <Text dimColor>No workflow run yet. Start one with the Workflow tool; this pane follows the newest run of this session.</Text>
128        </Box>
129      )
130    }
131    const live = running(w)
132    const done = finished(w)
133    const tokens = w.stages.reduce((n, s) => n + s.outputTokens, 0)
134    return (
135      <Box flexDirection="column" paddingX={1}>
136        <Text bold>
137          {w.run} · {live.length} running · {done.length} done · {(tokens / 1000).toFixed(1)}k output tokens
138        </Text>
139        {groupByPhase(w.stages).map(g => (
140          <Box key={`phase-${g.phase}`} flexDirection="column" marginTop={1}>
141            <Text bold dimColor={g.stages.every(s => s.endedAt !== null)}>
142              {g.phase || '(no phase)'}
143            </Text>
144            {g.stages.map(s => (
145              <Text key={s.key} dimColor={s.endedAt !== null} wrap="truncate-end">
146                {`  ${stageLine(s, w.now)}`.slice(0, width - 2)}
147              </Text>
148            ))}
149          </Box>
150        ))}
151      </Box>
152    )
153  })
154
155  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
156    const w = await read($, watch)
157    const live = running(w)
158    const isQuiet = e.props.hasSurvey || !w.run || live.length === 0
159    if (isQuiet) return next(e)
160    const { Box, Text } = $.ui.resolve(e)
161    const width = Math.max(40, e.props.bodyColumns ?? 80)
162    const head = `${w.run}: ${finished(w).length} done · ${live.length} running`
163    return (
164      <Box flexDirection="column">
165        <Text bold wrap="truncate-end">{head}</Text>
166        {live.slice(0, 4).map(s => (
167          <Text key={s.key} dimColor wrap="truncate-end">
168            {`  ▶ ${elapsed(w.now - s.startedAt)} ${s.label} [${s.model ?? s.alias ?? '?'} ${s.effort ?? ''}]${s.lastTool ? ' · ' + s.lastTool : ''}`.slice(0, width)}
169          </Text>
170        ))}
171      </Box>
172    )
173  })
174}
175
hooks/journal.ts 221 lines
1// Pure functions over a workflow run's files (journal.jsonl, agent-<id>.jsonl, agent-<id>.meta.json):
2// no `$`, so they test alone.
3import type { Stage, Watch } from '../types'
4
5export type Entry = {
6  type: string
7  key?: string
8  agentId?: string
9  label?: string
10  phase?: string
11  result?: unknown
12}
13
14export const EMPTY: Watch = { journal: null, run: null, stages: [], now: 0 }
15
16/** Parses JSON lines; a half-flushed last line is left for the next read. */
17export function parseLines(text: string): Entry[] {
18  const lines = text.split('\n').filter(line => line.length > 0)
19  const out: Entry[] = []
20  for (let i = 0; i < lines.length; i++) {
21    try {
22      const value: unknown = JSON.parse(lines[i] as string)
23      if (value && typeof value === 'object' && typeof (value as Entry).type === 'string') out.push(value as Entry)
24    } catch {
25      if (i === lines.length - 1) break
26    }
27  }
28  return out
29}
30
31/** Folds the journal's entries over the stages seen so far. */
32export function applyEntries(prev: Stage[], entries: Entry[], now: number): Stage[] {
33  const byKey = new Map(prev.map(s => [s.key, s]))
34  const order = prev.map(s => s.key)
35  for (const e of entries) {
36    if (!e.key) continue
37    if (e.type === 'started') {
38      const known = byKey.get(e.key)
39      if (known) {
40        byKey.set(e.key, { ...known, agentId: e.agentId ?? known.agentId, label: e.label ?? known.label, phase: e.phase ?? known.phase })
41      } else {
42        byKey.set(e.key, {
43          key: e.key,
44          agentId: e.agentId ?? null,
45          label: e.label ?? e.key.slice(0, 12),
46          phase: e.phase ?? '',
47          startedAt: now,
48          endedAt: null,
49          summary: null,
50          isEmpty: false,
51          alias: null,
52          model: null,
53          effort: null,
54          outputTokens: 0,
55          calls: 0,
56          lastTool: null,
57          isSettled: false,
58        })
59        order.push(e.key)
60      }
61    } else if (e.type === 'result') {
62      const known = byKey.get(e.key)
63      if (!known) continue
64      if (known.endedAt !== null) continue
65      const isEmpty = e.result === null || e.result === undefined
66      byKey.set(e.key, { ...known, endedAt: now, isEmpty, summary: isEmpty ? '(no result)' : summarize(known.label, e.result) })
67    }
68  }
69  return order.map(k => byKey.get(k) as Stage)
70}
71
72type Rec = Record<string, unknown>
73const rec = (r: unknown): Rec => (r && typeof r === 'object' ? (r as Rec) : {})
74
75/** What a transcript (agent-<id>.jsonl) says about its stage so far. */
76export type TranscriptFacts = { model: string | null; effort: string | null; outputTokens: number; calls: number; lastTool: string | null }
77
78/** Reads model, effort, output tokens and tool calls off a transcript's lines. */
79export function readTranscript(text: string): TranscriptFacts {
80  const facts: TranscriptFacts = { model: null, effort: null, outputTokens: 0, calls: 0, lastTool: null }
81  for (const line of text.split('\n')) {
82    if (!line) continue
83    let e: Rec
84    try {
85      e = rec(JSON.parse(line))
86    } catch {
87      continue
88    }
89    const msg = rec(e.message)
90    if (e.type === 'assistant') {
91      if (typeof msg.model === 'string') facts.model = msg.model
92      if (typeof e.effort === 'string') facts.effort = e.effort
93      const usage = rec(msg.usage)
94      if (typeof usage.output_tokens === 'number') facts.outputTokens += usage.output_tokens
95    }
96    const content = msg.content
97    if (!Array.isArray(content)) continue
98    for (const block of content) {
99      const b = rec(block)
100      if (b.type !== 'tool_use') continue
101      facts.calls += 1
102      const input = rec(b.input)
103      const arg = [input.command, input.file_path, input.pattern, input.description].find(v => typeof v === 'string') as string | undefined
104      facts.lastTool = `${String(b.name ?? '?')}${arg ? ' ' + arg.replace(/\s+/g, ' ').slice(0, 50) : ''}`
105    }
106  }
107  return facts
108}
109
110/** The alias the script asked for, from agent-<id>.meta.json. */
111export function readMeta(text: string): string | null {
112  try {
113    const m = rec(JSON.parse(text))
114    return typeof m.model === 'string' ? m.model : null
115  } catch {
116    return null
117  }
118}
119
120const count = (a: unknown): number => (Array.isArray(a) ? a.length : 0)
121const str = (v: unknown, n = 80): string => String(v ?? '').replace(/\s+/g, ' ').slice(0, n)
122
123/**
124 * One line per stage result. The alon-skills build-loop labels are known
125 * (plan, scout, spec, research, build, measure, refute, arbiter, fix, close);
126 * any other label gets a short generic line.
127 */
128export function summarize(label: string, result: unknown): string {
129  const role = label.split(':')[0] ?? ''
130  if (typeof result === 'string') return str(result, 100)
131  const r = rec(result)
132  switch (role) {
133    case 'plan': {
134      const stale = rec(r.staleBlocked)
135      if (r.staleBlocked) return `stale row ${str(stale.taskId)}: ${str(stale.reason)}`
136      return r.taskId ? `picked ${str(r.taskId)} on ${str(r.branch)}${r.resuming ? ' (resuming)' : ''}` : `stop: ${str(r.stopReason)}`
137    }
138    case 'scout':
139      return `${count(r.files)} files, ${count(r.tests)} tests, ${count(r.docSections)} doc sections`
140    case 'spec':
141      return r.stopReason ? `stop: ${str(r.stopReason)}` : `spec at ${str(r.specPath)}; docs changed ${count(r.docsChanged)}; unverified APIs ${count(r.unverifiedApis)}`
142    case 'research':
143      return `research at ${str(r.researchPath)}; still unverified ${count(r.unverifiedRemaining)}`
144    case 'build':
145      return `ok=${String(r.ok)} commits=${count(r.commits)} tests=${r.testsPassed ? 'pass' : 'FAIL'} lint=${r.lintPassed ? 'pass' : 'FAIL'} deviations=${count(r.deviations)}${r.blockedReason ? ` BLOCKED: ${str(r.blockedReason)}` : ''}`
146    case 'measure':
147      return `${String(r.filesChanged)} files, ${String(r.linesChanged)} lines${r.docsOnly ? ', docs only' : ''}${r.touchesTrustBoundary ? ', trust boundary' : ''}`
148    case 'refute': {
149      const first = rec((r.blocking as unknown[] | undefined)?.[0])
150      const head = `${String(r.verdict).toUpperCase()} blocking=${count(r.blocking)} advisory=${count(r.advisory)} testsRan=${String(r.testsRan)}`
151      return first.issue ? `${head} | ${str(first.file)}: ${str(first.issue, 60)}` : head
152    }
153    case 'arbiter':
154      return ((r.rulings as unknown[] | undefined) ?? []).map(x => `${str(rec(x).key)} ${str(rec(x).ruling)}`).join('; ') || 'no rulings'
155    case 'fix':
156      return `fixed=${count(r.fixed)} disputed=${count(r.notFixed)} commits=${count(r.commits)}`
157    case 'close':
158      return r.ok ? `row updated, commit ${str(r.commit) || '?'}` : 'close reported not ok'
159    default:
160      return str(JSON.stringify(result), 100)
161  }
162}
163
164/** Stages in journal order, grouped by phase in first-seen order. */
165export function groupByPhase(stages: Stage[]): Array<{ phase: string; stages: Stage[] }> {
166  const groups: Array<{ phase: string; stages: Stage[] }> = []
167  for (const s of stages) {
168    const g = groups.find(x => x.phase === s.phase)
169    if (g) g.stages.push(s)
170    else groups.push({ phase: s.phase, stages: [s] })
171  }
172  return groups
173}
174
175export const running = (w: Watch): Stage[] => w.stages.filter(s => s.endedAt === null)
176export const finished = (w: Watch): Stage[] => w.stages.filter(s => s.endedAt !== null)
177
178export function elapsed(ms: number): string {
179  const s = Math.max(0, Math.round(ms / 1000))
180  const m = Math.floor(s / 60)
181  return `${String(m).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`
182}
183
184/** `fable→claude-fable-5-1 high 0.8k`: what ran the stage and what it cost. */
185export function who(s: Stage): string {
186  const model = s.alias && s.model ? `${s.alias}→${s.model}` : (s.model ?? s.alias ?? '?')
187  const tokens = s.outputTokens >= 1000 ? `${(s.outputTokens / 1000).toFixed(1)}k` : String(s.outputTokens)
188  return `${model} ${s.effort ?? 'n/a'} ${tokens}`
189}
190
191/** One stage as a line: glyph, time, label, who ran it, then the live tool or the result. */
192export function stageLine(s: Stage, now: number): string {
193  const isLive = s.endedAt === null
194  const glyph = isLive ? '▶' : s.isEmpty ? '✗' : '✓'
195  const time = elapsed((isLive ? now : (s.endedAt as number)) - s.startedAt)
196  const tail = isLive ? (s.lastTool ? `${s.calls} calls · ${s.lastTool}` : 'starting…') : (s.summary ?? '')
197  return `${glyph} ${time}  ${s.label}  [${who(s)}]  ${tail}`
198}
199
200/** The one-line status: what runs now, and the tally. */
201export function statusLine(w: Watch): string | undefined {
202  if (!w.run || w.stages.length === 0) return undefined
203  const live = running(w)
204  const done = finished(w).length
205  if (live.length === 0) return `${w.run}: done, ${done} stages`
206  const names = live.map(s => s.label).join(', ')
207  return `${w.run}: ${live.length} running (${names.slice(0, 60)}${names.length > 60 ? '…' : ''}), ${done} done`
208}
209
210/** The whole picture as plain text: what the command prints, the same lines the pane draws. */
211export function report(w: Watch): string {
212  const live = running(w)
213  const done = finished(w)
214  const lines = [`${w.run}: ${live.length} running, ${done.length} done`]
215  for (const g of groupByPhase(w.stages)) {
216    lines.push(`${g.phase || '(no phase)'}:`)
217    for (const s of g.stages) lines.push(`  ${stageLine(s, w.now)}`)
218  }
219  return lines.join('\n')
220}
221
types/index.d.ts 47 lines
1export type Stage = {
2  /** The journal's key for this agent() call. */
3  key: string
4  /** The subagent's loop id; its transcript is agent-<id>.jsonl beside the journal. */
5  agentId: string | null
6  label: string
7  phase: string
8  /** $.clock.now() at the tick that first saw the stage. */
9  startedAt: number
10  /** $.clock.now() at the tick that saw its result; null while running. */
11  endedAt: number | null
12  /** One line about the result, or null while running. */
13  summary: string | null
14  /** True when the journal recorded a null result (skipped or died). */
15  isEmpty: boolean
16  /** The model alias the script asked for (from the agent's meta.json), or null. */
17  alias: string | null
18  /** The model id the engine resolved the alias to, from the transcript, or null. */
19  model: string | null
20  /** The effort level the transcript recorded; null for a model without one. */
21  effort: string | null
22  /** Output tokens the stage generated so far, thinking included. */
23  outputTokens: number
24  /** Tool calls seen in the transcript so far. */
25  calls: number
26  /** The last tool the stage called, with a short argument. */
27  lastTool: string | null
28  /** True once a finished stage's transcript was read for the last time. */
29  isSettled: boolean
30}
31
32export type Watch = {
33  /** The journal.jsonl being tailed, or null when no run was found yet. */
34  journal: string | null
35  /** The run's directory name (the run id). */
36  run: string | null
37  stages: Stage[]
38  /** $.clock.now() at the last tick, so elapsed times redraw. */
39  now: number
40}
41
42declare module 'claude-code' {
43  interface PluginState {
44    'workflow-watch': { watch: Watch }
45  }
46}
47