Shows the live keel runs inside Claude Code: one line per run above the prompt (point at its step bar or branch for more) and a /keel-progress side panel, read…

An optional Claude Code mod that shows this repository's live keel runs inside Claude Code, so you don't need a second terminal running keel-visual dash.
All three are captures of Claude Code running the mod over three demo runs, rendered as SVG.
/config to see every recently active worktree of the repository.active, waiting or interrupted). Each row shows the issue, the backbone as a segmented bar of chips (done green, the current step blue, or red where the run stopped, the rest grey), the current step, why it is held as a chip (amber while waiting, red when stopped), the pull request (a link to it on GitHub), and how long since keel last wrote. With more than one run each row starts with its worktree's branch, as wide as the longest one shown, within what the band can spare. The bar takes two cells per step on a band of 120 columns or more, one from 80, and is left out below that; the issue and the step name are never cut. The card adds two rows of border to the band: one run takes three rows. Text uses your terminal's own colors. The session's own run is first, marked ▸ and drawn bright. Nothing is drawn when no run is live.8 of 13 steps · next s8 test); at a branch, the whole branch, its worktree and who drives the run. The run's name also lights in the side panel. The terminal draws this on its own: no hook runs as the pointer moves, and the band never changes height.more / less lists every run, not just the first three, each with a second line holding the whole branch name and the worktree path.Claude Code · claude · opus · high (only the parts known; nothing when none is). It is in each side-panel row's dim line, in the open run's card and in the band's branch hover, never as a new chip in the band. It comes from two places: the run's keel activity record (host, agent, model, effort, written by keel activity --write --host … --agent … --model … --effort … and by the ship, pr-loop and implement commands), which wins field by field; and, for a run that has a PR, the PR's agent:<name> and model:<name> labels, which fill any field the record lacks, read from the same gh pr list the mod already runs once a minute (no extra process)./keel-progress: opens a side panel, and closes it when it is open. It docks beside a wide fullscreen transcript and sits above the prompt otherwise, laid out like the agents panel from Claude Code's mods video:✦ Keel in this session, how many runs are running and how many need you◌ s7 review · 6m) or why it is held; under it, dim, how far along it is, the PR and the worktreekeel status fails is listed with its error and left out of the band until a read succeeds again.· 4m). A live run nothing has been written for in 45 minutes reads · quiet 1h, in the waiting colour, so a stuck run stands out.interrupted, gates blocked), waits for you (needs-input), or leaves the board (merged, closed or finished). There are none for what was already there when the session opened. A short sound can go with them.Set these in /config (or with /plugin configure keel-progress@keel):
| Setting | Default | What it does |
|---|---|---|
| Show other sessions' runs | off | Every recently active worktree of the repository, not only this session's own (a read per worktree). |
| Refresh every (seconds) | 2 | How often keel's state is read while a run is live; five times less often with none. keel status itself runs only when a checkpoint changed (or every 30 s while a run is live); activity is read from its files |
| Runs above the prompt | 3 | How many runs the band shows before +N more |
| Notifications | on | Toasts for a stopped run, a run waiting for you, a run that left |
| Sound | off | A short sound with those toasts |
Parallel keel ship runs each work in a worktree of their own. keel records a run in two places, and the mod reads both:
.keel/state/checkpoint.json, or wherever the project's status contract says), written at keel's safe boundaries, read with keel status --json.keel/activity/<run-id>.json), stamped at every phase a command passes through, read from its files (the first read asks keel activity --json where they live; if that fails, the default .keel/activity is used and keel is not asked again). It is often newer than the checkpoint, and some runs never write a checkpoint at all.For each run, whichever of the two was written last is shown. Activity from other commands (pr-loop, review-cycle, …) shows too, with its phase name and command. A newer completed checkpoint hides that run's older running activity record, so a finished run does not linger. A checkpoint without a run id (an older keel) joins the run of its issue; with several runs for one issue, the one written last.
The mod always reads the session's own folder, then lists the repository's worktrees with git worktree list and reads each other worktree whose checkpoint or activity changed in the last 24 hours. A run is shown when:
keel status), or its activity record says running and was stamped in the last six hours (nothing marks an abandoned run done, so an older one is a run that stopped),gh pr list --state open, read at most once a minute, and once more when a scan meets a PR the list doesn't hold; without gh nothing is hidden on these grounds)The PR rule exists because keel merge used to leave a merged run's checkpoint at s10 (#1448), so old checkpoints read as waiting on the merge window. Outside a git repository, or when git can't run, only the session's own folder is read. With no live run, the pane still shows the session's project: no active run, its history counts and next issue.
It scans:
keel status is failing), every ten when there is none. A read lists the worktrees with git and reads the activity files; keel status itself runs only when a checkpoint changed, at most every 30 s while a run is live, and not at all in an idle session whose checkpoint has not movedkeelOnly one scan runs at a time. A read asked for after a keel command, by the pane or by Refresh starts once the running one ends, so it is taken after the command returned. A keel command run in the background returns at once; the next poll picks up what it writes.
The step names come from the status contract (keel.progress-status.v1), so a renamed or added step shows up without a mod release.
It follows the same rule as keel-visual: it only reads. It never writes the checkpoint or ledger and never drives a run. So it shows any run in the repository's worktrees, whether you, Claude Code or Codex started it.
Install keel itself first (install guide). You also need Claude Code 2.1.287 or newer, with mods enabled for your account. If claude plugin test prints hooks modules are turned off in this process, mods are off for your account and no local setting turns them on.
/plugin marketplace add berkayturanci/keel
/plugin install keel-progress@keel
Start Claude Code in the directory that holds .keel/project.yaml, and make sure keel is on your PATH.
Other hosts are not affected. Only Claude Code's plugin loader reads this directory: the keel plugin, the Python package, and the Codex, Cursor and Antigravity adapters don't change.
claude --plugin-dir mods/keel-progress # loads it, hot-reloads on save
claude plugin validate mods/keel-progress
cd mods/keel-progress && claude plugin test
hooks/view.js is the pure projection from status JSON to what is drawn, and hooks/register.js holds the hooks. The tests in tests/ stub git worktree list, gh, keel status, the checkpoint times and the clock.
keel status fails is listed in the pane and left out of the band..keel/project.yaml when the session starts. A project created later in the session (keel init) shows after /reload-plugins or a new session.hooks/register.js 1013 lines1// keel-progress: a read-only window on the keel runs of this repository, inside Claude Code.
2//
3// Parallel `keel ship` runs each live in a worktree of their own and write that worktree's
4// checkpoint, so the mod scans `git worktree list` and asks `keel status --json` (the
5// consumer-neutral keel.progress-status.v1 contract) about each worktree whose checkpoint
6// changed recently. It draws:
7// - one line per live run in the band above the prompt (at most BAND_MAX, then "+N more")
8// - a `/keel-progress` side panel: every live run as a row, the one picked in full
9// It never writes to a checkpoint or ledger and never drives a run.
10
11import { FALLBACK_STEPS, activityRuns, ago, cells, checkpointDetails, fitCells, githubBase, isLive, latestPerRun, paneLines, parseStatus, parseWorktrees, safeHref, stepName, stepStates, whoFromLabels, whoText } from './view.js'
12
13// Relative paths resolve against the session's working directory.
14const PROJECT = '.keel/project.yaml'
15// Where a checkpoint lives when the status contract does not say (policy_pack.reports.checkpoint moves it).
16const DEFAULT_CHECKPOINT = '.keel/state/checkpoint.json'
17// Where `keel activity` keeps one file per run (keel.activity.v1 contract `dir`).
18const ACTIVITY_DIR = '.keel/activity'
19// A run stamps its activity at every phase, and nothing marks an abandoned one done: a "running"
20// record untouched this long is a run that stopped, not one that is waiting.
21const ACTIVITY_FRESH_MS = 6 * 60 * 60 * 1000
22// Where this project keeps activity, relative to a worktree: learned from the session's own
23// `keel activity --json` (policy_pack.reports.activity can move it), ACTIVITY_DIR until then.
24let activityRel = ACTIVITY_DIR
25let activityRelKnown = false
26const PANE = 'keel-progress'
27// With keel status cached and activity read from its files, a scan's only process is the cheap
28// `git worktree list`; keel itself starts only when something changed, so every 2 s is cheap.
29const POLL_MS = 2_000
30// With no live run the timer still ticks every POLL_MS but polls only every IDLE_EVERY ticks (10 s).
31const IDLE_EVERY = 5
32const STATUS_TIMEOUT_MS = 10_000
33// Another worktree whose checkpoint is untouched this long is not scanned. It is longer than
34// any merge-window wait, so a run parked at s10 overnight still shows; the session's own
35// worktree is always read.
36const FRESH_MS = 24 * 60 * 60 * 1000
37// `keel merge` used to leave a merged run's checkpoint at s10 (#1448), so a run whose pull
38// request is no longer open is hidden. The open list is read at most this often, and once more
39// in a scan that meets a PR it does not hold (a PR `keel ship` just opened).
40const PR_CACHE_MS = 60_000
41const BAND_MAX = 3
42// A live run nothing has been written for in this long is drawn as quiet, so a stuck run shows.
43const QUIET_MS = 45 * 60 * 1000
44// Wait reasons that mean the run is waiting for a person, worth a notification.
45const NEEDS_YOU = new Set(['needs-input'])
46// Branch labels in the band: at least LABEL_MIN cells, at most LABEL_MAX, else what is left of
47// the band's width after BAND_LINE_COLUMNS, the step bar and the card's border and padding.
48const LABEL_MIN = 12
49const LABEL_MAX = 48
50const BAND_LINE_COLUMNS = 64
51// The card's border and padding take this many of the band's columns, the keel mark (or the
52// spacer under it) and its gap this many more, and the more/less toggle on the first row these.
53const CARD_CHROME = 4
54const MARK_COLUMNS = 7
55const TOGGLE_COLUMNS = 5
56// The step bar: two cells per step on a wide band, one on a narrower one, none below that.
57const WIDE_BAR_COLUMNS = 120
58const NARROW_BAR_COLUMNS = 80
59
60// Text colors are the terminal's own (they follow its theme); a chip is white on a saturated
61// background, which reads on a dark and a light theme alike.
62const TONES = {
63 title: { bold: true },
64 bar: { color: 'cyan', bold: true },
65 ok: { color: 'green' },
66 wait: { color: 'yellow' },
67 bad: { color: 'red' },
68 live: { color: 'blue' },
69 runChip: { backgroundColor: '#1F6FEB', color: '#FFFFFF', bold: true },
70 dim: { dimColor: true },
71 plain: {},
72 // the step bar: one two-cell chip per backbone step
73 done: { backgroundColor: '#2D7D46' },
74 current: { backgroundColor: '#1F6FEB' },
75 stopped: { backgroundColor: '#B62324' },
76 todo: { backgroundColor: '#6E7681' },
77 // why a run is held: a chip
78 waitChip: { backgroundColor: '#9A6700', color: '#FFFFFF' },
79 stopChip: { backgroundColor: '#B62324', color: '#FFFFFF', bold: true },
80}
81const BORDER = '#6E7681'
82
83let hasProject = false
84let runs = [] // [{ path, label, snapshot, steps }] — live runs, the session's own first
85let own = null // the session's own last good status, live or not: the pane's idle view
86let ownStale = false // the last read of the session's own folder failed, so `own` is old
87let failures = [] // [{ label, message }] — worktrees whose `keel status` failed
88let scanned = 0 // other worktrees with a fresh checkpoint at the last scan
89let superseded = 0 // stale copies of a run another worktree holds a newer checkpoint for
90let closedPr = 0 // runs hidden because their pull request is no longer open
91let openPrs = null // Set of open PR numbers, or null when `gh` could not say
92let openPrsAt = -Infinity
93let prWho = new Map() // PR number -> { agent, model } from its `agent:` / `model:` labels (same `gh` call)
94// PRs a forced refetch already found closed. Timer scans do not refetch for them again (a merged run's
95// PR never reappears, and refetching for it every scan would call GitHub every few seconds);
96// the regular once-a-minute read still checks them.
97const knownClosed = new Set()
98let inFlight = null // the running scan; callers share it instead of starting a second one
99// `keel status` costs a Python start (seconds), so its answer is kept per folder while that
100// folder's checkpoint is unchanged, for at most STATUS_TTL_MS. A fresh request (a keel command,
101// the pane, Refresh) reads every folder anew.
102const statusCache = new Map() // path ('' for the session's own) -> { mtimeMs, at, result }
103const STATUS_TTL_MS = 30_000
104let forceNext = false
105let again = false // a fresh scan was asked for while one ran: run once more after it
106let idleTicks = 0
107let poller = null
108// The user's settings (plugin userConfig), with the defaults the manifest declares.
109const settings = { pollMs: POLL_MS, bandMax: BAND_MAX, notify: true, sound: false, allSessions: false }
110let scanAt = 0 // when the last scan read the runs, for "updated … ago"
111let repoBase = null // https://github.com/<owner>/<repo>, once read from `git remote`
112let repoBaseKnown = false
113let previous = null // runKey -> { issue, step, status, wait } at the last scan; null before the first
114// Set by a fresh request (a keel command, a click): the next scan rechecks PRs `knownClosed` holds,
115// once, in case `gh pr list` lagged right after `gh pr create`.
116let recheckClosed = false
117let selected = null // the run (runKey) the pane shows in full
118let expanded = false // the band lists every run, each with a second line
119
120// The timer's tick: every time while a run is live or the session's own read is failing (so
121// recovery shows at once), every IDLE_EVERY-th tick otherwise. Another worktree that keeps
122// failing does not hold the whole scan on the fast cadence.
123function tick($) {
124 if (runs.length === 0 && !ownStale) {
125 idleTicks = (idleTicks + 1) % IDLE_EVERY
126 if (idleTicks !== 0) return undefined
127 }
128 return refresh($, false)
129}
130
131// One scan at a time. A caller that needs a read taken after something it just did
132// (`fresh`: a keel command, the pane, the button) gets one more scan once the current one
133// ends; a timer tick just shares the running one.
134function refresh($, fresh) {
135 if (!hasProject) return Promise.resolve()
136 if (fresh) {
137 recheckClosed = true
138 forceNext = true
139 }
140 if (inFlight !== null) {
141 if (fresh) again = true
142 return inFlight
143 }
144 inFlight = scanUntilSettled($)
145 return inFlight
146}
147
148// Clears inFlight in the same step as the last `again` check, so a request that lands
149// between them starts a new scan instead of joining one that has already finished.
150async function scanUntilSettled($) {
151 try {
152 do {
153 again = false
154 await scan($)
155 } while (again)
156 } catch (err) {
157 runs = [] // what the band showed came from a scan that no longer holds
158 failures = [{ label: 'scan', message: String(err?.message ?? err) }]
159 } finally {
160 inFlight = null
161 $.ui.invalidate('ui.render')
162 }
163}
164
165function lastLine(text) {
166 const lines = text
167 .split('\n')
168 .map((l) => l.trim())
169 .filter(Boolean)
170 return lines[lines.length - 1]
171}
172
173async function loadOpenPrs($, now, force) {
174 if (!force && now - openPrsAt < PR_CACHE_MS) return openPrs
175 openPrsAt = now
176 try {
177 const run = await $.process.run(['gh', 'pr', 'list', '--state', 'open', '--limit', '500', '--json', 'number,labels'], {
178 timeoutMs: STATUS_TIMEOUT_MS,
179 })
180 const list = run.exitCode === 0 ? JSON.parse(run.stdout) : null
181 openPrs = list ? new Set(list.map((pr) => pr.number)) : null
182 prWho = new Map(list ? list.map((pr) => [pr.number, whoFromLabels(pr.labels)]) : [])
183 } catch {
184 openPrs = null
185 prWho = new Map() // no gh, no auth, no network: nothing is hidden on PR grounds
186 }
187 return openPrs
188}
189
190// One `keel status`: { parsed } when it answered, { failure } when it could not be read.
191// `path` null is the session's own folder, read by the relative project path as before.
192async function statusOf($, path) {
193 const argv =
194 path === null
195 ? ['keel', 'status', PROJECT, '--json']
196 : ['keel', 'status', `${path}/${PROJECT}`, '--root', path, '--json']
197 const init = path === null ? { timeoutMs: STATUS_TIMEOUT_MS } : { cwd: path, timeoutMs: STATUS_TIMEOUT_MS }
198 try {
199 const run = await $.process.run(argv, init)
200 if (run.exitCode !== 0) {
201 // keel prints warnings before the fatal message, so the last line is the one that matters
202 return { failure: lastLine(run.stderr || run.stdout || '') ?? `exit ${run.exitCode}` }
203 }
204 if (run.isStdoutTruncated) return { failure: 'keel status --json output is too large to read' }
205 return { parsed: parseStatus(run.stdout) }
206 } catch (err) {
207 return { failure: String(err?.message ?? err) }
208 }
209}
210
211// One `keel activity --json`: the still-running records stamped in the last ACTIVITY_FRESH_MS,
212// as run entries with the time their file was written, and the directory keel read. Activity
213// only adds detail, so a failure here is silent; the status read reports a broken worktree.
214async function activityOf($, path, base, steps, now) {
215 const argv =
216 path === null
217 ? ['keel', 'activity', PROJECT, '--root', '.', '--json']
218 : ['keel', 'activity', `${path}/${PROJECT}`, '--root', path, '--json']
219 const init = path === null ? { timeoutMs: STATUS_TIMEOUT_MS } : { cwd: path, timeoutMs: STATUS_TIMEOUT_MS }
220 try {
221 const run = await $.process.run(argv, init)
222 if (run.exitCode !== 0 || run.isStdoutTruncated) return { dir: null, entries: [], failed: true }
223 const { dir, runs } = activityRuns(run.stdout, steps)
224 const where = dir ?? `${base}/${activityRel}`
225 const entries = []
226 for (const entry of runs) {
227 const mtimeMs = await mtimeOf($, `${where}/${entry.fileName}`)
228 if (mtimeMs !== null && now - mtimeMs < ACTIVITY_FRESH_MS) entries.push({ ...entry, mtimeMs })
229 }
230 return { dir, entries }
231 } catch {
232 return { dir: null, entries: [], failed: true }
233 }
234}
235
236async function mtimeOf($, path) {
237 try {
238 return (await $.fs.stat(path)).mtimeMs
239 } catch {
240 return null
241 }
242}
243
244async function realPath($, path) {
245 try {
246 return (await $.fs.stat(path, { resolve: true })).realPath ?? path
247 } catch {
248 return path
249 }
250}
251
252async function listWorktrees($) {
253 try {
254 const listed = await $.process.run(['git', 'worktree', 'list', '--porcelain'], { timeoutMs: STATUS_TIMEOUT_MS })
255 return listed.exitCode === 0 ? parseWorktrees(listed.stdout) : []
256 } catch {
257 return [] // no git, or git too slow: the session's own folder is still read
258 }
259}
260
261async function statusCached($, path, mtimeMs, now, force) {
262 const key = path ?? ''
263 const hit = statusCache.get(key)
264 // Unchanged checkpoint: reuse the answer. The 30 s re-read applies only while a run is live, so
265 // an idle session starts no keel process at all.
266 // A failed read is never kept past the TTL: one timeout under load must not hide a waiting run.
267 const fresh = now - hit?.at < STATUS_TTL_MS
268 if (!force && hit && hit.mtimeMs === mtimeMs && (fresh || (runs.length === 0 && hit.result.failure === undefined))) return hit.result
269 const result = await statusOf($, path)
270 statusCache.set(key, { mtimeMs, at: now, result })
271 return result
272}
273
274// A folder's activity records, read straight from their files (keel.activity.v1, one JSON
275// record per run): no `keel activity` process per scan. Once the project's activity directory is
276// known, this is what every scan uses.
277async function activityFiles($, base, steps, now) {
278 const dir = `${base}/${activityRel}`
279 let listed
280 try {
281 listed = await $.fs.list(dir)
282 } catch {
283 return { dir: null, entries: [] }
284 }
285 const entries = []
286 for (const f of listed) {
287 if (f.kind !== 'file' || !f.name.endsWith('.json') || !(now - f.mtimeMs < ACTIVITY_FRESH_MS)) continue
288 let record
289 try {
290 record = JSON.parse(await $.fs.read(`${dir}/${f.name}`))
291 } catch {
292 continue // being rewritten, or not a record: the next scan reads it
293 }
294 // Only keel's own records, as `keel activity` reads them.
295 if (record?.schema_version !== 'keel.activity.v1' || record?.record_type !== 'command_activity') continue
296 const { runs: found } = activityRuns(JSON.stringify({ activity: [record], path: dir }), steps)
297 for (const entry of found) if (entry.fileName === f.name) entries.push({ ...entry, mtimeMs: f.mtimeMs })
298 }
299 return { dir, entries }
300}
301
302async function scan($) {
303 const now = await $.clock.now()
304 scanAt = now
305 const force = forceNext
306 forceNext = false
307 await learnRepoBase($)
308 const cwd = await $.session.cwd()
309 const here = await realPath($, cwd)
310
311 // The session's own folder first, always, whatever its checkpoint's age or location.
312 const candidates = []
313 const nextFailures = []
314 const ownCheckpoint = own?.checkpointPath ?? DEFAULT_CHECKPOINT
315 const ownCkM = ownCheckpoint.startsWith('/') ? null : await mtimeOf($, `${cwd}/${ownCheckpoint}`)
316 const mine = await statusCached($, null, ownCkM, now, force)
317 const worktrees = await listWorktrees($)
318 let ownLabel = 'here'
319 let ownBranch = null
320 const others = []
321 for (const w of worktrees) {
322 const at = await realPath($, w.path)
323 if (at === here) {
324 ownLabel = w.label
325 ownBranch = w.branch
326 }
327 // A session shows its own runs: its folder and the worktrees keel made under it (keel ship
328 // puts a run's worktree inside the session's checkout). The rest belong to other sessions,
329 // and are read only when the user asked to see every session's runs.
330 else if (settings.allSessions || at.startsWith(`${here}/`)) others.push(w)
331 }
332 if (mine.failure !== undefined) nextFailures.push({ label: ownLabel, path: cwd, message: mine.failure })
333 else candidates.push({ path: cwd, label: ownLabel, branch: ownBranch, own: true, mtimeMs: 0, ...mine.parsed })
334 own = mine.parsed ?? own
335 ownStale = mine.failure !== undefined
336
337 // Other worktrees: only those whose checkpoint (where this project keeps it) changed lately.
338 // A failed own read keeps the last known location, so other worktrees don't drop out.
339 const checkpoint = mine.parsed?.checkpointPath ?? own?.checkpointPath ?? DEFAULT_CHECKPOINT
340 // Defensive: keel rejects an absolute checkpoint path today. If one ever appears it is one file
341 // every worktree shares, which the session's own read already covers.
342 const perWorktree = !checkpoint.startsWith('/')
343 if (perWorktree && candidates.length > 0) {
344 // no checkpoint here: the own entry can only be a live run if keel says so; it ranks oldest
345 candidates[0].mtimeMs = (await mtimeOf($, `${cwd}/${checkpoint}`)) ?? 0
346 }
347 const steps = mine.parsed?.steps ?? own?.steps ?? FALLBACK_STEPS
348 const ownFields = { path: cwd, label: ownLabel, branch: ownBranch, own: true }
349 // The own folder's activity is read when it changed lately, and once at the start to learn
350 // where this project keeps it.
351 const ownActM = await mtimeOf($, `${cwd}/${activityRel}`)
352 if (!activityRelKnown || (ownActM !== null && now - ownActM < FRESH_MS)) {
353 const mineAct = activityRelKnown ? await activityFiles($, cwd, steps, now) : await activityOf($, null, cwd, steps, now)
354 if (!activityRelKnown && (mineAct.dir !== null || mineAct.failed)) {
355 // A `keel activity` that failed (keel < 1.6.0, a project file that won't load, a timeout)
356 // also settles the location on the default: otherwise every scan would spawn the process
357 // again, and reading the files is what every later scan does anyway (#1461).
358 activityRelKnown = true
359 // A directory outside the session's real path (a symlinked activity dir) cannot be made
360 // relative to a worktree, so the default stays.
361 if (mineAct.dir?.startsWith(`${here}/`)) activityRel = mineAct.dir.slice(here.length + 1)
362 }
363 for (const entry of mineAct.entries) candidates.push({ ...ownFields, ...entry })
364 }
365
366 // Other worktrees: only those whose checkpoint (where this project keeps it) or activity
367 // changed lately. A failed own read keeps the last known location, so others don't drop out.
368 let fresh = 0
369 for (const w of perWorktree ? others : []) {
370 const ckM = await mtimeOf($, `${w.path}/${checkpoint}`)
371 const actM = await mtimeOf($, `${w.path}/${activityRel}`)
372 const ckFresh = ckM !== null && now - ckM < FRESH_MS
373 const actFresh = actM !== null && now - actM < FRESH_MS
374 if (!ckFresh && !actFresh) continue // nothing written lately: no run in this worktree
375 fresh += 1
376 const fields = { path: w.path, label: w.label, branch: w.branch, own: false }
377 if (ckFresh) {
378 const result = await statusCached($, w.path, ckM, now, force)
379 if (result.failure !== undefined) nextFailures.push({ label: w.label, path: w.path, message: result.failure })
380 else candidates.push({ ...fields, mtimeMs: ckM, ...result.parsed })
381 }
382 if (actFresh) {
383 const act = activityRelKnown ? await activityFiles($, w.path, steps, now) : await activityOf($, w.path, w.path, steps, now)
384 for (const entry of act.entries) candidates.push({ ...fields, ...entry })
385 }
386 }
387
388 // `gh` is asked only when a live run has a pull request, so an idle session makes no API calls.
389 // One run can leave checkpoints in several worktrees (a worktree nested in another, a
390 // resumed run): the most recently written one is the run's state, the rest are stale copies.
391 // Dedupe before the live filter, so a newer finished checkpoint hides an older "running"
392 // activity record of the same run (a session that ended without `keel activity --done`).
393 const { kept, superseded: dupes, keyOf } = latestPerRun(candidates.filter((run) => run.snapshot.current))
394 const live = kept.filter((run) => isLive(run.snapshot))
395 // A checkpoint can win over the activity record of the same run, but only the record says who drives it.
396 // Joined by the key latestPerRun names a run with (run id first), so two runs of one issue keep
397 // their own. Of a run's activity copies the newest decides, even one with no identity fields.
398 const recordWho = new Map()
399 const whoAt = new Map()
400 for (const run of candidates) {
401 if (!run.who) continue
402 const key = keyOf(run)
403 if (!whoAt.has(key) || (run.mtimeMs ?? 0) >= whoAt.get(key)) {
404 whoAt.set(key, run.mtimeMs ?? 0)
405 recordWho.set(key, run.who)
406 }
407 }
408 for (const run of live) if (!run.who && recordWho.has(keyOf(run))) run.who = recordWho.get(keyOf(run))
409 const withPr = live.some((run) => run.snapshot.current.pull_request != null)
410 let prs = withPr ? await loadOpenPrs($, now, false) : null
411 const recheck = recheckClosed
412 recheckClosed = false
413 let refetched = false
414 const nextRuns = []
415 let hidden = 0
416 for (const run of live) {
417 const pr = run.snapshot.current.pull_request
418 if (pr != null && prs !== null && !prs.has(pr) && !refetched && (recheck || !knownClosed.has(pr))) {
419 refetched = true
420 prs = await loadOpenPrs($, now, true)
421 if (prs !== null && !prs.has(pr)) knownClosed.add(pr)
422 }
423 if (pr != null && prs !== null && prs.has(pr)) knownClosed.delete(pr)
424 if (pr != null && prs !== null && !prs.has(pr)) {
425 hidden += 1
426 continue
427 }
428 nextRuns.push(run)
429 }
430 for (const run of nextRuns) {
431 if (run.snapshot.source !== 'activity' && perWorktree) run.details = await detailsOf($, `${run.path}/${checkpoint}`)
432 }
433 announce($, nextRuns, new Set(nextFailures.map((f) => f.path)))
434 runs = nextRuns
435 if (runs.length === 0) expanded = false // the band comes back compact
436 failures = nextFailures
437 scanned = fresh
438 closedPr = hidden
439 superseded = dupes
440}
441
442function textProps(part) {
443 return { ...TONES[part.tone], wrap: 'truncate', children: [part.text] }
444}
445
446// "· 4m" after a run: how long since keel last wrote anything for it. Past QUIET_MS a live run
447// is drawn as quiet, in the waiting colour, so a stuck run stands out.
448function agePart(run) {
449 if (!(run.mtimeMs > 0) || !(scanAt > 0)) return []
450 const since = ago(scanAt - run.mtimeMs)
451 if (since === null) return []
452 return scanAt - run.mtimeMs >= QUIET_MS
453 ? [{ text: ` · quiet ${since}`, tone: 'wait' }]
454 : [{ text: ` · ${since}`, tone: 'dim' }]
455}
456
457async function learnRepoBase($) {
458 if (repoBaseKnown) return
459 repoBaseKnown = true
460 try {
461 const run = await $.process.run(['git', 'remote', 'get-url', 'origin'], { timeoutMs: STATUS_TIMEOUT_MS })
462 if (run.exitCode === 0) repoBase = githubBase(run.stdout)
463 } catch {
464 // no git, no remote: the pane shows numbers without links
465 }
466}
467
468async function detailsOf($, file) {
469 try {
470 return checkpointDetails(await $.fs.read(file))
471 } catch {
472 return []
473 }
474}
475
476// Toasts (and, if the user asked, a sound) for what changed since the last scan: a run that
477// stopped, one that waits for a person, one that left the board. Nothing on the first scan.
478// A run is followed across scans by what names it, not by where it was read: the issue (stable
479// across checkpoint and activity, and across worktrees), else the run id, else the PR.
480function identity(run) {
481 const c = run.snapshot.current
482 if (c.issue != null) return `issue:${c.issue}`
483 if (c.run_id) return `run:${c.run_id}`
484 if (c.pull_request != null) return `pr:${c.pull_request}`
485 return `path:${run.path}`
486}
487
488// A run whose worktree could not be read this scan has not left: it is carried over, unannounced,
489// until a read says otherwise.
490function announce($, nextRuns, failedPaths) {
491 const now = new Map(nextRuns.map((run) => [identity(run), run]))
492 const carried = new Map()
493 if (previous !== null) {
494 for (const [key, run] of now) {
495 const c = run.snapshot.current
496 const was = previous.get(key)
497 const label = c.issue != null ? `#${c.issue}` : (c.run_id ?? 'run')
498 if (run.snapshot.status === 'interrupted' && was?.status !== 'interrupted') {
499 notify($, `keel ${label} stopped at ${c.step ?? '?'}${c.wait_reason ? `: ${c.wait_reason}` : ''}`, 'attention')
500 } else if (NEEDS_YOU.has(c.wait_reason) && was?.wait !== c.wait_reason) {
501 notify($, `keel ${label} is waiting for you (${c.wait_reason})`, 'attention')
502 }
503 }
504 for (const [key, was] of previous) {
505 if (now.has(key)) continue
506 if (failedPaths.has(was.path)) carried.set(key, was)
507 else notify($, `keel ${was.label} is no longer running (merged, closed or finished)`, 'done')
508 }
509 }
510 previous = new Map([
511 ...carried,
512 ...[...now].map(([key, run]) => {
513 const c = run.snapshot.current
514 return [
515 key,
516 { label: c.issue != null ? `#${c.issue}` : (c.run_id ?? 'run'), path: run.path, status: run.snapshot.status, wait: c.wait_reason },
517 ]
518 }),
519 ])
520}
521
522// A notification is a side note: one that fails never costs the scan its runs.
523function notify($, text, sound) {
524 if (!settings.notify) return
525 try {
526 Promise.resolve($.ui.toast(text, { timeoutMs: 8000 })).catch(() => {})
527 // The sound goes with the toast, never on its own.
528 if (settings.sound) $.audio.play({ asset: `fx/${sound}.wav` }).catch(() => {})
529 } catch {
530 // nothing to do: the band still shows the change
531 }
532}
533
534// The branch label takes what the band can spare beside the step bar and its text.
535function stepCells(bodyColumns) {
536 const cols = bodyColumns ?? 0
537 return cols >= WIDE_BAR_COLUMNS ? 2 : cols >= NARROW_BAR_COLUMNS ? 1 : 0
538}
539
540// One label width for every row, so the issue and bar columns line up: it leaves room for the
541// widest bar on screen, the mark, and the toggle the first row carries.
542function labelWidth(bodyColumns, steps, toggle) {
543 const bar = stepCells(bodyColumns) * steps
544 const reserve = BAND_LINE_COLUMNS + CARD_CHROME + MARK_COLUMNS + (toggle ? TOGGLE_COLUMNS : 0) + bar
545 return Math.max(LABEL_MIN, Math.min(LABEL_MAX, (bodyColumns ?? 0) - reserve))
546}
547
548// With several runs on screen, the session's own one is marked `▸` and drawn bright.
549function labelPart(run, width) {
550 const name = `${run.own ? '▸ ' : ' '}${run.label}`
551 const label = cells(name) > width ? `${fitCells(name, width - 1)}…` : name
552 return { text: label + ' '.repeat(Math.max(0, width - cells(label))), tone: run.own ? 'title' : 'dim' }
553}
554
555// One worktree can hold several runs (a ship and a review-cycle stamping activity side by side),
556// so a run is named by its worktree and its run id (or issue), not the worktree alone.
557function runKey(run) {
558 const c = run.snapshot.current
559 return `${run.path}#${c.run_id ?? c.issue ?? c.pull_request ?? ''}`
560}
561
562async function openRun($, key) {
563 selected = key
564 // No focus: a digit typed into an empty prompt (to answer something else) also presses the
565 // band's buttons, and must not take the keyboard away from the prompt.
566 await $.ui.open(PANE_OPEN)
567 $.ui.invalidate('ui.render')
568}
569
570// What `selected` holds after the open run's row is pressed again: no run open, none opened by itself.
571const COLLAPSED = Symbol('collapsed')
572
573function selectRun($, key) {
574 selected = key
575 $.ui.invalidate('ui.render')
576}
577
578function toggleExpanded($) {
579 expanded = !expanded
580 $.ui.invalidate('ui.render')
581}
582
583function chip(Text, text, tone) {
584 return Text({ ...TONES[tone], wrap: 'truncate', children: [text] })
585}
586
587// The backbone as one segmented bar: a two-cell chip per step, done green, the current one blue
588// (red when the run stopped there), the rest grey.
589function stepBar(ui, run, cellsPerStep = 2) {
590 const { Box, Text } = ui
591 const stopped = run.snapshot.status === 'interrupted'
592 const cell = ' '.repeat(cellsPerStep)
593 return Box({
594 flexDirection: 'row',
595 flexShrink: 0,
596 children: stepStates(run.steps, run.snapshot.current.step).map((st) =>
597 chip(Text, cell, st.state === 'done' ? 'done' : st.state === 'current' ? (stopped ? 'stopped' : 'current') : 'todo'),
598 ),
599 })
600}
601
602// What a band row shows after the issue button: the step bar, the step, why it is held, the PR
603// and how long since keel last wrote.
604function bandRow(ui, run, cellsPerStep, found = []) {
605 const { Box, Text } = ui
606 const c = run.snapshot.current
607 // The step name never shrinks; the bar is left out on a band too narrow for it.
608 const parts = [
609 ...(cellsPerStep > 0 ? [hoverPart(ui, `bar-${hoverScope(run)}`.slice(0, 64), stepBar(ui, run, cellsPerStep), stepProgress(run), found)] : []),
610 Box({ flexShrink: 0, children: [chip(Text, stepName(run.steps, c.step), 'title')] }),
611 ]
612 if (c.wait_reason) {
613 const stopped = run.snapshot.status === 'interrupted'
614 parts.push(chip(Text, ` ${stopped ? 'stopped' : 'waiting'}: ${c.wait_reason} `, stopped ? 'stopChip' : 'waitChip'))
615 }
616 // The PR opens on GitHub when the repository is there; otherwise it is plain text.
617 if (c.pull_request != null) {
618 parts.push(
619 prHref(c.pull_request)
620 ? Box({ flexShrink: 0, children: [ui.Link({ href: prHref(c.pull_request), label: `PR #${c.pull_request}` })] })
621 : chip(Text, `PR #${c.pull_request}`, 'dim'),
622 )
623 }
624 for (const part of agePart(run)) parts.push(chip(Text, part.text.replace(/^ · /, ''), part.tone))
625 return parts
626}
627
628// Links to a run's PR and issue on GitHub, or null where there is no valid one to make.
629function prHref(n) {
630 return repoBase !== null && Number.isInteger(n) ? safeHref(`${repoBase}/pull/${n}`) : null
631}
632
633function issueHref(n) {
634 return repoBase !== null && Number.isInteger(n) ? safeHref(`${repoBase}/issues/${n}`) : null
635}
636
637// A run in full, one element per line (a card is these plus its two border lines): its branch,
638// worktree, links, the step bar and step, what keel last wrote, and the checkpoint's details.
639function runCardRows(ui, e, focus) {
640 const { Box, Text, Link } = ui
641 const rows = []
642 const cline = (part) => rows.push(Text(textProps(part)))
643 cline({ text: `${focus.own ? '▸ this session · ' : ''}${focus.branch ?? focus.label}`, tone: 'title' })
644 cline({ text: focus.path, tone: 'dim' })
645 if (runWho(focus)) cline({ text: runWho(focus), tone: 'dim' })
646 const c = focus.snapshot.current
647 // Links to the PR and the issue, when the repository is on GitHub.
648 if (repoBase !== null && (c.pull_request != null || c.issue != null)) {
649 rows.push(
650 Box({
651 key: 'keel-progress-links',
652 flexDirection: 'row',
653 columnGap: 2,
654 children: [
655 ...(prHref(c.pull_request) ? [Link({ href: prHref(c.pull_request), label: `PR #${c.pull_request}` })] : []),
656 ...(issueHref(c.issue) ? [Link({ href: issueHref(c.issue), label: `issue #${c.issue}` })] : []),
657 ],
658 }),
659 )
660 }
661 // The card sizes the bar to the panel's width, as the band does: the bar, the card's chrome and
662 // a step name must fit.
663 const paneCols = (e.props.bodyColumns ?? 0) - 6
664 const paneCells = paneCols >= 50 ? 2 : paneCols >= 36 ? 1 : 0
665 rows.push(
666 Box({
667 flexDirection: 'row',
668 columnGap: 1,
669 children: [
670 ...(paneCells > 0 ? [stepBar(ui, focus, paneCells)] : []),
671 Box({ flexShrink: 0, children: [chip(Text, stepName(focus.steps, c.step), 'title')] }),
672 ],
673 }),
674 )
675 for (const part of paneLines(focus.snapshot, focus.steps).slice(1)) cline(part)
676 const since = focus.mtimeMs > 0 && scanAt > 0 ? ago(scanAt - focus.mtimeMs) : null
677 if (since !== null) {
678 const from = focus.snapshot.source === 'activity' ? 'activity record' : 'checkpoint'
679 const quiet = scanAt - focus.mtimeMs >= QUIET_MS
680 cline({ text: `last written ${since === 'now' ? 'just now' : `${since} ago`} (${from})${quiet ? ' · quiet' : ''}`, tone: quiet ? 'wait' : 'dim' })
681 }
682 for (const [name, value] of focus.details ?? []) cline({ text: `${name}: ${value}`, tone: 'plain' })
683 if (focus.snapshot.note) cline({ text: `note: ${focus.snapshot.note}`, tone: 'plain' })
684 return rows
685}
686
687// A part that says more while the pointer is on it. The part sits in a Box that joins a hover
688// group of its own; the detail is a hidden Box in the same group, drawn by `reveals` as the band
689// row's last child, at the row's right end (absolute: nothing moves, the band keeps its height).
690// Drawn last, it paints over what is under it rather than under the parts after it. Inverse
691// text reads on a dark and a light theme alike. No hook runs as the pointer moves.
692function hoverPart(ui, scope, shown, detail, found) {
693 const { Box } = ui
694 if (!detail) return shown
695 found.push({ scope, detail })
696 return Box({ flexShrink: 0, hover: { scope }, children: [shown] })
697}
698
699// The hidden details of a band row's parts, each shown while its part is pointed at.
700function reveals(ui, list) {
701 const { Box, Text } = ui
702 return list.map((r) =>
703 Box({
704 position: 'absolute',
705 top: 0,
706 right: 0,
707 display: 'none',
708 hover: { scope: r.scope, display: 'flex' },
709 children: [Text({ inverse: true, wrap: 'truncate', children: [` ${r.detail} `] })],
710 }),
711 )
712}
713
714// Where a run is on the backbone: "5 of 13 steps · next s5 classify", for the bar's hover and the panel.
715function stepProgress(run) {
716 const at = run.steps.findIndex((st) => st.id === run.snapshot.current.step)
717 if (at < 0) return null
718 const after = run.steps[at + 1]
719 return `${at + 1} of ${run.steps.length} steps${after ? ` · next ${stepName(run.steps, after.id)}` : ''}`
720}
721
722// The hover group a run's band row and panel row share (1-64 characters).
723function hoverScope(run) {
724 const c = run.snapshot.current
725 return `run-${c.run_id ?? c.issue ?? c.pull_request ?? run.label}`.slice(0, 64)
726}
727
728// The pane: the side panel's width when it docks beside a fullscreen transcript.
729const PANE_OPEN = { id: PANE, title: 'keel', closeOnEscape: true, columns: 64 }
730
731// Whether a run needs a person: it stopped, or it waits for input.
732function needsYou(run) {
733 return run.snapshot.status === 'interrupted' || NEEDS_YOU.has(run.snapshot.current.wait_reason)
734}
735
736// A run's dot: red when it stopped, yellow when it waits or has gone quiet, blue while it runs.
737function runDot(run) {
738 if (run.snapshot.status === 'interrupted') return 'bad'
739 if (run.snapshot.current.wait_reason || (run.mtimeMs > 0 && scanAt - run.mtimeMs >= QUIET_MS)) return 'wait'
740 return 'live'
741}
742
743// The right-hand side of a panel row: the step and how long since keel wrote, or why it is held.
744function runStatus(ui, run) {
745 const { Text } = ui
746 const c = run.snapshot.current
747 if (run.snapshot.status === 'interrupted') return chip(Text, ` stopped${c.wait_reason ? `: ${c.wait_reason}` : ''} `, 'stopChip')
748 if (c.wait_reason) return chip(Text, ` waiting: ${c.wait_reason} `, 'waitChip')
749 const age = agePart(run)[0]?.text.replace(/^ · /, '')
750 return chip(Text, `◌ ${stepName(run.steps, c.step)}${age ? ` · ${age}` : ''}`, 'runChip')
751}
752
753// Who drives a run: `Claude Code · claude · opus · high`, only the parts known. The activity
754// record's fields win over the PR's labels; empty when nothing is known.
755function runWho(run) {
756 const pr = run.snapshot.current.pull_request
757 return whoText(pr != null ? prWho.get(pr) : null, run.who)
758}
759
760// The dim line under a panel row: how far along, the PR, who drives it, and where it runs.
761function runSummary(run) {
762 const c = run.snapshot.current
763 return [stepProgress(run), c.pull_request != null ? `PR #${c.pull_request}` : null, runWho(run), run.path].filter(Boolean).join(' · ')
764}
765
766// One run in the side panel, as the agents panel draws an agent: a colored dot, the run's name
767// (a button that opens it in full below), its step or why it is held on the right, and under it,
768// dim, how far along it is.
769function paneRow($, ui, run, open) {
770 const { Box, Text, Button } = ui
771 const c = run.snapshot.current
772 return Box({
773 key: `keel-progress-row-${runKey(run)}`,
774 flexDirection: 'column',
775 paddingX: 1,
776 hover: { scope: hoverScope(run) },
777 children: [
778 Box({
779 flexDirection: 'row',
780 justifyContent: 'space-between',
781 columnGap: 1,
782 children: [
783 Box({
784 flexDirection: 'row',
785 columnGap: 1,
786 flexShrink: 1,
787 children: [
788 chip(Text, '●', runDot(run)),
789 Button({
790 key: `keel-progress-pick-${runKey(run)}`,
791 label: `${run.own ? '▸ ' : ''}${run.branch ?? run.label}${c.issue != null ? ` · #${c.issue}` : ''}`,
792 plain: true,
793 // Lit (inverse) while this run is pointed at, here or in the band.
794 hover: { scope: hoverScope(run), inverse: true },
795 onPress: () => selectRun($, open ? COLLAPSED : runKey(run)),
796 }),
797 chip(Text, open ? '⌄' : '›', 'dim'),
798 ],
799 }),
800 Box({ flexShrink: 0, children: [runStatus(ui, run)] }),
801 ],
802 }),
803 Box({ paddingLeft: 2, children: [chip(Text, runSummary(run), 'dim')] }),
804 ],
805 })
806}
807
808export function register(on, options = {}) {
809 if (Number.isFinite(options.poll_seconds)) settings.pollMs = Math.max(2, Math.min(60, options.poll_seconds)) * 1000
810 if (Number.isFinite(options.band_rows)) settings.bandMax = Math.max(1, Math.min(9, options.band_rows))
811 if (typeof options.notify === 'boolean') settings.notify = options.notify
812 if (typeof options.sound === 'boolean') settings.sound = options.sound
813 if (typeof options.all_sessions === 'boolean') settings.allSessions = options.all_sessions
814 on('session.start', async ($, e, next) => {
815 hasProject = await $.fs.exists(PROJECT)
816 if (hasProject) {
817 // Off the start path: the first scan lands a moment after the session opens.
818 $.clock.after(0, () => refresh($, true))
819 // session.start fires once per module load and a reload stops the old timer, so this is
820 // belt and braces: never two pollers.
821 poller?.cancel()
822 poller = $.clock.every(settings.pollMs, () => tick($))
823 }
824 await $.command.register({
825 name: 'keel-progress',
826 description: "Show this repository's live keel runs: every step, history counts and the next issue",
827 immediate: true,
828 })
829 return next(e)
830 })
831
832 // A keel command may have just moved a run, so scan as soon as it returns. A command run
833 // in the background returns at once; the next poll catches what it writes.
834 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
835 const result = await next(e)
836 // A keel invocation (bare, by path, quoted), not `.keel/` or `keel-visual`. An argument
837 // that happens to be the word keel costs one extra scan; a miss would leave the band stale.
838 if (hasProject && /(^|[\s;&|(/`'"])keel[`'"]?(\s|$)/.test(String(e.command ?? ''))) {
839 $.clock.after(0, () => refresh($, true))
840 }
841 return result
842 })
843
844 // The command toggles the side panel: it opens it, or closes it when it is open.
845 on('command.run', { command: 'keel-progress' }, async ($) => {
846 // A panel already shown closes; one behind another tab is brought forward by the open below.
847 let panes = []
848 try {
849 // An engine without panes() throws here too, and the panel just opens.
850 panes = await $.ui.panes()
851 } catch {
852 panes = []
853 }
854 if (panes.some((p) => p.id === PANE && p.isShown)) {
855 await $.ui.close({ id: PANE })
856 return {}
857 }
858 // Open first: a slow scan must not delay the pane; the scan redraws it.
859 selected = null
860 await $.ui.open(PANE_OPEN)
861 $.clock.after(0, () => refresh($, true))
862 return {}
863 })
864
865 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
866 if (runs.length === 0) return next(e)
867 const ui = $.ui.resolve(e)
868 const { Box, Text, Button } = ui
869 const labelled = runs.length > 1
870 const shown = expanded ? runs : runs.slice(0, settings.bandMax)
871 // As wide as the longest label on screen, within what the band can spare.
872 const longest = Math.max(...shown.map((run) => cells(run.label) + 2))
873 const barCells = stepCells(e.props.bodyColumns)
874 const toggle = runs.length > 1 || expanded
875 const widestBar = Math.max(...shown.map((run) => run.steps.length))
876 const width = Math.min(labelWidth(e.props.bodyColumns, widestBar, toggle), Math.max(LABEL_MIN, longest))
877 const rows = []
878 shown.forEach((run, i) => {
879 const issue = run.snapshot.current.issue
880 const found = []
881 rows.push(
882 Box({
883 key: `keel-progress-${runKey(run)}`,
884 // Pointing at a run here lights its name in the side panel, and the other way round.
885 hover: { scope: hoverScope(run) },
886 flexDirection: 'row',
887 columnGap: 1,
888 children: [
889 // The keel mark heads the first row (no header row of its own: the card is short).
890 Box({ flexShrink: 0, children: [chip(Text, i === 0 ? '◆ keel' : ' ', 'title')] }),
891 // The label keeps its padded width, so the issue and bar columns line up row to row;
892 // only the trailing chips give way on a narrow band.
893 ...(labelled ? [Box({ flexShrink: 0, children: [hoverPart(ui, `label-${hoverScope(run)}`.slice(0, 64), Text(textProps(labelPart(run, width))), [`${run.branch ?? run.label} · ${run.path}`, runWho(run)].filter(Boolean).join(' · '), found)] })] : []),
894 // The issue is a button: click it to open the pane on this run. No digit hotkey: a
895 // passive band must not take the first key of a prompt (#1466).
896 Box({
897 flexShrink: 0,
898 children: [
899 Button({
900 key: `keel-progress-open-${runKey(run)}`,
901 label: issue != null ? `#${issue}` : run.snapshot.current.step ?? 'run',
902 plain: true,
903 hover: { scope: hoverScope(run), inverse: true },
904 onPress: () => openRun($, runKey(run)),
905 }),
906 ],
907 }),
908 ...bandRow(ui, run, barCells, found),
909 ...(i === 0 && toggle
910 ? [Button({ key: 'keel-progress-toggle', label: expanded ? 'less' : 'more', plain: true, onPress: () => toggleExpanded($) })]
911 : []),
912 // Last, so each detail paints over the row rather than under the parts after it.
913 ...reveals(ui, found),
914 ],
915 }),
916 )
917 if (expanded) {
918 // The second line carries what the first had to cut: the whole branch and where it runs.
919 rows.push(Text({ ...textProps({ text: ` ${run.branch ?? run.label} · ${run.path}`, tone: 'dim' }), wrap: 'truncate-middle' }))
920 }
921 })
922 if (!expanded && runs.length > settings.bandMax) {
923 rows.push(Text(textProps({ text: `+${runs.length - settings.bandMax} more keel runs · more, or /keel-progress`, tone: 'dim' })))
924 }
925 const card = Box({ flexDirection: 'column', borderStyle: 'round', borderColor: BORDER, paddingX: 1, children: rows })
926 // Keep what the mods after this one draw in the band.
927 const theirs = await next(e)
928 return Box({ flexDirection: 'column', children: theirs ? [card, theirs] : [card] })
929 })
930
931 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
932 if (e.requestId !== PANE) return next(e)
933 const ui = $.ui.resolve(e)
934 const { Box, Text, Button, Link } = ui
935 const children = []
936 const line = (part) => children.push(Text(textProps(part)))
937 if (!hasProject) {
938 line({ text: `No ${PROJECT} in this directory, so there is no keel run to show.`, tone: 'dim' })
939 } else {
940 const held = runs.filter(needsYou)
941 const moving = runs.filter((run) => !needsYou(run))
942 // The header, as the agents panel heads its list: what this is, and how many are running.
943 const count = [moving.length > 0 ? `◌ ${moving.length} running` : null, held.length > 0 ? `${held.length} need you` : null].filter(Boolean).join(' · ')
944 children.push(
945 Box({
946 flexDirection: 'row',
947 justifyContent: 'space-between',
948 children: [
949 Box({ flexDirection: 'row', columnGap: 1, children: [chip(Text, '✦ Keel', 'title'), chip(Text, settings.allSessions ? 'in this repository' : 'in this session', 'dim')] }),
950 chip(Text, count || 'idle', held.length > 0 ? 'wait' : runs.length > 0 ? 'live' : 'dim'),
951 ],
952 }),
953 )
954 // The run picked is open in full; with none picked, the first one listed (one that needs you
955 // before one that runs) is, when its card fits in the rows the panel shows (the engine owns
956 // the scroll, so an overflow would push the header out of sight). A row of slack for a long
957 // branch name that wraps beside a wide chip.
958 const picked = runs.find((run) => runKey(run) === selected)
959 const first = held[0] ?? moving[0]
960 const listRows = 1 + failures.length + [held, moving].filter((g) => g.length > 0).length + runs.length * 2 + 3 + 1
961 const room = e.props.scroll?.bodyRows ?? Infinity
962 const focus = picked ?? (selected !== COLLAPSED && first && listRows + runCardRows(ui, e, first).length + 2 <= room ? first : undefined)
963 for (const [title, group] of [['NEEDS YOU', held], ['RUNNING', moving]]) {
964 if (group.length === 0) continue
965 children.push(Box({ flexDirection: 'row', columnGap: 1, children: [chip(Text, title, 'title'), chip(Text, `· ${group.length}`, 'dim')] }))
966 for (const run of group) {
967 children.push(paneRow($, ui, run, run === focus))
968 if (run === focus) {
969 children.push(
970 Box({
971 paddingLeft: 2,
972 children: [Box({ flexDirection: 'column', borderStyle: 'round', borderColor: BORDER, paddingX: 1, flexGrow: 1, children: runCardRows(ui, e, run) })],
973 }),
974 )
975 }
976 }
977 }
978 const hiddenNote =
979 (closedPr > 0 ? ` · ${closedPr} hidden (PR closed)` : '') +
980 (superseded > 0 ? ` · ${superseded} stale copy(ies) of a run` : '')
981 line({ text: `${scanned} other worktree(s) with recent keel state${hiddenNote}`, tone: 'dim' })
982 if (runs.length === 0 && own !== null) {
983 // No live run: the session's own project, as it is (no active run, history, next issue).
984 line({ text: ' ', tone: 'plain' })
985 // After a failed read this is the last status that worked, and says so (#1446).
986 if (ownStale) line({ text: 'last good status:', tone: 'dim' })
987 for (const part of paneLines(own.snapshot, own.steps)) line(part)
988 }
989 for (const failure of failures) {
990 line({ text: `keel status failed (${failure.label}): ${failure.message}`, tone: 'bad' })
991 }
992 }
993 children.push(
994 Box({
995 key: 'keel-progress-actions',
996 flexDirection: 'column',
997 children: [
998 ...(runs.length > 0 ? [chip(Text, 'click a run for its steps · /keel-progress to hide', 'dim')] : []),
999 Box({
1000 flexDirection: 'row',
1001 columnGap: 2,
1002 children: [
1003 Button({ key: 'refresh', label: 'Refresh', onPress: () => refresh($, true) }),
1004 Button({ key: 'close', label: 'Close', onPress: () => $.ui.close({ id: PANE }) }),
1005 ],
1006 }),
1007 ],
1008 }),
1009 )
1010 return Box({ flexDirection: 'column', children })
1011 })
1012}
1013hooks/view.js 301 lines1// Pure projection of `keel status --json` onto what keel-progress draws.
2// No mods API here: register.js turns these plain values into elements.
3
4// The backbone, used only when the status payload carries no contract.
5export const FALLBACK_STEPS = [
6 ['s0', 'config'], ['s1', 'select'], ['s2', 'branch'], ['s3', 'guard'],
7 ['s4', 'implement'], ['s5', 'classify'], ['s6', 'ci'], ['s7', 'review'],
8 ['s8', 'test'], ['s9', 'fixloop'], ['s10', 'merge'], ['s11', 'capture'],
9 ['s12', 'close'],
10].map(([id, name]) => ({ id, name }))
11
12// States worth a line above the prompt. `no-active-run` and `completed` draw nothing.
13const LIVE_STATES = new Set(['active', 'waiting', 'interrupted'])
14
15// Parses `keel status --json` stdout into { snapshot, steps, checkpointPath }, or throws.
16// checkpointPath is the project's checkpoint, relative to its root, when the contract names it.
17export function parseStatus(stdout) {
18 const payload = JSON.parse(stdout)
19 const snapshot = payload && payload.snapshot
20 if (!snapshot || typeof snapshot.status !== 'string') {
21 throw new Error('keel status --json has no snapshot.status')
22 }
23 const declared = payload.contract?.source?.checkpoint?.steps
24 const steps = Array.isArray(declared) && declared.length > 0
25 ? declared.map((s) => ({ id: String(s.step_id), name: String(s.step_name ?? s.step_id) }))
26 : FALLBACK_STEPS
27 const checkpoint = payload.contract?.source?.checkpoint?.path
28 return { snapshot, steps, checkpointPath: typeof checkpoint === 'string' && checkpoint ? checkpoint : null }
29}
30
31// `git worktree list --porcelain` → [{ path, label }]; the label is the branch, else the folder.
32export function parseWorktrees(porcelain) {
33 const out = []
34 for (const block of porcelain.replace(/\r\n/g, '\n').split('\n\n')) {
35 let path = null
36 let branch = null
37 for (const line of block.split('\n')) {
38 if (line.startsWith('worktree ')) path = line.slice('worktree '.length)
39 else if (line.startsWith('branch ')) branch = line.slice('branch '.length).replace(/^refs\/heads\//, '')
40 }
41 if (path) out.push({ path, branch, label: branch ?? path.split('/').pop() })
42 }
43 return out
44}
45
46// Keeps one entry per run — by run id, else issue and PR, else the worktree — the one whose
47// checkpoint was written last (the session's own on a tie). When the session's own folder held a
48// stale copy, the winner is still the session's run: it keeps the `own` mark and goes first.
49// Returns { kept, superseded }.
50export function latestPerRun(entries) {
51 // A run id names the run; an entry without one (an older checkpoint) joins the run that shares
52 // its issue, so an activity record and an older checkpoint of the same run meet. With several
53 // run ids for one issue it joins the one written last; runs written at the same moment keep the first seen.
54 const runOfIssue = new Map()
55 const newestOfIssue = new Map()
56 for (const e of entries) {
57 const c = e.snapshot.current
58 if (!c.run_id || c.issue == null) continue
59 if (!(newestOfIssue.get(c.issue) >= e.mtimeMs)) {
60 newestOfIssue.set(c.issue, e.mtimeMs)
61 runOfIssue.set(c.issue, `run:${c.run_id}`)
62 }
63 }
64 const keyOf = (e) => {
65 const c = e.snapshot.current
66 if (c.run_id) return `run:${c.run_id}`
67 // A copy written before the PR was opened has no PR yet: the issue alone names the run.
68 if (c.issue != null) return runOfIssue.get(c.issue) ?? `issue:${c.issue}`
69 if (c.pull_request != null) return `pr:${c.pull_request}`
70 return `path:${e.path}`
71 }
72 const best = new Map()
73 const ownKeys = new Set()
74 for (const e of entries) {
75 const key = keyOf(e)
76 if (e.own) ownKeys.add(key)
77 const prev = best.get(key)
78 if (!prev || e.mtimeMs > prev.mtimeMs || (e.mtimeMs === prev.mtimeMs && e.own && !prev.own)) best.set(key, e)
79 }
80 const kept = []
81 for (const e of entries) {
82 const key = keyOf(e)
83 if (best.get(key) !== e) continue
84 const entry = ownKeys.has(key) ? { ...e, own: true } : e
85 if (entry.own) kept.unshift(entry)
86 else kept.push(entry)
87 }
88 return { kept, superseded: entries.length - kept.length, keyOf }
89}
90
91// Terminal cells a character takes: East Asian wide and fullwidth characters and emoji take
92// two; combining marks, variation selectors and the zero-width joiner take none. A flag is two
93// regional indicators (U+1F1E6-1F1FF) drawn in two cells, so each indicator counts as one.
94// Written as escapes: invisible characters in a regex source do not survive editors.
95const WIDE = /[\u{1100}-\u{115F}\u{231A}\u{231B}\u{23E9}-\u{23EC}\u{23F0}\u{23F3}\u{25FD}\u{25FE}\u{2614}\u{2615}\u{2648}-\u{2653}\u{267F}\u{2693}\u{26A1}\u{26AA}\u{26AB}\u{26BD}\u{26BE}\u{26C4}\u{26C5}\u{26CE}\u{26D4}\u{26EA}\u{26F2}\u{26F3}\u{26F5}\u{26FA}\u{26FD}\u{2705}\u{270A}\u{270B}\u{2728}\u{274C}\u{274E}\u{2753}-\u{2755}\u{2757}\u{2795}-\u{2797}\u{27B0}\u{27BF}\u{2B1B}\u{2B1C}\u{2B50}\u{2B55}\u{2E80}-\u{303E}\u{3041}-\u{33FF}\u{3400}-\u{4DBF}\u{4E00}-\u{9FFF}\u{A000}-\u{A4CF}\u{AC00}-\u{D7A3}\u{F900}-\u{FAFF}\u{FE30}-\u{FE4F}\u{FF00}-\u{FF60}\u{FFE0}-\u{FFE6}\u{1F300}-\u{1F64F}\u{1F680}-\u{1F6FF}\u{1F900}-\u{1FAFF}\u{20000}-\u{3FFFD}]/u
96const ZERO = /[\u{0300}-\u{036F}\u{200B}-\u{200D}\u{FE00}-\u{FE0F}]/u
97function charCells(ch) {
98 return ZERO.test(ch) ? 0 : WIDE.test(ch) ? 2 : 1
99}
100
101export function cells(text) {
102 let n = 0
103 for (const ch of text) n += charCells(ch)
104 return n
105}
106
107// The longest prefix of `text` that fits in `width` cells.
108export function fitCells(text, width) {
109 let out = ''
110 let n = 0
111 for (const ch of text) {
112 const w = charCells(ch)
113 if (n + w > width) break
114 out += ch
115 n += w
116 }
117 return out
118}
119
120// keel's file name for a run id (activity.run_id_slug): lowercase, runs of anything outside
121// [a-z0-9._-] become '-', and leading or trailing '-' and '.' go.
122export function runIdSlug(runId) {
123 return String(runId).trim().toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^[-.]+|[-.]+$/g, '')
124}
125
126// `keel activity --json` → { dir, runs }: the directory keel read (absolute, or null when the
127// payload does not say) and the runs it says are still running, shaped like a status snapshot
128// so the band and the pane draw them the same way. Activity is written at every phase a command
129// stamps, so it is often newer than the checkpoint, which keel writes only at its safe
130// boundaries; and some flows stamp activity without ever writing a checkpoint. `steps` is the
131// backbone from a status contract, used when the phase is one of its steps (keel ship).
132export function activityRuns(stdout, steps) {
133 const payload = JSON.parse(stdout)
134 const dir = typeof payload?.path === 'string' && payload.path ? payload.path : null
135 const records = Array.isArray(payload?.activity) ? payload.activity : []
136 const runs = []
137 for (const r of records) {
138 if (!r || r.status !== 'running' || !r.run_id) continue
139 const onBackbone = steps.some((s) => s.id === r.phase)
140 const blocked = r.verdict === 'blocked'
141 runs.push({
142 fileName: `${runIdSlug(r.run_id)}.json`,
143 steps: onBackbone ? steps : [{ id: String(r.phase ?? r.command), name: `(${r.command})` }],
144 snapshot: {
145 status: blocked ? 'interrupted' : 'active',
146 current: {
147 run_id: String(r.run_id),
148 command: r.command ?? null,
149 issue: r.issue ?? null,
150 pull_request: r.pr ?? null,
151 step: r.phase ?? null,
152 wait_reason: blocked ? 'gates blocked' : null,
153 },
154 source: 'activity',
155 note: r.note ? String(r.note) : null,
156 },
157 who: whoFromRecord(r),
158 })
159 }
160 return { dir, runs }
161}
162
163// Who drives a run (#1482): the host, agent, model and effort an activity record carries, and the
164// `agent:` / `model:` labels of the run's PR. Only what is known, in that order; activity wins over
165// labels. `labels` is the gh label list ([{ name }]); null/undefined when there is none.
166export const WHO_FIELDS = ['host', 'agent', 'model', 'effort']
167const WHO_MAX = 64
168
169function whoValue(v) {
170 if (typeof v !== 'string') return null
171 const t = v.trim()
172 return t && t.length <= WHO_MAX && !/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/.test(t) ? t : null
173}
174
175export function whoFromRecord(record) {
176 const who = {}
177 for (const f of WHO_FIELDS) {
178 const v = whoValue(record?.[f])
179 if (v !== null) who[f] = v
180 }
181 return who
182}
183
184export function whoFromLabels(labels) {
185 const who = {}
186 for (const l of Array.isArray(labels) ? labels : []) {
187 const name = typeof l?.name === 'string' ? l.name : ''
188 for (const f of ['agent', 'model']) {
189 const v = name.startsWith(`${f}:`) ? whoValue(name.slice(f.length + 1)) : null
190 if (v !== null && who[f] === undefined) who[f] = v
191 }
192 }
193 return who
194}
195
196export function whoText(...sources) {
197 const who = Object.assign({}, ...sources)
198 return WHO_FIELDS.map((f) => who[f]).filter(Boolean).join(' · ')
199}
200
201// "now", "4m", "2h", "3d": how long ago `ms` milliseconds is, for the band and the pane.
202export function ago(ms) {
203 if (!(ms >= 0)) return null
204 const m = Math.floor(ms / 60_000)
205 if (m < 1) return 'now'
206 if (m < 60) return `${m}m`
207 const h = Math.floor(m / 60)
208 if (h < 48) return `${h}h`
209 return `${Math.floor(h / 24)}d`
210}
211
212// https://github.com/<owner>/<repo> from a git remote URL (https or ssh), else null.
213// Only github.com itself (an ssh host alias such as `github.com-work` included), and only names
214// GitHub allows, so a link built from it is always a valid href (a refused href would make the
215// engine refuse the whole band).
216const GH_NAME = '[A-Za-z0-9_.-]+'
217const GH_REMOTES = [
218 new RegExp(`^(?:ssh://)?[A-Za-z0-9_.-]+@github\\.com(?:-[A-Za-z0-9_.-]+)?[:/](${GH_NAME})/(${GH_NAME}?)(?:\\.git)?/?$`),
219 new RegExp(`^https?://(?:[^@/\\s]+@)?github\\.com/(${GH_NAME})/(${GH_NAME}?)(?:\\.git)?/?$`),
220]
221
222export function githubBase(remote) {
223 const text = String(remote ?? '').trim()
224 for (const re of GH_REMOTES) {
225 const m = text.match(re)
226 if (m && !['.', '..'].includes(m[1]) && !['.', '..', ''].includes(m[2])) return `https://github.com/${m[1]}/${m[2]}`
227 }
228 return null
229}
230
231// An href the mod API accepts: https, printable ASCII, no '@', spelled exactly as URL spells it.
232export function safeHref(h) {
233 if (typeof h !== 'string' || !/^https:\/\/[\x21-\x7e]+$/.test(h) || h.includes('@')) return null
234 try {
235 return typeof URL === 'function' && new URL(h).href === h ? h : null
236 } catch {
237 return null
238 }
239}
240
241// What a checkpoint says about the run's last gate, review and check, for the pane.
242export function checkpointDetails(text) {
243 try {
244 const state = JSON.parse(text)?.state ?? {}
245 const out = []
246 if (state.last_gate) out.push(['last gate', String(state.last_gate)])
247 if (state.last_review) out.push(['last review', String(state.last_review)])
248 if (state.last_check) out.push(['last check', String(state.last_check)])
249 return out
250 } catch {
251 return []
252 }
253}
254
255export function isLive(snapshot) {
256 return Boolean(snapshot && LIVE_STATES.has(snapshot.status) && snapshot.current)
257}
258
259// One entry per backbone step: 'done' before the current step, 'current' on it, 'pending' after.
260export function stepStates(steps, currentStep) {
261 const at = steps.findIndex((s) => s.id === currentStep)
262 return steps.map((s, i) => ({
263 ...s,
264 state: at < 0 ? 'pending' : i < at ? 'done' : i === at ? 'current' : 'pending',
265 }))
266}
267
268export function stepName(steps, id) {
269 const found = steps.find((s) => s.id === id)
270 return found ? `${id} ${found.name}` : String(id ?? '-')
271}
272
273// The pane's lines, top to bottom, each { text, tone }.
274export function paneLines(snapshot, steps) {
275 if (!snapshot) return [{ text: 'No keel status yet.', tone: 'dim' }]
276 const lines = [{ text: `keel — ${snapshot.status} (${snapshot.project?.repo ?? '?'})`, tone: 'title' }]
277 const c = snapshot.current
278 if (c) {
279 lines.push({
280 text: `issue #${c.issue ?? '-'} · PR ${c.pull_request != null ? '#' + c.pull_request : '-'} · ${c.command ?? 'run'}`,
281 tone: 'plain',
282 })
283 for (const s of stepStates(steps, c.step)) {
284 if (s.state === 'done') lines.push({ text: ` ✓ ${s.id} ${s.name}`, tone: 'ok' })
285 else if (s.state === 'current') {
286 const why = c.wait_reason ? ` — ${c.wait_reason}` : ''
287 lines.push({ text: ` ▶ ${s.id} ${s.name}${why}`, tone: snapshot.status === 'interrupted' ? 'bad' : 'bar' })
288 } else lines.push({ text: ` · ${s.id} ${s.name}`, tone: 'plain' })
289 }
290 } else {
291 lines.push({ text: 'No active run.', tone: 'dim' })
292 }
293 const n = snapshot.history?.counts ?? {}
294 lines.push({
295 text: `history: shipped ${n.shipped ?? 0} · blocked ${n.blocked ?? 0} · deferred ${n.deferred ?? 0} · skipped ${n.skipped ?? 0}`,
296 tone: 'plain',
297 })
298 lines.push({ text: `next: ${snapshot.next?.issue != null ? '#' + snapshot.next.issue : '-'}`, tone: 'plain' })
299 return lines
300}
301