Forge's board and machine views inside Claude Code.

Forge takes a change from an idea to a merged pull request. An AI coding agent, Claude Code or Codex, does the work: it plans, writes the code, runs the tests and gets it reviewed. You stay in charge of the decisions: what gets built, which option to pick, and when it goes in.
A pull request is a proposed change to the code that gets checked before it is added. Merging it is the moment the change becomes part of the real project.
In April 2026 OpenAI published *An open-source spec for Codex orchestration: Symphony* (openai/symphony). Its idea: the slow part of building with AI agents was never the agents, it was people watching them. So stop supervising agent sessions one by one and hand them whole pieces of work instead. The spec is a description of the idea, not a tool.
Symphony Forge is that idea made into a working tool. It keeps the core (you assign work, agents do it) and differs in three ways:
| Symphony (the spec) | Symphony Forge | |
|---|---|---|
| Who runs it | Codex only | Claude Code or Codex plans with you; Codex workers build |
| Where the truth lives | The issue tracker | The repo: plans, specs, decisions and each change's state |
| Checks before a change goes in | Left open on purpose | One approval of the plan, an automatic review and green tests on every change |
After a new client's prototype is signed off, each story follows this loop:
merge = "agent" in the project's forge.toml if you want the agent to run forge merge for each ready change instead.There are two sizes of change:
This is the path for a new client project, from the first conversation to running in production. The agent starts as a forward deployed engineer (FDE): it learns the customer's problem and builds the smallest working prototype that tests it. A salesperson can start this work; a developer can take over using the discovery notes and answers page. To start one, follow the prototype checklist.
| Stage | What happens | Who | Where it's kept |
|---|---|---|---|
| 1. Find the problem | The agent interviews you (and, through you, the customer) about what really happens today, and writes it as a problem card: the job, today's workaround, what it costs, who feels it | Agent asks, you answer | docs/product/DISCOVERY.md |
| 2. Pick an option | Two to four options, always including "don't build" and "smallest slice", each with an estimate of when it pays back | Agent proposes, you choose | forge spec payback |
| 3. Build the prototype | The agent asks each question when it first affects the build, records the answer and who gave it, confirms specs as they emerge, and builds the smallest working slice through fixes | Agent, with you and the customer | docs/product/BRIEF.md, docs/specs/, forge spec save, forge read, forge spec confirm, forge fix start, forge work, forge close |
| 4. Demo and review | The customer tries the prototype; a strict review checks the whole prototype and its answers before sign-off | Client and agent | Demo address, forge decision accept |
| 5. Client sign-off | The customer's named person approves the demo and answers; no client story or roadmap entry can be created before this | Client approves, agent records | forge decision new client-signoff, forge decision accept |
| 6. Plan the stories | Confirmed specs become stories on the roadmap, the smallest usable slice first, with no setup-only stories | Agent plans, you approve each story once | forge roadmap add, forge story new |
| 7. Build | Each task runs in its own branch and folder, built by a Codex worker (or Claude, if the project chooses) with its tests | Agent | forge task start, forge work |
| 8. Check | An automatic review plus green tests; anything serious goes back to the worker | Agent | forge close |
| 9. Ship | The pull request is merged, and the change goes out through your project's own deployment | You merge, or the agent if you allow it | GitHub, forge merge |
| 10. Close the loop | The last task's merge records the story's outcome, and on the spec's check date its success measure is measured and recorded | Agent, with your numbers | forge merge --outcome, forge spec measure |
Before sign-off, the prototype uses fixes even when the work is larger than an ordinary fix. Each still goes through tests, review and a pull request. The agent confirms specs during this work, then adds them to the roadmap after sign-off. See decision 0095 for why this order replaces the earlier roadmap-before-sign-off order.
You do three things:
merge = "agent"). The agent merges ready prototype changes before sign-off.The agent does everything else: the plan, the code, the tests, fixing what the review finds, and keeping Forge's settings file up to date.
Not sure where things stand? Run forge next, or just ask your agent "what's next?". It says where things are in one sentence and gives the exact next step.
gh), signed in with gh auth login.uv tool install --python 3.11 "git+https://github.com/knacklabs/symphony-forge@v1.2.8"
Check it worked with forge --version. Each project pins the Forge version it uses, and Forge prints the exact install line if yours doesn't match.
forge init inside it. It writes Forge's settings, a starter docs folder and the agent instructions, makes the first commit and protects the main branch.forge migrate. It moves you over in one pull request.forge sync once, then forge doctor to check your setup. Doctor prints a fix for each problem it finds.Tell your agent "upgrade Forge". It asks which release, recommending the newest, then runs forge upgrade <release> from the main branch with nothing uncommitted. That one command installs the release, has it refresh Forge's files for Claude Code and Codex (your own text and settings stay), and opens the upgrade's pull request, which merges like any other change. If it stops, its Next: line says what to do; running it again picks up where it stopped.
Open Claude Code or Codex in the project and ask "what's next?", or run forge next yourself. Then describe the change you want.
Clone this repository, then run uv sync and uv run pytest to run the tests. The Codex SDK is not needed for the tests. Start fixes with forge fix start "<why>" --done "<done when>" so the pull request uses a Forge branch; the pull-request check accepts only Forge branches.
Forge's own CI balances its test groups using the committed .test_durations file. Refresh it from the repository root after changes to slow tests:
uv run --python 3.11 pytest tests -q -n auto -o faulthandler_timeout=120 --timeout=150 --timeout-method=thread --store-durations --clean-durations
Commit the refreshed file after the full run passes. Check the latest CI run on all three platforms: each group, including setup, must finish within seven minutes on Ubuntu and macOS, and fourteen minutes on Windows (70% of its job limit). These settings affect only Forge's own repository; new and existing client repositories get no change.
Your agent runs these for you. You'll see them in its messages.
| Command | What it does |
|---|---|
forge next | Says where things stand and gives the exact next step |
forge board | Opens a plain-English page showing the work and its progress |
forge story new | Starts a new story and its plan |
forge read | Runs the one independent read of a plan |
forge task start | Starts one task of an approved story in its own branch and folder |
forge fix start | Starts a small fix in its own branch and folder |
forge work | Has the worker build a task or fix, or fix what the review found |
forge close | Runs the review, waits for the tests, and marks the pull request ready |
forge merge | Merges a ready pull request, when the project allows the agent to merge |
forge ask | Asks Codex a quick read-only question about the code |
forge doctor | Checks your setup and says how to fix each problem |
The full command list is in the Forge guide.
hooks/register.ts 44 lines1import type { CoreEngineInterface, On } from 'claude-code'
2import { createCore } from './forge.ts'
3import { text } from './summary.ts'
4import { addTab, registerPane } from './pane.ts'
5import { registerEvents } from './events.ts'
6import { registerApproval } from './approval.ts'
7import { registerMachine } from './machine.ts'
8
9const REFRESH_INTERVAL_MS = 10000
10const REFRESH_TIMEOUT_MS = 20000
11
12async function refresh($: CoreEngineInterface, core: ReturnType<typeof createCore>) {
13 const cwd = await $.session.cwd()
14 return core.refresh(
15 command => $.process.run(['forge', command, '--json'], { cwd, timeoutMs: REFRESH_TIMEOUT_MS }),
16 () => $.clock.now(),
17 () => $.ui.invalidate('ui.render'),
18 )
19}
20
21export function register(on: On) {
22 const core = createCore()
23 const { data } = core
24 let loaded = false
25 registerEvents(on, data)
26 on('session.start', async ($, e, next) => {
27 if (!loaded) {
28 loaded = true
29 await $.command.register({ name: 'forge', description: 'Show the Forge board', immediate: true })
30 $.clock.every(REFRESH_INTERVAL_MS, () => { void refresh($, core) })
31 }
32 await refresh($, core)
33 return next(e)
34 })
35 on('command.run', { command: 'forge' }, async $ => {
36 await $.ui.open({ id: 'forge', title: 'Forge', focus: true })
37 const now = await $.clock.now()
38 return { text: [text(data, now), data.machineText?.(now)].filter(Boolean).join('\n') }
39 })
40 registerPane(on, data)
41 registerApproval(on, data)
42 registerMachine(on, data, addTab)
43}
44hooks/forge.ts 89 lines1import type { ProcessRunResult } from 'claude-code'
2
3export type Stage = { name: string; status?: string | null; started_at?: string | null; ended_at?: string | null; seconds?: number | null }
4export type Item = {
5 title: string; id?: string; kind?: string; stage?: string | null
6 worker?: { kind: string; model: string | null; started_at: string | null } | null
7 pr?: { number: number | null; checks: string } | null
8 findings?: { count: number; titles: string[] }
9 round?: number | null; total_seconds?: number | null; stages?: Stage[]
10 children?: Item[]; [key: string]: unknown
11}
12export type Board = { version: string; repo_root: string; items: Item[] }
13export type Next = { version: string; repo_root: string; next: { command: string | null; line: string } }
14export type Data = {
15 board: Board | null; next: Next | null; lanes: Record<string, unknown> | null
16 error: string | null; refreshedAt: number | null
17 onUpdate(fn: () => void): () => void
18 machineText?: (now: number) => string
19}
20
21export const TOO_OLD = "This repo's Forge is too old for the pane: upgrade Forge here."
22const object = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v)
23const nullable = (v: unknown, kind: string) => v == null || typeof v === kind
24const timestamp = (v: unknown) => v == null || (typeof v === 'string' && Number.isFinite(Date.parse(v)))
25
26function item(v: unknown): v is Item {
27 if (!object(v) || typeof v.title !== 'string' || !nullable(v.stage, 'string')) return false
28 if (v.worker != null && (!object(v.worker) || typeof v.worker.kind !== 'string' || !nullable(v.worker.model, 'string') || !nullable(v.worker.started_at, 'string') || !timestamp(v.worker.started_at))) return false
29 if (v.pr != null && (!object(v.pr) || !nullable(v.pr.number, 'number') || !['pass', 'fail', 'running', 'unknown'].includes(String(v.pr.checks)))) return false
30 if (v.findings != null && (!object(v.findings) || typeof v.findings.count !== 'number' || !Array.isArray(v.findings.titles) || !v.findings.titles.every(t => typeof t === 'string'))) return false
31 if (!nullable(v.round, 'number') || !nullable(v.total_seconds, 'number')) return false
32 if (v.stages != null && (!Array.isArray(v.stages) || !v.stages.every(s => object(s) && typeof s.name === 'string' && nullable(s.status, 'string') && timestamp(s.started_at) && timestamp(s.ended_at) && nullable(s.seconds, 'number')))) return false
33 return v.children == null || (Array.isArray(v.children) && v.children.every(item))
34}
35
36function parse(text: string, command: string): Record<string, unknown> {
37 let v: unknown
38 try { v = JSON.parse(text) } catch { throw new Error(`Malformed forge ${command} output`) }
39 if (!object(v)) throw new Error(`Malformed forge ${command} output`)
40 return v
41}
42
43export function createCore() {
44 const listeners = new Set<() => void>()
45 const data: Data = {
46 board: null, next: null, lanes: null, error: null, refreshedAt: null,
47 onUpdate(fn) { listeners.add(fn); return () => { listeners.delete(fn) } },
48 }
49 let running = false
50 // Claude's loader forbids passing $ to imported helpers. register.ts keeps
51 // the literal host calls and supplies only these operations.
52 const refresh = async (run: (command: string) => Promise<ProcessRunResult>, now: () => Promise<number>, invalidate: () => void) => {
53 if (running) return
54 running = true
55 try {
56 const results = await Promise.allSettled(['board', 'next', 'lanes'].map(async command => {
57 const result = await run(command)
58 if (result.exitCode !== 0) {
59 const error = result.stderr || result.stdout || `forge ${command} failed`
60 if (command === 'lanes' && /invalid choice: ['"]lanes['"]/.test(error)) return null
61 if (/unrecognized arguments: --json/.test(error)) throw new Error(TOO_OLD)
62 throw new Error(error)
63 }
64 return parse(result.stdout, command)
65 }))
66 const failed = results.find(r => r.status === 'rejected')
67 if (failed?.status === 'rejected') throw failed.reason
68 const [board, next, lanes] = results.map(r => r.status === 'fulfilled' ? r.value : null)
69 if (!board || typeof board.version !== 'string' || typeof board.repo_root !== 'string' || !Array.isArray(board.items) || !board.items.every(item)) throw new Error('Malformed forge board output')
70 if (!next || typeof next.version !== 'string' || typeof next.repo_root !== 'string' || !object(next.next) || typeof next.next.line !== 'string' || !nullable(next.next.command, 'string')) throw new Error('Malformed forge next output')
71 if (lanes && typeof lanes.version !== 'string') throw new Error('Malformed forge lanes output')
72 const refreshedAt = await now()
73 data.board = board as Board
74 data.next = next as Next
75 data.lanes = lanes ?? null
76 data.refreshedAt = refreshedAt
77 data.error = null
78 } catch (error) {
79 data.error = String(error instanceof Error ? error.message : error).split(/\r?\n/)[0] ?? ''
80 } finally {
81 running = false
82 // All reads complete before the snapshot is published.
83 for (const fn of listeners) fn()
84 invalidate()
85 }
86 }
87 return { data, refresh }
88}
89hooks/summary.ts 103 lines1import type { Data, Item, Stage } from './forge.ts'
2import { TOO_OLD } from './forge.ts'
3
4export function seconds(value: number): string {
5 const n = Math.max(0, Math.floor(value))
6 return n < 60 ? `${n}s` : n < 3600 ? `${Math.floor(n / 60)}m ${n % 60}s` : `${Math.floor(n / 3600)}h ${Math.floor(n % 3600 / 60)}m`
7}
8
9export function elapsed(start: string, now: number): number {
10 return Math.max(0, (now - Date.parse(start)) / 1000)
11}
12
13export function rows(items: Item[]): Item[] {
14 return items.flatMap(i => [i, ...(i.children ?? [])])
15}
16
17export const record = (v: unknown): Record<string, unknown> => v && typeof v === 'object' && !Array.isArray(v) ? v as Record<string, unknown> : {}
18const list = (v: unknown): Record<string, unknown>[] => Array.isArray(v) ? v.map(record) : []
19
20export function stages(i: Item): Stage[] {
21 return ['Build', 'Tests', 'Review', 'CI', 'Merge'].map(name => i.stages?.find(s => s.name === name) ?? { name })
22}
23
24export function stageText(s: Stage, now: number): string {
25 if (s.status == null) return s.name
26 if (s.status === 'skipped') return `${s.name} –`
27 const symbol = s.status === 'pass' ? '✓' : s.status === 'fail' ? '✗' : s.status === 'running' ? '●' : '?'
28 const time = s.status === 'running' ? (s.started_at ? seconds(elapsed(s.started_at, now)) : 'unknown') : s.seconds != null ? seconds(s.seconds) : 'unknown'
29 return `${s.name} ${symbol} ${time}`
30}
31
32export function total(i: Item, now: number): string {
33 // Build includes the worker's own tests; concurrent stages must not double time.
34 const live = Math.max(0, ...stages(i).filter(s => s.status === 'running' && s.started_at).map(s => elapsed(s.started_at!, now)))
35 return i.total_seconds == null ? 'unknown' : seconds(i.total_seconds + live)
36}
37
38export function activeRows(data: Data): Item[] {
39 return rows(data.board?.items ?? []).filter(i => i.worker || record(i.activity).status === 'running' || stages(i).some(s => s.status === 'running'))
40}
41
42export function laneCounts(data: Data) {
43 const agents = record(data.lanes?.agents), tests = record(data.lanes?.tests)
44 const agentsRunning = list(agents.entries).filter(e => e.started_at != null).length
45 const agentsWaiting = list(agents.entries).filter(e => e.started_at === null).length
46 const testsWaiting = list(tests.entries).filter(e => e.started_at === null).length
47 const test = list(tests.entries).find(e => e.started_at != null)
48 const testTitle = test && data.board && test.repo_root === data.board.repo_root ? rows(data.board.items).find(i => i.id === test.item)?.title : undefined
49 return { agentsRunning, agentsWaiting, testsWaiting, test, testTitle, size: agents.size ?? '?' }
50}
51
52export function itemTime(i: Item, now: number): string {
53 return `${i.title}: ${stages(i).map(s => stageText(s, now)).join(' → ')} · round ${i.round ?? 'unknown'} · total ${total(i, now)}`
54}
55
56// The command and every drawn surface use the same facts and live clock.
57export function summary(data: Data, now: number, working = false): string[] {
58 if (data.error === TOO_OLD) return [TOO_OLD]
59 const active = activeRows(data)
60 const n = laneCounts(data)
61 const next = !working && data.next?.next.command ? `1: ${data.next.next.command}` : data.next?.next.line ?? 'Loading Forge…'
62 const step = data.lanes && record(active.find(i => typeof record(i.worker).step === 'string')?.worker).step
63 return [
64 `${data.lanes ? `Agents ${n.agentsRunning}/${n.size} (${n.agentsWaiting} waiting) · Tests: ${n.testTitle ?? n.test?.item ?? 'idle'} (${n.testsWaiting} waiting) · ` : ''}${next}${step ? ` · ${step}` : ''}`,
65 ...active.slice(0, active.length > 2 ? 1 : 2).map(i => itemTime(i, now)),
66 ...(active.length > 2 ? [`+${active.length - 1} more · /forge for all`] : []),
67 ]
68}
69
70export function itemLines(i: Item, now: number): [string, string] {
71 const w = record(i.worker), a = record(i.activity), f = record(i.findings)
72 const worker = i.worker ? ` · ${i.worker.kind} ${i.worker.model ?? 'unknown'} ${i.worker.started_at ? seconds(elapsed(i.worker.started_at, now)) : 'unknown'}${w.tool ? ` · ${w.tool}` : ''}${w.effort ? ` · ${w.effort}` : ''}${w.round != null ? ` · round ${w.round}` : ''}${w.step ? ` · ${w.step}` : ''}` : ''
73 const activity = a.status === 'queued' ? `queued ${a.lane ?? ''} #${a.place ?? '?'}` : a.status === 'running' ? `running ${a.action ?? ''}` : a.status === 'idle' ? `idle${i.idle_since ? ` since ${i.idle_since}` : ''}${i.stalled === true ? ' · stalled' : ''}` : ''
74 const pr = i.pr?.number != null ? ` · PR #${i.pr.number}: ${i.pr.checks}` : ''
75 const findings = list(f.items).length ? list(f.items).map(f => `${f.priority ?? ''} ${f.title ?? ''}`.trim()) : i.findings?.titles ?? []
76 const failures = list(record(i.pr).failures).map(f => `${f.job}: ${f.cause}`)
77 const tests = record(i.tests)
78 const gates = record(i.gates)
79 const gateText = ([['plan_read', 'Plan read'], ['review', 'Review'], ['ci', 'CI']] as const).flatMap(([name, label]) => {
80 const gate = record(gates[name])
81 return gate.status ? [`${label}: ${gate.status}${gate.count != null ? ` (${gate.count})` : ''}${typeof gate.elapsed === 'number' ? ` ${seconds(gate.elapsed)}` : ''}`] : []
82 })
83 return [
84 `${i.title} · ${i.stage ?? 'unknown'}${activity ? ` · ${activity}` : ''}${worker}${pr} · ${i.findings?.count ?? 0} open findings${typeof f.dismissed === 'number' ? ` · ${f.dismissed} dismissed` : ''}`,
85 [stages(i).map(s => stageText(s, now)).join(' → '), `round ${i.round ?? 'unknown'} · total ${total(i, now)}`, ...(tests.done != null && tests.total != null ? [`Tests ${tests.done}/${tests.total}`] : []), ...gateText, ...failures, ...findings].join(' · '),
86 ]
87}
88
89export function text(data: Data, now: number): string {
90 if (data.error === TOO_OLD) return TOO_OLD
91 const lines = summary(data, now)
92 const items = data.board?.items ?? []
93 if (data.board && !items.length) lines.push('Nothing in progress.')
94 function write(i: Item, indent: string) {
95 const [state, detail] = itemLines(i, now)
96 lines.push(`${indent}${state}`, `${indent} ${detail}`)
97 for (const child of i.children ?? []) write(child, indent + ' ')
98 }
99 for (const i of items) write(i, '')
100 if (data.error) lines.push(`Couldn't refresh: ${data.error}`)
101 return lines.join('\n')
102}
103hooks/pane.ts 170 lines1import type { Elements, On, RenderInput, RenderNode, Timer } from 'claude-code'
2import type { Data, Item, Next } from './forge.ts'
3import { TOO_OLD } from './forge.ts'
4import { activeRows, itemLines, itemTime, laneCounts, record, rows, stageText, stages, summary, total } from './summary.ts'
5
6type Tab = (e: RenderInput<'Pane'>, t: Elements['terminal'] | Elements['desktop'], now: number) => RenderNode
7const tabs = new Map<string, Tab>()
8const actions = new Map<string, ((item: Item, e: RenderInput<'Pane'>) => RenderNode)[]>()
9const MIN_TITLE_COLUMNS = 4
10
11export function addTab(name: string, render: Tab) {
12 tabs.set(name, render)
13}
14
15export function addItemAction(kind: string, render: (item: Item, e: RenderInput<'Pane'>) => RenderNode) {
16 actions.set(kind, [...(actions.get(kind) ?? []), render])
17}
18
19const escape = (text: string) => text.replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]!)
20const tableCell = (text: string) => text.replace(/\\/g, '\\\\').replace(/\|/g, '\\|').replace(/[\r\n]/g, ' ').replace(/[<>]/g, c => c === '<' ? '<' : '>')
21
22function timeline(item: Item, now: number): string {
23 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 44">${stages(item).map((s, n) => {
24 const color = s.status === 'fail' ? '#b91c1c' : s.status === 'pass' ? '#166534' : s.status === 'running' ? '#1d4ed8' : '#374151'
25 const label = stageText(s, now)
26 return `<g><title>${escape(label)}</title><rect x="${n * 120}" y="0" width="116" height="40" rx="4" fill="${color}"/><text x="${n * 120 + 6}" y="24" fill="#ffffff" font-size="11">${escape(label)}</text></g>`
27 }).join('')}</svg>`
28}
29
30export function registerPane(on: On, data: Data) {
31 let selected = 'Board', working = false, cwd = '', repaint: Timer | undefined
32 on('session.start', { isInteractive: true }, async ($, e, next) => {
33 cwd = e.cwd
34 await $.ui.open({ id: 'forge', title: 'Forge' })
35 // Drawing ticks never fetch state; CORE owns the only refresh schedule.
36 repaint?.cancel()
37 repaint = $.clock.every(1000, () => { $.ui.invalidate('ui.render') })
38 return next(e)
39 })
40 on('session.end', (_$, e, next) => {
41 if (e.reason !== 'clear' && e.reason !== 'resume') repaint?.cancel()
42 return next(e)
43 })
44 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
45 if (e.requestId !== 'forge' || (e.surface !== 'terminal' && e.surface !== 'desktop')) return next(e)
46 const t = $.ui.resolve(e)
47 const now = await $.clock.now()
48 const children: RenderNode[] = []
49 if (tabs.size) children.push(t.Box({ flexDirection: 'row', gap: 1, children: ['Board', ...tabs.keys()].map(name => t.Button({ key: `tab-${name}`, label: name, onPress: () => { selected = name; $.ui.invalidate('ui.render') } })) }))
50 const tab = tabs.get(selected)
51 if (tab) children.push(tab(e, t, now))
52 else if (data.error === TOO_OLD) children.push(t.Text({ children: TOO_OLD }))
53 else {
54 if (!data.board) children.push(t.Text({ dimColor: true, children: 'Loading Forge…' }))
55 if (data.board && !data.board.items.length) children.push(t.Text({ children: 'Nothing in progress.' }))
56 for (const item of rows(data.board?.items ?? [])) {
57 const [state, detail] = itemLines(item, now)
58 if (e.surface === 'desktop') {
59 const desktop = $.ui.resolve(e)
60 children.push(desktop.Markdown({ text: `| Item and status |\n| --- |\n| ${tableCell(state)} |\n| ${tableCell(detail)} |` }))
61 children.push(desktop.Svg({ source: timeline(item, now), alt: stages(item).map(s => stageText(s, now)).join(' → '), isInteractive: true }))
62 } else {
63 children.push(t.Text({ bold: true, wrap: 'wrap', children: state }))
64 children.push(t.Text({ wrap: 'wrap', children: detail }))
65 }
66 for (const render of actions.get(item.kind ?? '') ?? []) children.push(render(item, e))
67 }
68 }
69 if (data.error && data.error !== TOO_OLD) children.push(t.Text({ dimColor: true, children: `Couldn't refresh: ${data.error}` }))
70 return t.Box({ flexDirection: 'column', gap: 1, children })
71 })
72 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
73 working = e.props.isWorking
74 if (e.props.hasSurvey || e.props.maxRows < 1) return next(e)
75 const t = $.ui.resolve(e), now = await $.clock.now()
76 const command = data.error === TOO_OLD || working ? null : data.next?.next.command
77 const narrow = e.props.bodyColumns < 80 || e.props.maxRows === 1
78 let lines = summary(data, now, working)
79 let compact: { title: string, counts: string, timing: string, tail: string } | undefined
80 if (narrow && !data.lanes) lines = lines.slice(0, 1)
81 else if (narrow && data.error !== TOO_OLD) {
82 const n = laneCounts(data), first = activeRows(data)[0]
83 const stage = first && stages(first).find(s => s.status === 'running')
84 const counts = data.lanes ? `${n.agentsRunning + (n.test ? 1 : 0)} running, ${n.agentsWaiting}+${n.testsWaiting} waiting · ` : ''
85 const step = record(first?.worker).step
86 lines = [`${counts}${command ? `1: ${command}` : data.next?.next.line ?? 'Loading Forge…'}${typeof step === 'string' ? ` · ${step}` : ''}`]
87 compact = { title: first?.title ?? '', counts, timing: first ? `: ${stage ? stageText(stage, now) : first.stage ?? 'unknown'} (${total(first, now)}) · ` : '', tail: `${data.next?.next.line ?? 'Loading Forge…'}${typeof step === 'string' ? ` · ${step}` : ''}` }
88 }
89 if ((e.viewport?.columns ?? e.props.bodyColumns) < 144 && data.error !== TOO_OLD) {
90 lines[0] += ' · /forge for the board'
91 if (compact) compact.tail += ' · /forge for the board'
92 }
93 const children: RenderNode[] = []
94 const firstChildren: RenderNode[] = []
95 const first = lines[0] ?? ''
96 const lanes = laneCounts(data)
97 const counts = !narrow && data.lanes && data.error !== TOO_OLD ? `Agents ${lanes.agentsRunning}/${lanes.size} (${lanes.agentsWaiting} waiting) · Tests: ${lanes.test ? 1 : 0} running (${lanes.testsWaiting} waiting) · ` : ''
98 const testTitle = `${lanes.testTitle ?? lanes.test?.item ?? 'idle'} · `
99 const titleWidth = Math.min(testTitle.length, Math.max(0, e.props.bodyColumns - counts.length - MIN_TITLE_COLUMNS))
100 const nextWidth = Math.max(0, e.props.bodyColumns - counts.length - titleWidth)
101 let tailColumns = compact?.tail.length ?? 0
102 if (command) {
103 const [prefix, suffix = ''] = first.split(`1: ${command}`)
104 tailColumns = command.length + 3 + suffix.length
105 firstChildren.push(
106 t.Text({ children: prefix ?? '', wrap: 'truncate-end' }),
107 t.Button({ key: 'next-step', hotkey: '1', plain: true, label: counts && command.length + 3 > nextWidth ? `${Array.from(command).slice(0, Math.max(0, nextWidth - 4)).join('')}…` : command, onPress: async () => {
108 if (working) return
109 let updated: Next
110 try {
111 const result = await $.process.run(['forge', 'next', '--json'], { cwd: cwd || data.board?.repo_root, timeoutMs: 20000 })
112 if (result.exitCode !== 0) throw new Error((result.stderr || result.stdout || 'Forge did not answer').split(/\r?\n/)[0])
113 const value: unknown = JSON.parse(result.stdout)
114 const root = record(value), following = record(root.next)
115 if (typeof root.version !== 'string' || typeof root.repo_root !== 'string' || typeof following.line !== 'string' || !(following.command === null || typeof following.command === 'string')) throw new Error('Malformed forge next output')
116 updated = value as Next
117 } catch (error) {
118 $.ui.toast(`Couldn't check the next step: ${error instanceof Error ? error.message : String(error)}`)
119 return
120 }
121 data.next = updated
122 $.ui.invalidate('ui.render')
123 if (updated.next.command !== command || working) return
124 try {
125 // The published declarations lag the documented asUser option.
126 const submission = { text: command, asUser: true }
127 const result = await $.prompt.submit(submission)
128 if (result.drop !== undefined) $.ui.toast(`Couldn't run the next step: ${result.drop || 'Prompt unavailable'}`)
129 } catch (error) {
130 $.ui.toast(`Couldn't run the next step: ${error instanceof Error ? error.message : String(error)}`)
131 }
132 } }),
133 t.Text({ children: suffix, wrap: 'truncate-end' }),
134 )
135 } else firstChildren.push(t.Text({ wrap: 'truncate-end', children: first }))
136 if (compact) {
137 const fixed = compact.counts.length + compact.timing.length
138 const tailWidth = Math.min(tailColumns, Math.max(0, e.props.bodyColumns - fixed - MIN_TITLE_COLUMNS))
139 children.push(t.Box({ flexDirection: 'row', width: e.props.bodyColumns, children: [
140 t.Box({ width: compact.counts.length, flexShrink: 0, children: t.Text({ children: compact.counts }) }),
141 t.Box({ width: Math.max(0, e.props.bodyColumns - fixed - tailWidth), minWidth: 0, children: t.Text({ wrap: 'truncate-end', children: compact.title }) }),
142 t.Box({ width: compact.timing.length, flexShrink: 0, children: t.Text({ children: compact.timing }) }),
143 t.Box({ width: tailWidth, flexShrink: 0, overflow: 'hidden', flexDirection: 'row', children: command ? firstChildren.slice(1) : t.Text({ wrap: 'truncate-end', children: compact.tail }) }),
144 ] }))
145 } else if (counts) {
146 const step = record(activeRows(data).find(i => typeof record(i.worker).step === 'string')?.worker).step
147 const tail = `${data.next?.next.line ?? 'Loading Forge…'}${step ? ` · ${step}` : ''}${(e.viewport?.columns ?? e.props.bodyColumns) < 144 ? ' · /forge for the board' : ''}`
148 children.push(t.Box({ flexDirection: 'row', width: e.props.bodyColumns, children: [
149 t.Box({ width: counts.length, flexShrink: 0, children: t.Text({ children: counts }) }),
150 t.Box({ width: titleWidth, minWidth: 0, children: t.Text({ wrap: 'truncate-end', children: testTitle }) }),
151 t.Box({ width: nextWidth, flexShrink: 0, overflow: 'hidden', flexDirection: 'row', children: command ? firstChildren.slice(1) : t.Text({ wrap: 'truncate-end', children: tail }) }),
152 ] }))
153 } else children.push(command ? t.Box({ flexDirection: 'row', children: firstChildren }) : firstChildren[0]!)
154 const active = activeRows(data)
155 for (const [n, line] of lines.slice(1, Math.min(3, e.props.maxRows)).entries()) {
156 const item = active.length > 2 && n === 1 ? undefined : active[n]
157 const timing = item ? itemTime(item, now).slice(item.title.length + 2) : undefined
158 let status = timing ?? line
159 // Reserve the timing cells; the native surface truncates only the title.
160 if (timing && status.length >= e.props.bodyColumns) status = status.replaceAll(' ', '')
161 const text = t.Text({ wrap: 'truncate-end', children: status.split('✗').flatMap((part, n) => n ? [t.Text({ color: 'red', children: '✗' }), part] : [part]) })
162 children.push(item && timing ? t.Box({ flexDirection: 'row', width: e.props.bodyColumns, children: [
163 t.Box({ width: Math.max(0, e.props.bodyColumns - status.length), minWidth: 0, children: t.Text({ wrap: 'truncate-end', children: `${item.title}: ` }) }),
164 t.Box({ width: status.length, flexShrink: 0, children: text }),
165 ] }) : text)
166 }
167 return t.Box({ flexDirection: 'column', children })
168 })
169}
170hooks/events.ts 106 lines1import type { On } from 'claude-code'
2import type { Data, Item } from './forge.ts'
3
4const KINDS = new Set(['review_findings', 'checks_failed', 'ready_to_merge', 'worker_question', 'run_finished'])
5const object = (value: unknown): value is Record<string, unknown> => !!value && typeof value === 'object' && !Array.isArray(value)
6
7function occurrences(items: Item[]): Map<string, string> {
8 const lines = new Map<string, string>()
9 for (const item of items) {
10 const command = object(item.next) && typeof item.next.command === 'string' ? item.next.command : 'forge next'
11 if (Array.isArray(item.occurrences)) {
12 for (const event of item.occurrences) {
13 if (object(event) && typeof event.id === 'string' && event.id && typeof event.kind === 'string'
14 && KINDS.has(event.kind) && typeof event.title === 'string') {
15 lines.set(event.id, `${item.title.replace(/[\r\n]+/g, ' ')}: ${event.title.replace(/[\r\n]+/g, ' ')} Next: ${command}`)
16 }
17 }
18 }
19 for (const [id, line] of occurrences(item.children ?? [])) lines.set(id, line)
20 }
21 return lines
22}
23
24export function registerEvents(on: On, data: Data) {
25 let eligibility = Promise.resolve(false)
26 let key: string | null = null
27 const pending = new Map<string, string>()
28 let drain = async () => {}
29 let unsubscribe = () => {}
30 on('session.start', { isInteractive: true }, async ($, e, next) => {
31 unsubscribe()
32 drain = async () => {}
33 key = null
34 pending.clear()
35 // Turn hooks must share this decision while the reload refresh is pending.
36 eligibility = (async () => await $.env.get('FORGE_WORKER') !== '1'
37 && (await $.session.surfaces()).some(s => s === 'terminal' || s === 'desktop'))()
38 const started = await next(e)
39 if (!await eligibility) return started
40 let running = false
41 drain = async () => {
42 if (running) return
43 running = true
44 try {
45 const repo = await $.session.repo()
46 const session = await $.session.id()
47 const currentKey = repo ? JSON.stringify([repo.root, session]) : null
48 const baseline = key !== currentKey || key === null
49 if (baseline) {
50 key = null
51 pending.clear()
52 }
53 if (!repo || data.error || data.board?.repo_root !== repo.root
54 || await $.env.get('FORGE_WORKER') === '1'
55 || !(await $.session.surfaces()).some(s => s === 'terminal' || s === 'desktop')) return
56 const seenKey = JSON.stringify([repo.root, session])
57 const saved = await $.store.get(seenKey)
58 const seen = new Set(Array.isArray(saved) ? saved.filter((id): id is string => typeof id === 'string') : [])
59 const current = occurrences(data.board.items)
60 if (baseline) {
61 for (const id of current.keys()) seen.add(id)
62 await $.store.set(seenKey, [...seen])
63 key = seenKey
64 return
65 }
66 for (const [id, line] of current) if (!seen.has(id)) pending.set(id, line)
67 if (!pending.size || await $.store.get(JSON.stringify(['forge-active-turn', session]))) return
68 // The host can change identity while the asynchronous reads finish.
69 if ((await $.session.repo())?.root !== repo.root || await $.session.id() !== session) return
70 const batch = [...pending]
71 const result = await $.prompt.submit({ text: batch.map(([, line]) => line).join('\n') })
72 if (result.drop !== undefined) return
73 for (const [id] of batch) seen.add(id)
74 await $.store.set(seenKey, [...seen])
75 for (const [id] of batch) pending.delete(id)
76 } catch {
77 // A failed host call leaves occurrences unseen for the next refresh.
78 } finally {
79 running = false
80 }
81 }
82 unsubscribe = data.onUpdate(() => { void drain() })
83 await drain()
84 return started
85 })
86 on('session.end', {}, async ($, e, next) => {
87 key = null
88 pending.clear()
89 if (await eligibility) await $.store.set(JSON.stringify(['forge-active-turn', e.sessionId]), null)
90 return next(e)
91 })
92 on('turn.start', async ($, e, next) => {
93 if (await eligibility) await $.store.set(JSON.stringify(['forge-active-turn', await $.session.id()]), e.turnId)
94 return next(e)
95 })
96 on('turn.complete', async ($, e, next) => {
97 const result = await next(e)
98 if (!e.agentId && await eligibility) {
99 const activeKey = JSON.stringify(['forge-active-turn', await $.session.id()])
100 if (await $.store.get(activeKey) === e.turnId) await $.store.set(activeKey, null)
101 await drain()
102 }
103 return result
104 })
105}
106hooks/approval.ts 7 lines1import type { On } from 'claude-code'
2import type { Data } from './forge.ts'
3
4// Claude strips caller-supplied ExitPlanMode plan text and reads its session
5// plan file instead. Keep this seam empty: approval stays in Plan Mode.
6export function registerApproval(_on: On, _data: Data) {}
7hooks/machine.ts 205 lines1import type { On, RenderNode } from 'claude-code'
2import type { Data, Item } from './forge.ts'
3import type { addTab } from './pane.ts'
4import { elapsed, record, rows, seconds } from './summary.ts'
5
6type Entry = {
7 id: string; repo_root: string; checkout_root: string; repo_name: string; item: string | null; title?: string | null; kind: string
8 model: string | null; effort: string | null; joined_at: string; started_at: string | null
9 output_path: string | null; progress: { done: number | null; total: number | null } | null
10 place: number; elapsed: number
11}
12type Lanes = { agents: { size: number; entries: Entry[] }; tests: { size: number; entries: Entry[] }; machine: { load: number[] | null; cores: number | null } }
13const entries = (lanes: Lanes) => [...lanes.agents.entries, ...lanes.tests.entries]
14const escape = (value: string) => value.replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]!)
15
16function valid(value: unknown): value is Lanes {
17 const root = record(value), machine = record(root.machine)
18 return (machine.cores === null || (typeof machine.cores === 'number' && machine.cores > 0)) &&
19 (machine.load === null || (Array.isArray(machine.load) && machine.load.length === 3 && machine.load.every(n => typeof n === 'number' && Number.isFinite(n) && n >= 0))) &&
20 ['agents', 'tests'].every(name => {
21 const lane = record(root[name])
22 return typeof lane.size === 'number' && lane.size > 0 && Array.isArray(lane.entries) && lane.entries.every(value => {
23 const e = record(value), p = record(e.progress)
24 return ['id', 'repo_root', 'checkout_root', 'repo_name', 'kind', 'joined_at'].every(k => typeof e[k] === 'string') && Number.isFinite(Date.parse(String(e.joined_at))) &&
25 ['item', 'model', 'effort', 'output_path'].every(k => e[k] === null || typeof e[k] === 'string') &&
26 (e.title == null || typeof e.title === 'string') &&
27 (e.started_at === null || (typeof e.started_at === 'string' && Number.isFinite(Date.parse(e.started_at)))) &&
28 Number.isInteger(e.place) && Number(e.place) >= 0 && typeof e.elapsed === 'number' && Number.isFinite(e.elapsed) && e.elapsed >= 0 &&
29 (e.progress === null || ((p.done === null || (Number.isInteger(p.done) && Number(p.done) >= 0)) &&
30 (p.total === null || (Number.isInteger(p.total) && Number(p.total) > 0)) &&
31 (p.done === null || p.total === null || Number(p.done) <= Number(p.total))))
32 })
33 })
34}
35function local(data: Data, entry: Entry): Item | undefined {
36 return data.board && path(entry.repo_root) === path(data.board.repo_root) ? rows(data.board.items).find(i => i.id === entry.item) : undefined
37}
38function path(value: string): string {
39 const normal = value.replaceAll('\\', '/').replace(/\/$/, '')
40 return /^[a-z]:/i.test(normal) || normal.startsWith('//') ? normal.toLowerCase() : normal
41}
42function title(data: Data, entry: Entry): string {
43 return local(data, entry)?.title ?? entry.title ?? entry.kind
44}
45function worker(data: Data, entry: Entry): Record<string, unknown> {
46 const current = record(local(data, entry)?.worker)
47 const kind = entry.kind === 'work' || entry.kind === 'worker' ? 'build' : entry.kind
48 return entry.started_at !== null && current.kind === kind ? current : {}
49}
50function color(data: Data, entry: Entry): string {
51 const tool = worker(data, entry).tool
52 return entry.kind === 'test' ? 'yellow' : tool === 'codex' ? 'blue' : tool === 'claude' ? 'green' : 'gray'
53}
54function label(data: Data, entry: Entry, now: number): string {
55 const current = worker(data, entry)
56 const tool = typeof current.tool === 'string' ? current.tool : ''
57 const symbol = entry.kind === 'test' ? '■' : tool === 'codex' ? '◆' : tool === 'claude' ? '●' : '◇'
58 const reported = entry.progress
59 const p = reported?.done != null && reported.total != null ? { done: reported.done, total: reported.total } : null
60 const filled = p ? Math.floor(p.done / p.total * 10) : 0
61 const bar = p ? ` [${'█'.repeat(filled)}${'░'.repeat(10 - filled)}] ${p.done}/${p.total}` : ''
62 const model = current.model ?? entry.model, effort = current.effort ?? entry.effort
63 return `${symbol} ${entry.repo_name} · ${title(data, entry)} · ${entry.kind}${tool ? ` · ${tool}` : ''}${model ? ` · ${model}` : ''}${effort ? ` · ${effort}` : ''}${current.round != null ? ` · round ${current.round}` : ''} · ${seconds(elapsed(entry.started_at ?? entry.joined_at, now))}${entry.started_at === null ? entry.place > 0 ? ` · waiting #${entry.place}` : ' · starting' : ''}${bar}${current.step ? ` · ${current.step}` : ''}`
64}
65function gates(data: Data, now: number): string[] {
66 return rows(data.board?.items ?? []).flatMap(item => {
67 if (item.stage === 'done' || item.stage === 'merged') return []
68 const g = record(item.gates)
69 const facts = [['plan_read', 'Plan read'], ['review', 'Review'], ['ci', 'CI']].flatMap(([key, title]) => {
70 const gate = record(g[key!])
71 const live = gate.status === 'running' ? Math.max(0, (now - (data.refreshedAt ?? now)) / 1000) : 0
72 return gate.status && gate.status !== 'none' ? [`${title}: ${gate.status}${gate.count != null ? ` (${gate.count} findings)` : ''}${typeof gate.elapsed === 'number' ? ` · ${seconds(gate.elapsed + live)}` : ''}`] : []
73 })
74 return facts.length ? [`${item.title} · ${facts.join(' · ')}`] : []
75 })
76}
77
78export function registerMachine(on: On, data: Data, add: typeof addTab) {
79 let snapshot: Lanes | null = null, error = '', selected = ''
80 let showLog = false, outputPath: string | null = null, output: string | null = null, status = '', confirming = false
81 const samples: number[] = []
82 let invalidate = () => {}
83 let openOutput: ((run: Entry) => Promise<void>) | undefined, stop: ((run: Entry) => Promise<void>) | undefined
84 data.onUpdate(() => {
85 if (data.error) { error = data.error; return }
86 if (!valid(data.lanes)) { error = data.lanes === null ? 'Upgrade Forge in this repo to use the Machine tab.' : 'Malformed forge lanes output. Run forge lanes to check, then refresh.'; return }
87 snapshot = data.lanes
88 error = ''
89 if (snapshot.machine.load?.[0] != null) {
90 samples.push(snapshot.machine.load[0]); if (samples.length > 30) samples.shift()
91 }
92 if (!entries(snapshot).some(e => e.id === selected)) selected = entries(snapshot)[0]?.id ?? ''
93 })
94 add('Machine', (e, t, now) => {
95 const runs = snapshot ? entries(snapshot) : []
96 const selectedRun = runs.find(run => run.id === selected)
97 const descriptions = runs.map(run => label(data, run, now))
98 const children: RenderNode[] = []
99 const tree: RenderNode[] = []
100 if (e.surface === 'desktop' && 'Svg' in t) {
101 let y = 50
102 const boxes = runs.map((run, n) => {
103 const fill = { yellow: '#92400e', blue: '#1d4ed8', green: '#166534', gray: '#374151' }[color(data, run)]
104 const lines = descriptions[n]!.match(/.{1,90}/gu) ?? [], height = Math.max(44, lines.length * 16 + 16), top = y
105 y += height + 10
106 return `<g><title>${escape(descriptions[n]!)}</title><path d="M20 44 V${top + 20} H40" fill="none" stroke="#6b7280"/><rect x="40" y="${top}" width="710" height="${height}" rx="4" fill="${fill}"/><text x="50" y="${top + 20}" fill="white" font-size="12">${lines.map((line, j) => `<tspan x="50" dy="${j ? 16 : 0}">${escape(line)}</tspan>`).join('')}</text></g>`
107 }).join('')
108 const source = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 ${y + 10}"><rect x="4" y="4" width="750" height="40" rx="4" fill="#374151"/><text x="16" y="30" fill="white" font-size="14">This session · plans + decides</text>${boxes}</svg>`
109 tree.push(t.Svg({ source, alt: ['This session · plans + decides', ...descriptions].join('\n'), isInteractive: true }))
110 } else {
111 tree.push(t.Text({ bold: true, children: 'This session · plans + decides' }))
112 for (const run of runs.filter(run => run.place === 0)) tree.push(t.Text({ color: color(data, run), wrap: 'wrap', children: `${e.props.bodyColumns >= 100 ? '└─ ' : ''}${label(data, run, now)}` }))
113 }
114 if (!runs.length) tree.push(t.Text({ children: snapshot ? 'Nothing running' : 'Loading machine…' }))
115 const gateLines = gates(data, now)
116 children.push(t.Box({ flexDirection: e.props.bodyColumns >= 100 ? 'row' : 'column', gap: 2, children: [
117 t.Box({ flexDirection: 'column', flexGrow: 1, minWidth: 0, children: tree }),
118 ...(gateLines.length ? [t.Box({ flexDirection: 'column', flexGrow: 1, minWidth: 0, children: [t.Text({ bold: true, children: 'Gates' }), ...gateLines.map(line => t.Text({ wrap: 'wrap', children: line }))] })] : []),
119 ] }))
120 for (const name of ['agents', 'tests'] as const) {
121 const waiting = snapshot?.[name].entries.filter(e => e.place > 0) ?? []
122 if (waiting.length) children.push(t.Text({ bold: true, children: `Waiting next · ${name}` }))
123 if (e.surface === 'desktop' && waiting.length) {
124 const cells = waiting.map(run => label(data, run, now).replaceAll('\\', '\\\\').replaceAll('|', '\\|').replace(/[\r\n]/g, ' ').replaceAll('<', '<').replaceAll('>', '>'))
125 children.push(t.Markdown({ text: `| Waiting next |\n| --- |\n${cells.map(cell => `| ${cell} |`).join('\n')}` }))
126 } else for (const run of waiting) children.push(t.Text({ color: color(data, run), wrap: 'wrap', children: label(data, run, now) }))
127 }
128 children.push(t.Text({ children: '◆ Codex (blue) · ● Claude (green) · ◇ Agent (grey) · ■ Tests (amber)' }))
129 const load = snapshot?.machine.load?.[0], cores = snapshot?.machine.cores
130 if (load != null) {
131 children.push(t.Text({ color: cores != null && load > cores ? 'yellow' : undefined, children: `Load ${load} · ${cores ?? 'unknown'} cores` }))
132 if (e.surface === 'terminal' && 'Raster' in t && samples.length) {
133 const max = Math.max(cores ?? 1, ...samples), bytes = new Uint8Array(samples.length * 12), view = new DataView(bytes.buffer)
134 samples.forEach((value, n) => {
135 view.setUint32(n * 12, '▁▂▃▄▅▆▇█'.charCodeAt(Math.min(7, Math.floor(value / max * 7))), true)
136 view.setUint32(n * 12 + 4, cores != null && value > cores ? 0xfbbf24 : 0x93c5fd, true)
137 view.setUint32(n * 12 + 8, 0x01000000, true)
138 })
139 // Claude exposes this standard method; the pinned ES2023 types lag it.
140 const cells = (bytes as Uint8Array & { toBase64(): string }).toBase64()
141 children.push(t.Raster({ key: 'machine-load', columns: samples.length, rows: 1, cells }))
142 }
143 }
144 for (const run of runs) children.push(t.Button({ key: `machine-run-${run.id}`, label: `${run.id === selected ? '› ' : ''}${title(data, run)} · ${run.kind} · ${run.repo_name}`, onPress: () => { selected = run.id; status = ''; invalidate() } }))
145 children.push(t.Box({ flexDirection: 'row', gap: 1, children: [
146 ...(selectedRun ? [t.Button({ key: 'machine-output', hotkey: 'o', label: 'Open output', onPress: () => openOutput?.(selectedRun) }),
147 t.Button({ key: 'machine-stop', hotkey: 's', label: 'Stop run', onPress: () => stop?.(selectedRun) })] : []),
148 t.Button({ key: 'machine-log', hotkey: 'l', label: showLog ? 'Hide events' : 'Show events', onPress: () => { showLog = !showLog; invalidate() } }),
149 ] }))
150 if (showLog) {
151 const events = record(data.board).events
152 for (const value of Array.isArray(events) ? events.slice(-20) : []) {
153 const event = record(value)
154 if (typeof event.time === 'string' && typeof event.line === 'string') children.push(t.Text({ wrap: 'wrap', children: `${event.time} · ${event.line}` }))
155 }
156 }
157 if (status) children.push(t.Text({ wrap: 'wrap', children: status }))
158 if (error) children.push(t.Text({ wrap: 'wrap', children: `Couldn't refresh: ${error}` }))
159 return t.Box({ flexDirection: 'column', gap: 1, children })
160 })
161 on('session.start', { isInteractive: true }, async ($, e, next) => {
162 if (e.surface !== 'terminal' && e.surface !== 'desktop') return next(e)
163 invalidate = () => { $.ui.invalidate('ui.render') }
164 openOutput = async run => {
165 outputPath = run.output_path
166 output = null
167 await $.ui.open({ id: 'forge-output', title: 'Run output', focus: true })
168 $.ui.invalidate('ui.render')
169 }
170 stop = async run => {
171 if (confirming) return
172 confirming = true
173 try {
174 if (await $.ui.ask(`Stop ${run.kind} run for ${title(data, run)} in ${run.repo_name}?`, ['Stop', 'Keep running']) !== 'Stop') return
175 const fresh = await $.process.run(['forge', 'lanes', '--json'], { cwd: e.cwd, timeoutMs: 20000 })
176 if (fresh.exitCode !== 0) throw new Error(fresh.stderr || fresh.stdout || 'Forge did not answer. Refresh and try again.')
177 const lanes: unknown = JSON.parse(fresh.stdout)
178 if (!valid(lanes)) throw new Error('Malformed forge lanes output. Refresh and try again.')
179 if (!entries(lanes).some(entry => entry.id === run.id)) status = 'That run ended or was replaced. Refresh to choose the current run.'
180 else {
181 const result = await $.process.run(['forge', 'stop', '--id', run.id], { cwd: e.cwd, timeoutMs: 20000 })
182 status = result.exitCode === 0 ? result.stdout.trim() : `Could not stop the run: ${result.stderr || result.stdout || 'Refresh and try again.'}`
183 }
184 } catch (error) { status = `Could not stop the run: ${error instanceof Error ? error.message : String(error)}` }
185 finally { confirming = false; $.ui.invalidate('ui.render') }
186 }
187 return next(e)
188 })
189 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
190 if (e.requestId !== 'forge-output') return next(e)
191 if (outputPath) {
192 try { output = (await $.fs.read(outputPath)).replace(/\r?\n$/, '').split(/\r?\n/).slice(-200).join('\n') }
193 catch { output = null }
194 }
195 const t = $.ui.resolve(e)
196 return t.Text({ wrap: 'wrap', children: output ?? 'Output not available. Choose another run or try again after it starts.' })
197 })
198 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
199 const cwd = path(await $.session.cwd())
200 const run = snapshot && entries(snapshot).find(entry => entry.place > 0 && path(entry.checkout_root) === cwd)
201 return run ? next({ ...e, props: { ...e.props, suffix: `${e.props.suffix} · in line #${run.place} (${run.kind === 'test' ? 'tests' : 'agents'})` } }) : next(e)
202 })
203 data.machineText = (now: number) => snapshot ? ['Machine · this session plans + decides', ...(entries(snapshot).length ? entries(snapshot).map(e => label(data, e, now)) : ['Nothing running']), ...gates(data, now), ...(snapshot.machine.load ? [`Load ${snapshot.machine.load[0]} · ${snapshot.machine.cores ?? 'unknown'} cores`] : []), ...(error ? [`Couldn't refresh: ${error}`] : [])].join('\n') : ''
204}
205