Live progress of Stagehand playtests inside Claude Code: a band above the prompt, a detail pane, status line and toasts.

A Claude Code mod that shows Stagehand playtests live, inside Claude Code, while they run in Roblox Studio.
While a run started with run_test, run_steps or run_suite is active you get:
finished/total, the running step with its target tag and elapsed / budget (amber from 75 % of the budget), the last finished step or the failure reason and hint. It collapses to one summary line when the run ends and clears on your next prompt./stagehand; /stagehand off closes it; opened automatically where it docks beside the transcript, which the terminal does from 144 columns, or 110 once you have opened it yourself): status pill, session mode and players, per-target counts (SERVER 3/5 P1 3/7), every step with its status glyph, target, label and duration or live budget bar, failed steps with reason and hint, the last game log lines, Cancel run (press twice) and Copy report path, and the last five runs under Recent.stagehand ▶ spawn-two-players 7/16 · P1 click Play 2.4s / 10s).Everything is read over the session's own stagehand MCP connection (get_run format:"json" and tail_run), so it always follows the daemon this Claude Code session talks to, even when several daemons share one Studio. The daemon needs no changes.
From a terminal session's prompt:
/plugin install stagehand-progress --marketplace <owner>/<repo>
(y to add the marketplace, then a scope.) From a shell: claude plugin install stagehand-progress --marketplace <owner>/<repo>. The mod expects the MCP server to be registered under the name stagehand (see docs/mcp.md).
Set under /config (or pluginConfigs["stagehand-progress"].options in settings):
| Option | Default | Meaning |
|---|---|---|
pollMs | 600 | poll interval while a run is active |
autoOpen | true | open the pane when a run starts, where it docks (fullscreen) |
notify | true | native notification when a run finishes |
historySize | 5 | runs kept under Recent |
logLines | 8 | game log lines in the pane |
The mod is a plain hooks module (no build, no npm deps): hooks/register.tsx registers the hooks, hooks/poll.ts follows the run, hooks/parse.ts and hooks/format.ts are pure, hooks/view/*.tsx draw, types/index.d.ts declares the $.state contract.
Load it from a checkout for one session:
claude --plugin-dir packages/claude-mod
or for every session, put the absolute path in CLAUDE_CODE_PLUGIN_DIRS (the env block of ~/.claude/settings.json works). An interactive session hot-reloads the folder on save. The engine lays the API typings beside the mod in .claude-plugin/types/ on first load (ignored by git), after which tsc -p packages/claude-mod type-checks it.
Checks:
claude plugin validate packages/claude-mod # manifest + what the module hooks and calls
claude plugin test packages/claude-mod # tests/*.test.ts against the engine (fake daemon via mcp.call hooks)
Known gap: a suite's "test N of M" is not exposed by the daemon; the band counts the runs it has seen since the run_suite call started and uses names.length as the total when names was given.
hooks/register.tsx 247 lines1// stagehand-progress: live progress of Stagehand playtests inside Claude Code.
2//
3// A run started through the stagehand MCP server (run_test, run_steps,
4// run_suite) is followed by polling the daemon's own get_run / tail_run
5// tools over the session's connection; the view model lives in $.state and
6// is drawn as a band above the prompt, a pane, the status line and toasts.
7
8import { atom, read, update } from 'claude-code'
9import type { Register } from 'claude-code'
10
11import type { LiveRun, RunHistoryEntry, UiFlags } from '../types'
12import { RUN_GLYPH, elapsedText, fmtSeconds, isTerminal, truncate } from './format'
13import { parseToolText, runningStep } from './parse'
14import { configure, endSuite, isPolling, pinRun, startPolling, stopIfIdle, SERVER } from './poll'
15import { renderBand } from './view/Band'
16import { renderPane } from './view/Pane'
17
18const PANE = 'stagehand'
19const liveAtom = atom({ plugin: 'stagehand-progress', key: 'live' } as const, null as LiveRun | null)
20const historyAtom = atom({ plugin: 'stagehand-progress', key: 'history' } as const, [] as RunHistoryEntry[])
21const uiAtom = atom({ plugin: 'stagehand-progress', key: 'ui' } as const, { confirmCancel: false } as UiFlags)
22const RUN_TOOLS = /^mcp__stagehand__(run_test|run_steps|run_suite|start_session)$/
23
24function numberOption(v: unknown, fallback: number): number {
25 const n = typeof v === 'number' ? v : typeof v === 'string' ? Number(v) : NaN
26 return Number.isFinite(n) && n > 0 ? n : fallback
27}
28function boolOption(v: unknown, fallback: boolean): boolean {
29 return typeof v === 'boolean' ? v : fallback
30}
31
32export const register: Register = (on, options) => {
33 const opts = {
34 pollMs: numberOption(options.pollMs, 600),
35 autoOpen: boolOption(options.autoOpen, true),
36 notify: boolOption(options.notify, true),
37 historySize: numberOption(options.historySize, 5),
38 logLines: Math.floor(numberOption(options.logLines, 8)),
39 }
40
41 // Whether this surface docks panes (fullscreen). Re-derived on every drawing; unknown until one.
42 let isFullscreen: boolean | undefined
43 // The newest run id the daemon knew before the current tool call.
44 let lastSeenRunId: string | null = null
45 let statusClear: { cancel: () => void } | null = null
46 // A run seen before any drawing said whether panes dock: open once a drawing says so.
47 let autoOpenPending: string | null = null
48
49 const statusLine = (live: LiveRun | null): string | undefined => {
50 if (!live || live.error) return undefined
51 if (isTerminal(live.status)) {
52 return `stagehand ${RUN_GLYPH[live.status]} ${live.name} ${live.status} · ${live.counts.passed}/${live.total} passed in ${fmtSeconds(live.elapsedMs)}`
53 }
54 if (live.launchMessage && live.steps.length === 0) return `stagehand ~ ${live.launchMessage}`
55 const s = runningStep(live)
56 const step = s ? ` · ${s.tag} ${truncate(s.label, 40)} ${elapsedText(s.elapsedMs ?? 0, s.timeoutMs)}` : ''
57 return `stagehand ▶ ${live.name} ${live.finished}/${live.total}${step}`
58 }
59
60 on('session.start', async ($, e, next) => {
61 configure({
62 call: (tool, args) => $.mcp.call(SERVER, tool, args),
63 every: (ms, fn) => $.clock.every(ms, fn),
64 after: (ms, fn) => $.clock.after(ms, fn),
65 getLive: () => read($, liveAtom),
66 setLive: (fn) => update($, liveAtom, fn),
67 setHistory: (fn) => update($, historyAtom, fn),
68 setUi: (fn) => update($, uiAtom, fn),
69 options: opts,
70 onTick: (live) => {
71 $.ui.status(statusLine(live))
72 if (live && isTerminal(live.status)) {
73 // Keep the result pinned briefly, then clear; settle ticks must not re-arm it forever.
74 statusClear ??= $.clock.after(10_000, () => {
75 statusClear = null
76 $.ui.status(undefined)
77 })
78 } else {
79 statusClear?.cancel()
80 statusClear = null
81 }
82 if (autoOpenPending && live && live.runId === autoOpenPending && !isTerminal(live.status) && isFullscreen !== undefined) {
83 autoOpenPending = null
84 if (isFullscreen) void $.ui.open({ id: PANE, title: 'Stagehand' })
85 }
86 },
87 onRunSeen: (live) => {
88 if (!opts.autoOpen) return
89 if (isFullscreen === undefined) autoOpenPending = live.runId
90 else if (isFullscreen) void $.ui.open({ id: PANE, title: 'Stagehand' })
91 },
92 onFinish: (live) => {
93 const fail = live.failure
94 const text =
95 live.status === 'passed'
96 ? `✓ ${live.name} passed · ${live.counts.passed}/${live.total} in ${fmtSeconds(live.elapsedMs)}`
97 : live.status === 'failed'
98 ? `✗ ${live.name} failed${fail?.step ? ` at ${fail.step}` : ''}${fail?.display ? ` ${fail.display}` : ''}${fail?.reason ? `: ${fail.reason}` : ''} (${fmtSeconds(live.elapsedMs)})`
99 : `${RUN_GLYPH[live.status]} ${live.name} ${live.status} after ${fmtSeconds(live.elapsedMs)}`
100 $.ui.toast(text, { timeoutMs: live.status === 'passed' ? 5000 : 8000 })
101 if (opts.notify) void $.ui.notify(text, { title: 'Stagehand' })
102 },
103 })
104
105 await $.command.register({
106 name: 'stagehand',
107 description: 'Open the Stagehand run pane (live steps, output, cancel); /stagehand off closes it',
108 argumentHint: 'off',
109 immediate: true,
110 })
111
112 // After a hot reload, resume following a run that was live.
113 const live = await read($, liveAtom)
114 if (live && !isTerminal(live.status) && live.runId !== 'pending' && !isPolling()) {
115 startPolling({ lastSeenRunId: null, runId: live.runId })
116 }
117 return next(e)
118 })
119
120 on('command.run', { command: 'stagehand' }, async ($, e) => {
121 isFullscreen = e.presentation.isFullscreen
122 if (e.args.trim() === 'off') {
123 await $.ui.close({ id: PANE })
124 return { text: 'Stagehand pane closed.' }
125 }
126 const opened = await $.ui.open({ id: PANE, title: 'Stagehand', focus: true })
127 return { text: opened.isPlaced ? 'Stagehand pane opened.' : `Stagehand pane is open but not drawn: ${opened.reason}` }
128 }).catch(($, e, next) => (next.called ? next(e) : { text: 'stagehand-progress: the command failed; see the debug log.' }))
129
130 // A run tool is about to run: start following the daemon before the call blocks.
131 on('tool.call', { tool: RUN_TOOLS }, async ($, e, next) => {
132 const tool = String(e.tool).replace('mcp__stagehand__', '')
133 const isSuite = tool === 'run_suite'
134 const suiteNames = (e as unknown as { names?: unknown }).names
135 const names = isSuite && Array.isArray(suiteNames) ? suiteNames.length : null
136
137 // Remember the newest run before this call so `latest` is not mistaken for the new one.
138 try {
139 const prev = await $.mcp.call(SERVER, 'get_run', { runId: 'latest', format: 'json' })
140 const text = prev.content.find((b): b is { type: 'text'; text: string } => (b as { type?: string }).type === 'text')?.text
141 const o = text ? (JSON.parse(text) as { runId?: unknown }) : null
142 lastSeenRunId = o && typeof o.runId === 'string' ? o.runId : null
143 } catch {
144 lastSeenRunId = null
145 }
146
147 const launch = tool === 'start_session' ? 'starting a Studio session…' : null
148 if (tool === 'start_session') {
149 await update($, liveAtom, () => null)
150 }
151 startPolling({ lastSeenRunId, launchMessage: launch, suite: isSuite ? { total: names } : null })
152 if (launch) $.ui.status(`stagehand ~ ${launch}`)
153
154 const ran = await next(e)
155
156 if (tool === 'start_session') {
157 // No run was created: drop the launch placeholder and the poller it fed.
158 stopIfIdle()
159 await update($, liveAtom, (live) => (live && live.runId === 'pending' ? null : live))
160 $.ui.status(undefined)
161 return ran
162 }
163 if (ran.deny === undefined) {
164 const { runId, dir } = parseToolText(ran.text)
165 if (!isSuite) pinRun(runId, dir)
166 }
167 if (isSuite) endSuite()
168 return ran
169 }).catch(($, e, next) => next(e))
170
171 // A new prompt clears a finished run from the band.
172 on('prompt.submit', async ($, e, next) => {
173 const live = await read($, liveAtom)
174 if (live && isTerminal(live.status) && !isPolling()) {
175 await update($, liveAtom, () => null)
176 statusClear?.cancel()
177 statusClear = null
178 $.ui.status(undefined)
179 }
180 return next(e)
181 }).catch(($, e, next) => next(e))
182
183 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
184 if (e.viewport?.isFullscreen !== undefined) isFullscreen = e.viewport.isFullscreen
185 if (e.props.hasSurvey) return next(e)
186 const live = await read($, liveAtom)
187 if (!live) return next(e)
188 try {
189 const els = $.ui.resolve(e)
190 return renderBand(els, live, e.props.bodyColumns)
191 } catch (err) {
192 $.ui.log(`stagehand-progress: band failed: ${String(err)}`, { to: 'debug' })
193 return next(e)
194 }
195 })
196
197 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
198 if (e.viewport?.isFullscreen !== undefined) isFullscreen = e.viewport.isFullscreen
199 const [live, history, ui] = await Promise.all([read($, liveAtom), read($, historyAtom), read($, uiAtom)])
200 const els = $.ui.resolve(e)
201 const rows = e.props.scroll?.bodyRows ?? e.viewport?.rows ?? 24
202 try {
203 return renderPane(
204 els,
205 {
206 live,
207 history,
208 confirmCancel: ui.confirmCancel,
209 width: e.props.bodyColumns,
210 rows,
211 logLines: opts.logLines,
212 },
213 {
214 onCancel: async () => {
215 const flags = await read($, uiAtom)
216 const current = await read($, liveAtom)
217 if (!current || isTerminal(current.status)) return
218 if (!flags.confirmCancel) {
219 await update($, uiAtom, (u) => ({ ...u, confirmCancel: true }))
220 // Drop the confirmation if the second press never comes.
221 $.clock.after(6000, () => void update($, uiAtom, (u) => ({ ...u, confirmCancel: false })))
222 return
223 }
224 await update($, uiAtom, (u) => ({ ...u, confirmCancel: false }))
225 try {
226 const res = await $.mcp.call(SERVER, 'cancel_run', { runId: current.runId })
227 $.ui.toast(res.isError ? `stagehand: cancel failed` : `⊘ cancelling ${current.name}`)
228 } catch {
229 $.ui.toast('stagehand: cancel failed (daemon not reachable)')
230 }
231 },
232 onCopyReport: async () => {
233 const current = await read($, liveAtom)
234 if (!current?.dir) return
235 const copied = await $.ui.copy({ text: current.dir, surface: e.surface })
236 $.ui.toast(copied.isCopied ? `copied ${current.dir}` : `could not copy: ${copied.reason}`)
237 },
238 },
239 )
240 } catch (err) {
241 $.ui.log(`stagehand-progress: pane failed: ${String(err)}`, { to: 'debug' })
242 const { Text } = els
243 return <Text color="warning">stagehand-progress: pane failed: {String(err)}</Text>
244 }
245 })
246}
247hooks/format.ts 131 lines1// Pure formatting helpers. Mirrors plugin/src/UI/ProgressModel.luau so the
2// band and pane read like the Studio widget.
3
4import type { RunStatus, StepStatus } from '../types'
5
6/** "2.4s" / "0.3s" / "1m 05s" */
7export function fmtSeconds(ms: number): string {
8 const s = Math.max(0, ms) / 1000
9 if (s >= 60) {
10 const m = Math.floor(s / 60)
11 const r = Math.floor(s % 60)
12 return `${m}m ${String(r).padStart(2, '0')}s`
13 }
14 return `${s.toFixed(1)}s`
15}
16
17/** "2.4s / 10s" (budget only when known). */
18export function elapsedText(elapsedMs: number, timeoutMs: number | null): string {
19 const e = fmtSeconds(elapsedMs)
20 if (timeoutMs && timeoutMs > 0) return `${e} / ${fmtBudget(timeoutMs)}`
21 return e
22}
23
24/** Budgets are round numbers: "10s", "1m 30s", "250ms". */
25export function fmtBudget(ms: number): string {
26 if (ms < 1000) return `${ms}ms`
27 const s = ms / 1000
28 if (s >= 60) {
29 const m = Math.floor(s / 60)
30 const r = Math.round(s % 60)
31 return r === 0 ? `${m}m` : `${m}m ${String(r).padStart(2, '0')}s`
32 }
33 return Number.isInteger(s) ? `${s}s` : `${s.toFixed(1)}s`
34}
35
36/** Fraction of the budget used (0..1); `warn` from 75 %. */
37export function budget(elapsedMs: number, timeoutMs: number | null): { fraction: number; warn: boolean } {
38 if (!timeoutMs || timeoutMs <= 0) return { fraction: 0, warn: false }
39 const fraction = Math.min(1, Math.max(0, elapsedMs / timeoutMs))
40 return { fraction, warn: fraction >= 0.75 }
41}
42
43/** A bar of `width` cells filled to `fraction`. */
44export function bar(fraction: number, width: number, fill = '█', empty = '░'): string {
45 const w = Math.max(0, Math.floor(width))
46 if (w === 0) return ''
47 const f = Math.min(1, Math.max(0, fraction))
48 const filled = Math.round(f * w)
49 return fill.repeat(filled) + empty.repeat(w - filled)
50}
51
52/** "client#2" → "P2", "server" → "SERVER". */
53export function tag(target: string): string {
54 const m = /^client#(\d+)$/.exec(target)
55 return m ? `P${m[1]}` : target.toUpperCase()
56}
57
58// Single-cell glyphs only: some terminal fonts draw ⊘ ⚠ ⟳ ↷ two cells wide and shift the row.
59export const STEP_GLYPH: Record<StepStatus, string> = {
60 passed: '✓',
61 failed: '✗',
62 running: '▶',
63 pending: '·',
64 skipped: '»',
65 cancelled: '×',
66}
67
68export const RUN_GLYPH: Record<RunStatus, string> = {
69 queued: '…',
70 running: '▶',
71 passed: '✓',
72 failed: '✗',
73 cancelled: '×',
74 error: '!',
75}
76
77/** "steps.3" → "3", "setup.0" → "s0", "teardown.1" → "t1", "steps.2.1" → "2.1"; cut to `max` cells. */
78export function shortId(id: string, max = 3): string {
79 const m = /^(setup|steps|teardown)\.(.+)$/.exec(id)
80 const short = m ? `${m[1] === 'setup' ? 's' : m[1] === 'teardown' ? 't' : ''}${m[2]}` : id
81 return short.length > max ? `${short.slice(0, max - 1)}…` : short
82}
83
84export const RUN_WORD: Record<RunStatus, string> = {
85 queued: 'QUEUED',
86 running: 'RUNNING',
87 passed: 'PASSED',
88 failed: 'FAILED',
89 cancelled: 'CANCELLED',
90 error: 'ERROR',
91}
92
93/** Theme key for a run or step status. */
94export function statusColor(status: RunStatus | StepStatus): 'success' | 'error' | 'warning' | 'claude' | 'subtle' | 'inactive' {
95 switch (status) {
96 case 'passed':
97 return 'success'
98 case 'failed':
99 case 'error':
100 return 'error'
101 case 'running':
102 return 'claude'
103 case 'cancelled':
104 case 'skipped':
105 return 'inactive'
106 case 'queued':
107 case 'pending':
108 return 'subtle'
109 }
110}
111
112export function isTerminal(status: RunStatus): boolean {
113 return status === 'passed' || status === 'failed' || status === 'cancelled' || status === 'error'
114}
115
116/** Cut to `n` cells with an ellipsis; `n` ≥ 1. */
117export function truncate(s: string, n: number): string {
118 if (n <= 0) return ''
119 if (s.length <= n) return s
120 if (n === 1) return '…'
121 return `${s.slice(0, n - 1)}…`
122}
123
124export function padEnd(s: string, n: number): string {
125 return s.length >= n ? s : s + ' '.repeat(n - s.length)
126}
127
128export function padStart(s: string, n: number): string {
129 return s.length >= n ? s : ' '.repeat(n - s.length) + s
130}
131hooks/parse.ts 209 lines1// Pure mapping from the daemon's tool output to the mod's view model.
2// Shapes come from packages/mcp/src/engine/types.ts (PartialRunResult,
3// StepRecord, RunEvent) and server/tools.ts (result text trailers).
4
5import type { LiveRun, LogLine, RunFailure, RunHistoryEntry, RunStatus, StepStatus, StepView } from '../types'
6import { tag } from './format'
7
8type Loose = Record<string, unknown>
9
10const RUN_STATUSES: readonly RunStatus[] = ['queued', 'running', 'passed', 'failed', 'cancelled', 'error']
11const STEP_STATUSES: readonly StepStatus[] = ['pending', 'running', 'passed', 'failed', 'skipped', 'cancelled']
12
13function obj(v: unknown): Loose | null {
14 return v !== null && typeof v === 'object' && !Array.isArray(v) ? (v as Loose) : null
15}
16function str(v: unknown): string | null {
17 return typeof v === 'string' ? v : null
18}
19function num(v: unknown): number | null {
20 return typeof v === 'number' && Number.isFinite(v) ? v : null
21}
22
23/** The `runId:` / `report:` trailer of a run tool's text, or the JSON a `wait:false` call returns. */
24export function parseToolText(text: string | undefined): { runId: string | null; dir: string | null } {
25 if (!text) return { runId: null, dir: null }
26 const idLine = /^runId:\s*(\S+)\s*$/m.exec(text)
27 const dirLine = /^report:\s*(.+?)\s*$/m.exec(text)
28 if (idLine) return { runId: idLine[1] ?? null, dir: dirLine?.[1] ?? null }
29 const o = tryJson(text)
30 if (o) return { runId: str(o.runId), dir: str(o.dir) }
31 return { runId: null, dir: null }
32}
33
34/** Parse a tool's text as JSON; null when it is not an object. */
35export function tryJson(text: string | undefined): Loose | null {
36 if (!text) return null
37 try {
38 return obj(JSON.parse(text))
39 } catch {
40 return null
41 }
42}
43
44/** The text of an MCP result's first text block. */
45export function mcpText(result: { content?: ReadonlyArray<unknown> } | undefined): string | undefined {
46 for (const block of result?.content ?? []) {
47 const b = obj(block)
48 if (b && b.type === 'text' && typeof b.text === 'string') return b.text
49 }
50 return undefined
51}
52
53function toStep(raw: unknown, now: number): StepView | null {
54 const s = obj(raw)
55 if (!s) return null
56 const id = str(s.id)
57 if (!id) return null
58 const status = (STEP_STATUSES as readonly string[]).includes(String(s.status)) ? (s.status as StepStatus) : 'pending'
59 const target = str(s.target) ?? 'daemon'
60 const startedAt = num(s.startedAt)
61 const timeoutMs = num(s.timeoutMs) ?? 0
62 return {
63 id,
64 label: str(s.label) ?? str(s.display) ?? id,
65 target,
66 tag: tag(target),
67 verb: str(s.verb) ?? '',
68 status,
69 soft: s.soft === true,
70 timeoutMs,
71 startedAt,
72 durationMs: num(s.durationMs),
73 elapsedMs: status === 'running' && startedAt !== null ? Math.max(0, now - startedAt) : null,
74 reason: str(s.reason),
75 hint: str(s.hint),
76 }
77}
78
79function toFailure(raw: unknown): RunFailure | null {
80 const f = obj(raw)
81 if (!f) return null
82 const reason = str(f.reason)
83 if (!reason) return null
84 return { step: str(f.step), display: str(f.display), reason, hint: str(f.hint) }
85}
86
87/**
88 * Build the live view from a `get_run format:"json"` body. Carries over the
89 * poller-owned fields (`logs`, `lastSeq`, `launchMessage`, `suite`, `dir`) from `prev`.
90 */
91export function toLive(raw: unknown, prev: LiveRun | null, now: number): LiveRun | null {
92 const r = obj(raw)
93 if (!r) return null
94 const runId = str(r.runId)
95 if (!runId) return null
96 const status = (RUN_STATUSES as readonly string[]).includes(String(r.status)) ? (r.status as RunStatus) : 'running'
97 const steps = (Array.isArray(r.steps) ? r.steps : []).map((s) => toStep(s, now)).filter((s): s is StepView => s !== null)
98 const c = obj(r.counts) ?? {}
99 const counts = {
100 passed: num(c.passed) ?? 0,
101 failed: num(c.failed) ?? 0,
102 skipped: num(c.skipped) ?? 0,
103 cancelled: num(c.cancelled) ?? 0,
104 pending: num(c.pending) ?? 0,
105 }
106 const startedIso = str(r.startedAt)
107 const startedAt = startedIso ? Date.parse(startedIso) : NaN
108 const finishedIso = str(r.finishedAt)
109 const finishedAt = finishedIso ? Date.parse(finishedIso) : NaN
110 const sess = obj(r.session)
111 const same = prev && prev.runId === runId ? prev : null
112 const durationMs = num(r.durationMs)
113 const elapsedMs =
114 Number.isFinite(finishedAt) && durationMs !== null ? durationMs : Number.isFinite(startedAt) ? Math.max(0, now - startedAt) : 0
115 return {
116 runId,
117 name: str(r.name) ?? runId,
118 status,
119 startedAt: Number.isFinite(startedAt) ? startedAt : null,
120 finishedAt: Number.isFinite(finishedAt) ? finishedAt : null,
121 elapsedMs,
122 session: sess ? { mode: str(sess.mode) ?? '?', players: num(sess.players) ?? 0, reused: sess.reused === true } : null,
123 total: steps.length,
124 finished: steps.filter((s) => s.status !== 'pending' && s.status !== 'running').length,
125 counts,
126 steps,
127 failure: toFailure(r.failure),
128 softFailures: Array.isArray(r.softFailures) ? r.softFailures.length : 0,
129 lastSeq: same?.lastSeq ?? 0,
130 logs: same?.logs ?? [],
131 launchMessage: same?.launchMessage ?? null,
132 dir: str(r.dir) ?? same?.dir ?? null,
133 error: null,
134 suite: same?.suite ?? prev?.suite ?? null,
135 }
136}
137
138/** Fold a `tail_run` body into the live view: log lines, launch messages, the cursor. */
139export function applyEvents(live: LiveRun, raw: unknown, maxLogs: number): LiveRun {
140 const t = obj(raw)
141 if (!t) return live
142 const events = Array.isArray(t.events) ? t.events : []
143 let logs = live.logs
144 let launchMessage = live.launchMessage
145 let lastSeq = num(t.lastSeq) ?? live.lastSeq
146 for (const raw of events) {
147 const ev = obj(raw)
148 if (!ev) continue
149 const seq = num(ev.seq) ?? lastSeq
150 if (seq > lastSeq) lastSeq = seq
151 const type = str(ev.type)
152 if (type === 'log') {
153 const level = ev.level === 'warn' || ev.level === 'error' ? ev.level : 'info'
154 const line: LogLine = { seq, level, role: str(ev.role) ?? str(ev.node) ?? '?', message: str(ev.message) ?? '', stepId: str(ev.stepId) }
155 logs = [...logs, line]
156 } else if (type === 'session') {
157 launchMessage = str(ev.message)
158 } else if (type === 'session.ready' || type === 'phase.started') {
159 launchMessage = null
160 }
161 }
162 if (logs.length > maxLogs) logs = logs.slice(-maxLogs)
163 return { ...live, logs, launchMessage, lastSeq }
164}
165
166export function runningStep(live: LiveRun): StepView | null {
167 return live.steps.find((s) => s.status === 'running') ?? null
168}
169
170export function lastFinishedStep(live: LiveRun): StepView | null {
171 for (let i = live.steps.length - 1; i >= 0; i--) {
172 const s = live.steps[i]
173 if (s && (s.status === 'passed' || s.status === 'failed' || s.status === 'skipped' || s.status === 'cancelled')) return s
174 }
175 return null
176}
177
178export function firstFailedStep(live: LiveRun): StepView | null {
179 return live.steps.find((s) => s.status === 'failed') ?? null
180}
181
182/** Per-target counts in first-seen order. */
183export function perTarget(live: LiveRun): Array<{ tag: string; done: number; total: number; failed: number }> {
184 const out: Array<{ tag: string; done: number; total: number; failed: number }> = []
185 for (const s of live.steps) {
186 let row = out.find((r) => r.tag === s.tag)
187 if (!row) {
188 row = { tag: s.tag, done: 0, total: 0, failed: 0 }
189 out.push(row)
190 }
191 row.total++
192 if (s.status !== 'pending' && s.status !== 'running') row.done++
193 if (s.status === 'failed') row.failed++
194 }
195 return out
196}
197
198export function summarize(live: LiveRun): RunHistoryEntry {
199 const failed = live.failure?.step ?? firstFailedStep(live)?.id ?? null
200 return {
201 runId: live.runId,
202 name: live.name,
203 status: live.status,
204 durationMs: live.elapsedMs,
205 failingStep: failed,
206 reason: live.failure?.reason ?? firstFailedStep(live)?.reason ?? null,
207 }
208}
209hooks/poll.ts 267 lines1// The poller: while a Stagehand run is active, one tick asks the daemon for
2// the run snapshot (get_run json) and new events (tail_run), then writes the
3// view model to $.state so the band and pane redraw.
4//
5// It never holds `$`: register.tsx hands it closures that spell each call.
6
7import type { McpToolResult, Timer } from 'claude-code'
8
9import type { LiveRun, RunHistoryEntry, UiFlags } from '../types'
10import { isTerminal } from './format'
11import { applyEvents, mcpText, summarize, toLive, tryJson } from './parse'
12
13export const SERVER = 'stagehand'
14
15export type PollOptions = {
16 pollMs: number
17 logLines: number
18 historySize: number
19}
20
21export type PollDeps = {
22 /** `$.mcp.call(SERVER, tool, args)` */
23 call: (tool: string, args: Record<string, unknown>) => Promise<McpToolResult>
24 /** `$.clock.every` */
25 every: (ms: number, fn: () => void) => Timer
26 /** `$.clock.after` */
27 after: (ms: number, fn: () => void) => Timer
28 /** `read($, liveAtom)` */
29 getLive: () => Promise<LiveRun | null>
30 /** `update($, liveAtom, fn)` */
31 setLive: (fn: (live: LiveRun | null) => LiveRun | null) => Promise<unknown>
32 /** `update($, historyAtom, fn)` */
33 setHistory: (fn: (h: RunHistoryEntry[]) => RunHistoryEntry[]) => Promise<unknown>
34 /** `update($, uiAtom, fn)` */
35 setUi: (fn: (u: UiFlags) => UiFlags) => Promise<unknown>
36 options: PollOptions
37 /** A run reached a terminal status (toast, notify, status line). */
38 onFinish: (live: LiveRun) => void
39 /** First tick that saw the run as live (auto-open the pane). */
40 onRunSeen: (live: LiveRun) => void
41 /** Every tick that produced a view (status line). */
42 onTick: (live: LiveRun | null) => void
43}
44
45type Poller = {
46 timer: Timer
47 /** `"latest"` until the tool result names the run. */
48 runId: string
49 /** The newest run before this call; a `latest` equal to it has not started yet. */
50 lastSeenRunId: string | null
51 dir: string | null
52 busy: boolean
53 misses: number
54 seen: boolean
55 /** The last run this poller finished; keeps a suite's next run from re-firing finish for the old one. */
56 finishedRunId: string | null
57 suite: { seen: number; total: number | null } | null
58 launchMessage: string | null
59}
60
61const MAX_MISSES = 10
62/** After a run finishes, keep polling this long for a suite's next run. */
63const SETTLE_MS = 2500
64
65// Module state: a hot reload drops the timer with the module, and session.start resumes from $.state.
66let poller: Poller | null = null
67let deps: PollDeps | null = null
68let settleTimer: Timer | null = null
69
70export function configure(d: PollDeps): void {
71 deps = d
72}
73
74export function isPolling(): boolean {
75 return poller !== null
76}
77
78export type StartArgs = {
79 lastSeenRunId: string | null
80 runId?: string
81 launchMessage?: string | null
82 suite?: { total: number | null } | null
83}
84
85/** Start (or re-aim) the poller. */
86export function startPolling(args: StartArgs): void {
87 if (!deps) return
88 if (poller) {
89 poller.lastSeenRunId = args.lastSeenRunId ?? poller.lastSeenRunId
90 if (args.runId) poller.runId = args.runId
91 if (args.launchMessage !== undefined) poller.launchMessage = args.launchMessage
92 if (args.suite !== undefined) poller.suite = args.suite ? { seen: 0, total: args.suite.total } : null
93 return
94 }
95 const p: Poller = {
96 timer: deps.every(Math.max(150, deps.options.pollMs), () => void tick()),
97 runId: args.runId ?? 'latest',
98 lastSeenRunId: args.lastSeenRunId,
99 dir: null,
100 busy: false,
101 misses: 0,
102 seen: false,
103 finishedRunId: null,
104 suite: args.suite ? { seen: 0, total: args.suite.total } : null,
105 launchMessage: args.launchMessage ?? null,
106 }
107 poller = p
108 void tick()
109}
110
111/** The tool result named the run: pin the poller to it. */
112export function pinRun(runId: string | null, dir: string | null): void {
113 if (!poller) return
114 if (runId) poller.runId = runId
115 if (dir) poller.dir = dir
116}
117
118/** The tool call that owned a suite returned: stop counting runs for it. */
119export function endSuite(): void {
120 if (poller) poller.suite = null
121}
122
123/** Stop a poller that never saw a run (a `start_session` call creates none). */
124export function stopIfIdle(): boolean {
125 if (poller && poller.runId === 'latest' && !poller.seen) {
126 stopPolling()
127 return true
128 }
129 return false
130}
131
132export function stopPolling(): void {
133 poller?.timer.cancel()
134 poller = null
135 settleTimer?.cancel()
136 settleTimer = null
137}
138
139async function call(tool: string, args: Record<string, unknown>): Promise<McpToolResult | null> {
140 if (!deps) return null
141 try {
142 return await deps.call(tool, args)
143 } catch {
144 return null
145 }
146}
147
148async function tick(): Promise<void> {
149 const p = poller
150 if (!p || !deps || p.busy) return
151 p.busy = true
152 try {
153 await step(p, deps)
154 } finally {
155 p.busy = false
156 }
157}
158
159async function step(p: Poller, d: PollDeps): Promise<void> {
160 const now = Date.now()
161 const got = await call('get_run', { runId: p.runId, format: 'json' })
162 const body = got && !got.isError ? tryJson(mcpText(got)) : null
163 if (!body) {
164 p.misses++
165 if (p.misses >= MAX_MISSES) {
166 const why = got ? (mcpText(got)?.slice(0, 120) ?? 'empty reply') : 'mcp.call failed'
167 await d.setLive((live) => (live ? { ...live, error: `daemon not answering (get_run: ${why})` } : live))
168 d.onTick(await d.getLive())
169 stopPolling()
170 }
171 return
172 }
173 p.misses = 0
174
175 // `latest` still names the run that was newest before this call: nothing has started yet.
176 const bodyRunId = typeof body.runId === 'string' ? body.runId : null
177 if (p.runId === 'latest' && bodyRunId && bodyRunId === p.lastSeenRunId) {
178 if (p.launchMessage) {
179 const msg = p.launchMessage
180 await d.setLive((live) => (live && live.runId === 'pending' ? { ...live, launchMessage: msg } : launchPlaceholder(msg, now)))
181 }
182 d.onTick(await d.getLive())
183 return
184 }
185 if (bodyRunId && bodyRunId === p.finishedRunId) {
186 // The run we already reported; a suite may still start another one.
187 d.onTick(await d.getLive())
188 return
189 }
190
191 const prev = await d.getLive()
192 let next = toLive(body, prev, now)
193 if (!next) {
194 p.misses++
195 return
196 }
197 const isNew = !prev || prev.runId !== next.runId
198 if (p.dir && !next.dir) next = { ...next, dir: p.dir }
199 if (p.suite) {
200 if (isNew) p.suite.seen++
201 next = { ...next, suite: { ...p.suite } }
202 } else if (isNew) {
203 next = { ...next, suite: null }
204 }
205 if (p.launchMessage && !next.launchMessage && next.status === 'queued') next = { ...next, launchMessage: p.launchMessage }
206
207 const tail = await call('tail_run', { runId: next.runId, afterSeq: next.lastSeq, limit: 200 })
208 const tailBody = tail && !tail.isError ? tryJson(mcpText(tail)) : null
209 if (tailBody) next = applyEvents(next, tailBody, Math.max(d.options.logLines, 8) * 5)
210 if (next.status !== 'queued' && !next.launchMessage) p.launchMessage = null
211
212 const done = next
213 await d.setLive(() => done)
214 if (!p.seen) {
215 p.seen = true
216 d.onRunSeen(done)
217 }
218 d.onTick(done)
219
220 if (isTerminal(done.status)) {
221 p.finishedRunId = done.runId
222 p.lastSeenRunId = done.runId
223 p.runId = 'latest'
224 const size = Math.max(1, d.options.historySize)
225 await d.setHistory((h) => [summarize(done), ...h.filter((e) => e.runId !== done.runId)].slice(0, size))
226 await d.setUi((u) => ({ ...u, confirmCancel: false }))
227 d.onFinish(done)
228 // Give a suite the chance to start its next run; otherwise stop.
229 settleTimer?.cancel()
230 settleTimer = d.after(SETTLE_MS, () => {
231 settleTimer = null
232 const q = poller
233 if (!q || !q.finishedRunId || q.runId !== 'latest') return
234 void (async () => {
235 const probe = await call('get_run', { runId: 'latest', format: 'json' })
236 const b = probe && !probe.isError ? tryJson(mcpText(probe)) : null
237 const id = b && typeof b.runId === 'string' ? b.runId : null
238 if (!id || id === q.finishedRunId) stopPolling()
239 })()
240 })
241 }
242}
243
244function launchPlaceholder(message: string, now: number): LiveRun {
245 return {
246 runId: 'pending',
247 name: 'starting session',
248 status: 'queued',
249 startedAt: now,
250 finishedAt: null,
251 elapsedMs: 0,
252 session: null,
253 total: 0,
254 finished: 0,
255 counts: { passed: 0, failed: 0, skipped: 0, cancelled: 0, pending: 0 },
256 steps: [],
257 failure: null,
258 softFailures: 0,
259 lastSeq: 0,
260 logs: [],
261 launchMessage: message,
262 dir: null,
263 error: null,
264 suite: null,
265 }
266}
267hooks/view/Band.tsx 116 lines1// The band above the prompt: one or two lines while a run is active.
2
3import type { Elements, RenderElement } from 'claude-code'
4
5import type { LiveRun } from '../../types'
6import { RUN_GLYPH, STEP_GLYPH, bar, budget, elapsedText, fmtSeconds, isTerminal, statusColor, truncate } from '../format'
7import { lastFinishedStep, runningStep } from '../parse'
8
9/** The three elements every surface draws; the union of tables collapses to this. */
10export type Els = Pick<Elements['terminal'], 'Box' | 'Text' | 'Button'>
11
12export function renderBand(els: Els, live: LiveRun, width: number): RenderElement {
13 const { Box, Text } = els
14 const w = Math.max(20, width)
15 const glyph = RUN_GLYPH[live.status]
16 const color = statusColor(live.status)
17
18 if (live.error) {
19 return (
20 <Box flexDirection="column">
21 <Text color="warning" wrap="truncate-end">
22 stagehand: {live.error}
23 </Text>
24 </Box>
25 )
26 }
27
28 if (live.launchMessage && live.steps.length === 0) {
29 return (
30 <Box flexDirection="column">
31 <Text wrap="truncate-end">
32 <Text color="claude">~</Text> <Text weight="semibold">stagehand</Text> <Text dimColor>launching Studio ·</Text> {live.launchMessage}
33 </Text>
34 </Box>
35 )
36 }
37
38 if (isTerminal(live.status)) {
39 const fail = live.failure
40 const summary = `${live.counts.passed} passed, ${live.counts.failed} failed, ${live.counts.skipped} skipped · ${fmtSeconds(live.elapsedMs)}`
41 return (
42 <Box flexDirection="column">
43 <Text wrap="truncate-end">
44 <Text color={color} weight="semibold">
45 {glyph} {live.name} {live.status}
46 </Text>{' '}
47 <Text dimColor>{summary}</Text>
48 </Text>
49 {fail && (
50 <Text wrap="truncate-end">
51 {' '}
52 <Text color="error">{fail.step ? `${fail.step} ` : ''}{fail.display ?? ''}</Text> <Text>{fail.reason}</Text>
53 {fail.hint ? <Text dimColor> · {fail.hint}</Text> : null}
54 </Text>
55 )}
56 </Box>
57 )
58 }
59
60 const running = runningStep(live)
61 const last = lastFinishedStep(live)
62 const counter = `${live.finished}/${live.total}`
63 const suite = live.suite ? `test ${live.suite.seen}${live.suite.total ? `/${live.suite.total}` : ''} · ` : ''
64 const head = `${glyph} ${suite}${live.name}`
65 // Right side: "<TAG> <label> <elapsed>/<budget>"
66 let right = ''
67 let warn = false
68 if (running) {
69 const el = running.elapsedMs ?? 0
70 const b = budget(el, running.timeoutMs)
71 warn = b.warn
72 right = `${running.tag} · ${running.label} ${elapsedText(el, running.timeoutMs)}`
73 } else if (live.status === 'queued') {
74 right = live.launchMessage ?? 'queued'
75 }
76 const fixed = head.length + 2 + counter.length + 2 + 2
77 const barWidth = Math.max(6, Math.min(24, Math.floor((w - fixed) * 0.3)))
78 const rightRoom = Math.max(10, w - fixed - barWidth - 1)
79
80 return (
81 <Box flexDirection="column">
82 <Box flexDirection="row" gap={1}>
83 <Box flexShrink={0}>
84 <Text color="claude" weight="semibold" wrap="truncate-end">
85 {head}
86 </Text>
87 </Box>
88 <Box flexShrink={0}>
89 <Text color="claude">{bar(live.total ? live.finished / live.total : 0, barWidth)}</Text>
90 </Box>
91 <Box flexShrink={0}>
92 <Text tabular>{counter}</Text>
93 </Box>
94 <Box flexShrink={1} overflow="hidden">
95 <Text color={warn ? 'warning' : undefined} wrap="truncate-end">
96 {truncate(right, rightRoom)}
97 </Text>
98 </Box>
99 </Box>
100 <Box flexDirection="row" justifyContent="space-between">
101 <Text dimColor wrap="truncate-end">
102 {last ? lastLine(last) : live.launchMessage ? ` ${live.launchMessage}` : ' waiting for the first step'}
103 </Text>
104 <Text dimColor>/stagehand</Text>
105 </Box>
106 </Box>
107 )
108}
109
110function lastLine(s: { id: string; status: string; label: string; durationMs: number | null; reason: string | null }): string {
111 const mark = STEP_GLYPH[s.status as keyof typeof STEP_GLYPH] ?? '·'
112 const time = s.durationMs !== null ? ` (${fmtSeconds(s.durationMs)})` : ''
113 const why = s.status === 'failed' && s.reason ? `: ${s.reason}` : ''
114 return ` last: ${mark} ${s.id} ${s.label}${time}${why}`
115}
116hooks/view/Pane.tsx 327 lines1// The detail pane: header, overall bar, step list, output tail, buttons, history.
2//
3// Layout rules: fixed columns sit in `flexShrink={0}` boxes so only labels give
4// way when a row is wider than the pane; glyphs are single-cell in common
5// terminal fonts (no ⊘ ⚠ ⟳, which some fonts draw two cells wide).
6
7import type { RenderElement } from 'claude-code'
8
9import type { LiveRun, RunHistoryEntry, StepView } from '../../types'
10import { RUN_GLYPH, RUN_WORD, STEP_GLYPH, bar, budget, elapsedText, fmtBudget, fmtSeconds, isTerminal, padEnd, padStart, shortId, statusColor, truncate } from '../format'
11import { perTarget } from '../parse'
12import type { Els } from './Band'
13
14export type PaneHandlers = {
15 onCancel: () => void
16 onCopyReport: () => void
17}
18
19export type PaneCtx = {
20 live: LiveRun | null
21 history: RunHistoryEntry[]
22 confirmCancel: boolean
23 width: number
24 /** Rows the body may show at once. */
25 rows: number
26 logLines: number
27}
28
29const IDX_W = 3
30const TAG_W = 6
31
32export function renderPane(els: Els, ctx: PaneCtx, on: PaneHandlers): RenderElement {
33 const { Box, Text } = els
34 const { live } = ctx
35 const w = Math.max(30, ctx.width - 2)
36 if (!live) {
37 return (
38 <Box flexDirection="column" paddingX={1}>
39 <Text>
40 <Text weight="semibold">Stagehand</Text> <Text dimColor>· no run yet</Text>
41 </Text>
42 {ctx.history.length > 0 && renderHistory(els, ctx.history, w)}
43 </Box>
44 )
45 }
46
47 const color = statusColor(live.status)
48 const sess = live.session
49 ? `${live.session.mode}${live.session.players ? ` · ${live.session.players} player${live.session.players === 1 ? '' : 's'}` : ''}${live.session.reused ? ' (reused)' : ''}`
50 : live.launchMessage
51 ? 'launching…'
52 : ''
53 const targets = perTarget(live)
54 const targetLine = targets.length > 1 ? targets.map((t) => `${t.tag} ${t.done}/${t.total}`).join(' ') : ''
55 const anyFailed = targets.some((t) => t.failed > 0)
56 const done = isTerminal(live.status)
57 const counter = `${live.finished}/${live.total} steps`
58 const barWidth = Math.max(10, Math.min(30, w - counter.length - targetLine.length - 4))
59
60 const logs = live.logs.slice(-Math.max(0, ctx.logLines))
61 const recent = ctx.history.filter((entry) => entry.runId !== live.runId).slice(0, 5)
62
63 // Rows everything but the step list takes, so the list gets the rest (never fewer than 8 or the whole list).
64 const fixedRows =
65 3 + (live.launchMessage ? 1 : 0) + (live.error ? 1 : 0) + (live.failure ? 2 + (live.failure.hint ? 1 : 0) : 0) + (logs.length > 0 ? logs.length + 2 : 0) + 2 + (recent.length > 0 ? recent.length + 2 : 0)
66 const stepRoom = Math.max(Math.min(live.steps.length + 2, 8), ctx.rows - fixedRows)
67 const window = windowSteps(live.steps, stepRoom)
68
69 return (
70 <Box flexDirection="column" paddingX={1}>
71 <Box flexDirection="row" gap={1}>
72 <Box flexShrink={0}>
73 <Text color={color} weight="semibold" inverse>
74 {' '}{RUN_GLYPH[live.status]} {RUN_WORD[live.status]}{' '}
75 </Text>
76 </Box>
77 <Box flexShrink={1} overflow="hidden">
78 <Text weight="semibold" wrap="truncate-end">
79 {live.name}
80 </Text>
81 </Box>
82 <Box flexShrink={2} overflow="hidden">
83 <Text dimColor wrap="truncate-end">
84 {sess}
85 </Text>
86 </Box>
87 <Box flexGrow={1} />
88 <Box flexShrink={0}>
89 <Text tabular dimColor>
90 {fmtSeconds(live.elapsedMs)}
91 </Text>
92 </Box>
93 </Box>
94 <Box flexDirection="row" gap={1}>
95 <Box flexShrink={0}>
96 <Text color={done ? color : 'claude'}>{bar(live.total ? live.finished / live.total : 0, barWidth)}</Text>
97 </Box>
98 <Box flexShrink={0}>
99 <Text tabular>{counter}</Text>
100 </Box>
101 {targetLine && (
102 <Box flexShrink={1} overflow="hidden">
103 <Text color={anyFailed ? 'error' : done ? 'success' : undefined} dimColor={!anyFailed && !done} wrap="truncate-end">
104 {targetLine}
105 </Text>
106 </Box>
107 )}
108 </Box>
109 {live.launchMessage && (
110 <Text wrap="truncate-end">
111 <Text color="claude">~</Text> {live.launchMessage}
112 </Text>
113 )}
114 {live.error && <Text color="warning">{live.error}</Text>}
115 <Text> </Text>
116
117 {window.before > 0 && <Text dimColor>{` ${window.before} earlier…`}</Text>}
118 {window.steps.map((s) => renderStep(els, s, w))}
119 {window.after > 0 && <Text dimColor>{` ${window.after} more pending…`}</Text>}
120
121 {live.failure && (
122 <Box flexDirection="column" marginTop={1}>
123 <Text wrap="wrap">
124 <Text color="error" weight="semibold">
125 Failed{live.failure.step ? ` at ${live.failure.step}` : ''}:
126 </Text>{' '}
127 {live.failure.reason}
128 </Text>
129 {live.failure.hint && (
130 <Text dimColor wrap="wrap">
131 {' hint: '}
132 {live.failure.hint}
133 </Text>
134 )}
135 </Box>
136 )}
137
138 {logs.length > 0 && (
139 <Box flexDirection="column" marginTop={1}>
140 <Text dimColor>{`Output ${'─'.repeat(Math.max(0, w - 7))}`}</Text>
141 {logs.map((l) => (
142 <Text wrap="truncate-end">
143 {' '}
144 <Text dimColor>{padEnd(truncate(l.role, 7), 7)}</Text>{' '}
145 <Text color={l.level === 'error' ? 'error' : l.level === 'warn' ? 'warning' : undefined}>
146 {l.level === 'warn' ? '! ' : l.level === 'error' ? '!! ' : ''}
147 {l.message}
148 </Text>
149 </Text>
150 ))}
151 </Box>
152 )}
153
154 <Box flexDirection="row" gap={2} marginTop={1}>
155 {!done && live.runId !== 'pending' && (
156 <Box flexShrink={0}>
157 <els.Button key="cancel" variant={ctx.confirmCancel ? 'primary' : 'secondary'} hotkey="c" onPress={on.onCancel}>
158 {ctx.confirmCancel ? 'Really cancel?' : 'Cancel run'}
159 </els.Button>
160 </Box>
161 )}
162 {live.dir && (
163 <Box flexShrink={0}>
164 <els.Button key="copy" hotkey="r" onPress={on.onCopyReport}>
165 Copy report path
166 </els.Button>
167 </Box>
168 )}
169 <Box flexGrow={1} />
170 <Box flexShrink={1} overflow="hidden">
171 <Text dimColor wrap="truncate-start">
172 {live.runId}
173 </Text>
174 </Box>
175 </Box>
176
177 {recent.length > 0 && renderHistory(els, recent, w)}
178 </Box>
179 )
180}
181
182function renderStep(els: Els, s: StepView, w: number): RenderElement {
183 const { Box, Text } = els
184 const glyph = STEP_GLYPH[s.status]
185 const color = statusColor(s.status)
186 // Right column: duration, running budget bar, or pending budget.
187 let right: RenderElement
188 let rightW: number
189 if (s.status === 'running') {
190 const el = s.elapsedMs ?? 0
191 const b = budget(el, s.timeoutMs)
192 const text = elapsedText(el, s.timeoutMs)
193 rightW = (s.timeoutMs > 0 ? 9 : 0) + text.length
194 right = (
195 <Text color={b.warn ? 'warning' : 'claude'} tabular>
196 {s.timeoutMs > 0 ? `${bar(b.fraction, 8)} ` : ''}
197 {text}
198 </Text>
199 )
200 } else if (s.status === 'pending') {
201 const text = s.timeoutMs > 0 ? `(${fmtBudget(s.timeoutMs)})` : ''
202 rightW = text.length
203 right = <Text dimColor>{text}</Text>
204 } else {
205 const text = s.durationMs !== null && s.durationMs > 0 ? fmtSeconds(s.durationMs) : s.status === 'skipped' ? 'skipped' : '—'
206 rightW = text.length
207 right = (
208 <Text dimColor tabular>
209 {text}
210 </Text>
211 )
212 }
213 const labelRoom = Math.max(8, w - 2 - IDX_W - TAG_W - rightW - 4)
214 return (
215 <Box flexDirection="column">
216 <Box flexDirection="row" gap={1}>
217 <Box flexShrink={0} width={1}>
218 <Text color={color}>{glyph}</Text>
219 </Box>
220 <Box flexShrink={0} width={IDX_W}>
221 <Text dimColor tabular>
222 {padStart(shortId(s.id, IDX_W), IDX_W)}
223 </Text>
224 </Box>
225 <Box flexShrink={0} width={TAG_W}>
226 <Text color={s.tag === 'SERVER' ? 'warning' : s.tag.startsWith('P') ? 'claude' : undefined} dimColor={s.status === 'pending'}>
227 {padEnd(s.tag, TAG_W)}
228 </Text>
229 </Box>
230 <Box flexGrow={1} flexShrink={1} overflow="hidden">
231 <Text dimColor={s.status === 'pending' || s.status === 'skipped'} weight={s.status === 'running' ? 'semibold' : undefined} wrap="truncate-end">
232 {truncate(s.label, labelRoom)}
233 {s.soft ? ' (soft)' : ''}
234 </Text>
235 </Box>
236 <Box flexShrink={0}>{right}</Box>
237 </Box>
238 {s.status === 'failed' && s.reason && (
239 <Text color="error" wrap="truncate-end">
240 {' > '}
241 {s.reason}
242 </Text>
243 )}
244 {s.status === 'failed' && s.hint && (
245 <Text dimColor wrap="truncate-end">
246 {' '}
247 {s.hint}
248 </Text>
249 )}
250 </Box>
251 )
252}
253
254function renderHistory(els: Els, history: RunHistoryEntry[], w: number): RenderElement {
255 const { Box, Text } = els
256 const nameW = Math.max(12, Math.min(28, w - 24))
257 return (
258 <Box flexDirection="column" marginTop={1}>
259 <Text dimColor>{`Recent ${'─'.repeat(Math.max(0, w - 7))}`}</Text>
260 {history.slice(0, 5).map((entry) => (
261 <Box flexDirection="row" gap={1}>
262 <Box flexShrink={0} width={1}>
263 <Text color={statusColor(entry.status)}>{RUN_GLYPH[entry.status]}</Text>
264 </Box>
265 <Box flexShrink={0} width={nameW}>
266 <Text wrap="truncate-end">{truncate(entry.name, nameW)}</Text>
267 </Box>
268 <Box flexShrink={0} width={7}>
269 <Text dimColor tabular>
270 {padStart(fmtSeconds(entry.durationMs), 7)}
271 </Text>
272 </Box>
273 <Box flexShrink={1} overflow="hidden">
274 <Text wrap="truncate-end">
275 {entry.failingStep ? <Text color="error">{entry.failingStep} </Text> : null}
276 {entry.reason ? <Text dimColor>{entry.reason}</Text> : null}
277 </Text>
278 </Box>
279 </Box>
280 ))}
281 </Box>
282 )
283}
284
285/** Choose which steps to draw in `room` rows: keep the running (or first failed) step in view. */
286export function windowSteps(steps: StepView[], room: number): { steps: StepView[]; before: number; after: number } {
287 // Failed rows take up to three lines; budget for them.
288 const cost = (s: StepView) => (s.status === 'failed' ? 1 + (s.reason ? 1 : 0) + (s.hint ? 1 : 0) : 1)
289 const total = steps.reduce((n, s) => n + cost(s), 0)
290 if (total <= room) return { steps, before: 0, after: 0 }
291 const focus = Math.max(
292 0,
293 steps.findIndex((s) => s.status === 'running'),
294 steps.findIndex((s) => s.status === 'failed'),
295 )
296 // Last non-pending step (no findLastIndex: the hooks runtime may predate ES2023).
297 let lastDone = 0
298 for (let i = steps.length - 1; i >= 0; i--) {
299 if (steps[i]?.status !== 'pending') {
300 lastDone = i
301 break
302 }
303 }
304 const anchor = focus > 0 ? focus : lastDone
305 // Grow a window around the anchor, favouring what comes after it slightly.
306 let lo = anchor
307 let hi = anchor
308 let used = cost(steps[anchor] as StepView) + 2 // two marker rows
309 while (used < room && (lo > 0 || hi < steps.length - 1)) {
310 if (hi < steps.length - 1 && (hi - anchor <= anchor - lo || lo === 0)) {
311 hi++
312 used += cost(steps[hi] as StepView)
313 } else if (lo > 0) {
314 lo--
315 used += cost(steps[lo] as StepView)
316 } else break
317 if (used > room) {
318 // Undo the last growth.
319 if (hi > anchor && used - cost(steps[hi] as StepView) <= room - 0) {
320 hi--
321 } else if (lo < anchor) lo++
322 break
323 }
324 }
325 return { steps: steps.slice(lo, hi + 1), before: lo, after: steps.length - 1 - hi }
326}
327types/index.d.ts 95 lines1// State contract of the stagehand-progress mod. Every value the drawings read
2// lives in `$.state` under these keys (module variables die on hot reload).
3
4export type StepStatus = 'pending' | 'running' | 'passed' | 'failed' | 'skipped' | 'cancelled'
5export type RunStatus = 'queued' | 'running' | 'passed' | 'failed' | 'cancelled' | 'error'
6
7export type StepView = {
8 id: string
9 /** `label ?? display` from the daemon's StepRecord. */
10 label: string
11 /** Raw target (`server`, `client#2`, `edit`, `daemon`). */
12 target: string
13 /** Display tag (`SERVER`, `P2`, `EDIT`, `DAEMON`). */
14 tag: string
15 verb: string
16 status: StepStatus
17 soft: boolean
18 /** Step budget in ms; 0 when instant. */
19 timeoutMs: number
20 startedAt: number | null
21 durationMs: number | null
22 /** Elapsed at the last poll tick, running steps only. */
23 elapsedMs: number | null
24 reason: string | null
25 hint: string | null
26}
27
28export type LogLine = {
29 seq: number
30 level: 'info' | 'warn' | 'error'
31 role: string
32 message: string
33 stepId: string | null
34}
35
36export type RunFailure = {
37 step: string | null
38 display: string | null
39 reason: string
40 hint: string | null
41}
42
43export type LiveRun = {
44 runId: string
45 name: string
46 status: RunStatus
47 /** Epoch ms when the run started; null while queued. */
48 startedAt: number | null
49 finishedAt: number | null
50 /** Elapsed at the last poll tick. */
51 elapsedMs: number
52 session: { mode: string; players: number; reused: boolean } | null
53 total: number
54 finished: number
55 counts: { passed: number; failed: number; skipped: number; cancelled: number; pending: number }
56 steps: StepView[]
57 failure: RunFailure | null
58 softFailures: number
59 /** tail_run cursor. */
60 lastSeq: number
61 logs: LogLine[]
62 /** Studio launch progress while a session starts. */
63 launchMessage: string | null
64 /** Report directory, once known. */
65 dir: string | null
66 /** Last poll error, shown in the band. */
67 error: string | null
68 /** Set while a run_suite call is in flight: runs seen since it started. */
69 suite: { seen: number; total: number | null } | null
70}
71
72export type RunHistoryEntry = {
73 runId: string
74 name: string
75 status: RunStatus
76 durationMs: number
77 failingStep: string | null
78 reason: string | null
79}
80
81export type UiFlags = {
82 /** The Cancel button was pressed once and awaits confirmation. */
83 confirmCancel: boolean
84}
85
86declare module 'claude-code' {
87 interface PluginState {
88 'stagehand-progress': {
89 live: LiveRun | null
90 history: RunHistoryEntry[]
91 ui: UiFlags
92 }
93 }
94}
95