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.

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.
hooks/register.tsx 175 lines1import { 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}
175hooks/journal.ts 221 lines1// 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}
221types/index.d.ts 47 lines1export 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