SLOPSHOPPER

stagehand-progress

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

panebandguardcommandtoaststatus
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · stagehand-progress
│ ┃ Stagehand ✕ › fix the failing auth test and add an audit log call │ ┃ Stagehand · no run yet │ ⏺ 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 │ │ › /stagehand │ ⎿ stagehand-progress: Stagehand pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Stagehand
Stagehand · no run yet
README

stagehand-progress

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:

  • A band above the prompt (any terminal width): run name, overall bar, 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.
  • A pane (/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.
  • The status line under the prompt (stagehand ▶ spawn-two-players 7/16 · P1 click Play 2.4s / 10s).
  • A toast and a native notification when the run finishes.

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.

Install

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

Options

Set under /config (or pluginConfigs["stagehand-progress"].options in settings):

OptionDefaultMeaning
pollMs600poll interval while a run is active
autoOpentrueopen the pane when a run starts, where it docks (fullscreen)
notifytruenative notification when a run finishes
historySize5runs kept under Recent
logLines8game log lines in the pane

Developing

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.

Source 7 files
hooks/register.tsx 247 lines
1// 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}
247
hooks/format.ts 131 lines
1// 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}
131
hooks/parse.ts 209 lines
1// 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}
209
hooks/poll.ts 267 lines
1// 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}
267
hooks/view/Band.tsx 116 lines
1// 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}
116
hooks/view/Pane.tsx 327 lines
1// 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}
327
types/index.d.ts 95 lines
1// 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