SLOPSHOPPER

forge

Forge's board and machine views inside Claude Code.

newpanebandspinnercommandtoast
★ 9v1.2.8MITupdated 2026-10-09knacklabs/symphony-forge/src/forge/mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · forge
│ ┃ Forge ✕ › fix the failing auth test and add an audit log call │ ┃ [ Board ] [ Machine ] │ ┃ ⏺ Read(src/auth.ts) │ ┃ Loading Forge… ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ Couldn't refresh: Malformed forge board ⎿ Added 2 lines, removed 1 line │ ┃ output ⏺ 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 │ │ › /forge │ ⎿ forge: Loading Forge… │ ⎿ forge: Couldn't refresh: Malformed forge board output │ │ Loading Forge… ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Loading Forge…
Pane · Forge
[ Board ] [ Machine ] Loading Forge… Couldn't refresh: Malformed forge board output
README

Symphony Forge

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.

Highlights from the explainer; tap to watch it with sound

Where it comes from

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 itCodex onlyClaude Code or Codex plans with you; Codex workers build
Where the truth livesThe issue trackerThe repo: plans, specs, decisions and each change's state
Checks before a change goes inLeft open on purposeOne approval of the plan, an automatic review and green tests on every change

Who it's for

  • Developers: every change runs in its own branch, and it isn't ready until the tests pass and an automatic code review finds no serious problem. Nothing is committed straight to the main branch.
  • Product people who don't code: you approve a short plan written in plain English, and follow progress on a simple board page instead of in code.
  • Vibe coders who build with AI: you don't need to know git. Forge handles branches, commits and pull requests for your agent, and tells you the next step whenever you're unsure.
  • Salespeople starting a prototype: follow the Start a prototype checklist from an empty repo to a live customer demo.

How it works

After a new client's prototype is signed off, each story follows this loop:

  1. A short story doc. The agent writes one short page: what changes for you, why, when it's done, and the tasks.
  2. One independent read. A second AI agent, not the one that wrote the plan, reads it cold and points out problems. The plan is fixed once.
  3. You approve once. Nothing is built until you say yes.
  4. The work runs on its own. Each task gets its own branch (a separate line of work) and its own folder, so tasks don't get in each other's way. A Codex or Claude worker writes the code.
  5. Tests and review must pass. An automatic code review runs, and the tests must be green. Only then is the pull request marked ready.
  6. It merges. Before client sign-off, the agent merges ready prototype changes. After sign-off, you click merge by default. Set 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:

  • Story: a bigger change, one that touches an interface or more than five code files. It gets the plan, the independent read and your approval.
  • Fix: a small change. It needs only a one-line reason and a "done when", but still gets its own branch, the tests and the review.

From idea to production

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.

StageWhat happensWhoWhere it's kept
1. Find the problemThe 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 itAgent asks, you answerdocs/product/DISCOVERY.md
2. Pick an optionTwo to four options, always including "don't build" and "smallest slice", each with an estimate of when it pays backAgent proposes, you chooseforge spec payback
3. Build the prototypeThe 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 fixesAgent, with you and the customerdocs/product/BRIEF.md, docs/specs/, forge spec save, forge read, forge spec confirm, forge fix start, forge work, forge close
4. Demo and reviewThe customer tries the prototype; a strict review checks the whole prototype and its answers before sign-offClient and agentDemo address, forge decision accept
5. Client sign-offThe customer's named person approves the demo and answers; no client story or roadmap entry can be created before thisClient approves, agent recordsforge decision new client-signoff, forge decision accept
6. Plan the storiesConfirmed specs become stories on the roadmap, the smallest usable slice first, with no setup-only storiesAgent plans, you approve each story onceforge roadmap add, forge story new
7. BuildEach task runs in its own branch and folder, built by a Codex worker (or Claude, if the project chooses) with its testsAgentforge task start, forge work
8. CheckAn automatic review plus green tests; anything serious goes back to the workerAgentforge close
9. ShipThe pull request is merged, and the change goes out through your project's own deploymentYou merge, or the agent if you allow itGitHub, forge merge
10. Close the loopThe last task's merge records the story's outcome, and on the spec's check date its success measure is measured and recordedAgent, with your numbersforge 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.

What you do vs what the agent does

You do three things:

  • Approve a story's plan.
  • Choose between options when the agent asks.
  • Merge the finished pull request after client sign-off, unless you let the agent do it (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.

Get started

What you need

  • git, which keeps track of changes.
  • GitHub CLI (gh), signed in with gh auth login.
  • uv, a tool that installs Python programs. Get it from docs.astral.sh/uv.
  • Python 3.11 or later. uv can install it for you.
  • Claude Code and Codex. You talk to either one. Forge uses the other one for the independent read.

Install

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.

Set up a project

  • A new project: create an empty repository on GitHub (no files yet), clone it, and run 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.
  • A project with an older, copied-in Forge: run forge migrate. It moves you over in one pull request.
  • Every new copy of a project on your machine: run forge sync once, then forge doctor to check your setup. Doctor prints a fix for each problem it finds.

Upgrade a project

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.

Start working

Open Claude Code or Codex in the project and ask "what's next?", or run forge next yourself. Then describe the change you want.

Contributing

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.

Commands you'll see

Your agent runs these for you. You'll see them in its messages.

CommandWhat it does
forge nextSays where things stand and gives the exact next step
forge boardOpens a plain-English page showing the work and its progress
forge story newStarts a new story and its plan
forge readRuns the one independent read of a plan
forge task startStarts one task of an approved story in its own branch and folder
forge fix startStarts a small fix in its own branch and folder
forge workHas the worker build a task or fix, or fix what the review found
forge closeRuns the review, waits for the tests, and marks the pull request ready
forge mergeMerges a ready pull request, when the project allows the agent to merge
forge askAsks Codex a quick read-only question about the code
forge doctorChecks your setup and says how to fix each problem

The full command list is in the Forge guide.

Learn more

  • Forge guide: installing, every command, the two sizes of change, closing, upgrading and releasing.
  • Standards: the engineering rules every worker follows.
  • Specs: the detailed designs behind how Forge behaves, starting with Forge v1.
Source 7 files
hooks/register.ts 44 lines
1import 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}
44
hooks/forge.ts 89 lines
1import 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}
89
hooks/summary.ts 103 lines
1import 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}
103
hooks/pane.ts 170 lines
1import 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&apos;' })[c]!)
20const tableCell = (text: string) => text.replace(/\\/g, '\\\\').replace(/\|/g, '\\|').replace(/[\r\n]/g, ' ').replace(/[<>]/g, c => c === '<' ? '&lt;' : '&gt;')
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}
170
hooks/events.ts 106 lines
1import 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}
106
hooks/approval.ts 7 lines
1import 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) {}
7
hooks/machine.ts 205 lines
1import 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&apos;' })[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('<', '&lt;').replaceAll('>', '&gt;'))
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