A native-first, self-prompting development loop for Claude Code: labelled GitHub issues in, gated + independently reviewed PRs out. Code writer ≠ code…

Labelled GitHub issues in. Gated, independently reviewed PRs out.
<img alt="release v0.72.0" src="https://img.shields.io/badge/release-v0.72.0-8b5cf6?style=flat-square"> <img alt="Claude Code" src="https://img.shields.io/badge/host-Claude_Code-22d3ee?style=flat-square"> <img alt="code writer does not equal code reviewer" src="https://img.shields.io/badge/invariant-code_writer_%E2%89%A0_code_reviewer-a78bfa?style=flat-square"> <img alt="human merge by default" src="https://img.shields.io/badge/default-human_merge-f59e0b?style=flat-square">
Autoloop is a development loop that runs inside Claude Code. You label a small issue loop-ready; it plans, has the plan reviewed, implements, has the code reviewed, runs your full test gate, and marks a pull request ready. Then it takes the next issue. You merge.
Every artifact is reviewed by a fresh process that did not write it, on a different model. The PR is evidence-bound: the reviewed diff, the gate, the remote head, and every CI check agree on one commit.
issue (loop-ready)
→ 01 premise check the issue is still true against the base
→ 02 plan fresh planner: boundary, invariants, failing-first tests
→ 03 review fresh reviewer challenges the plan once
→ 04 claim branch, claim commit, draft PR, frozen plan
→ 05 build fresh implementer, one commit per plan task
→ 06 simplify fresh pass, behavior frozen, tests untouched
→ 07 diff fresh reviewer reads the diff against invariants and checklist
→ 08 review fresh code review; fresh writers fix verified findings
→ 09 gate full objective gate on a clean tree
→ 10 publish bind the pushed head, mark the PR ready
→ 11 record one run record on the issue, with the unit's timing
ready PR → you merge
Pitcrew is the return path: when a human leaves review feedback, CI goes red, or the branch falls behind, it diagnoses, repairs with a fresh writer, re-reviews, re-gates, and hands the same PR back. Every cycle services Pitcrew work before new issues.
The loop takes the initiative. An obstacle is one of three things, and only the first stops a unit:
loop-ready), or a product value no source states. The loop blocks it with its question; you reply /answer <decision> on the issue, and the next run resumes that unit first.loop-repair issue under the unit's own loop-ready, and the loop takes it next.loop-decided), and keeps going. Reply /answer <what instead> to reverse it.Units that resolve themselves still do: an already delivered issue closes as loop-obsolete, a unit waiting on another issue or on time gets loop-waiting and comes back when the condition clears, and a usage limit moves a role to its fallback model or parks the run until it resets.
Every open question and every recent decision is collected in one pinned loop-digest issue, rewritten at each close or park.
Each step runs on its own model, reasoning effort and fallback, set in ~/.claude/autoloop/config.json (created with these defaults when missing) and overridable per project in .autoloop/config.json under models:
| Step | Role | Model | Effort | Fallback |
|---|---|---|---|---|
| 02 plan | plan | astra[1m] | high | opus[1m] |
| 03 plan review | plan-review | fable[1m] | high | sonnet[1m] |
| 05 implement | implement | opus[1m] | high | fable[1m] |
| 06 simplify | simplify | fable[1m] | high | opus[1m] |
| 07 diff review | diff-review | astra[1m] | high | sonnet[1m] |
| 08 code / doubt review | code-review, doubt-review | astra[1m] | high | sonnet[1m] |
| 08 fixes | fix | opus[1m] | high | fable[1m] |
These are family aliases, not fixed model releases. [1m] selects the context window. The serving CLI or gateway resolves each alias, so its model version can change without an Autoloop release. astra and sol need a gateway that supports those names; Sol is configurable but has no default role assignment.
Existing global files are never overwritten by a plugin update. Edit their models entries to adopt these defaults; project overrides still take precedence. Omitted effort inherits from the lower-precedence table. Explicit null uses the engine default. A project's models needs Autoloop 0.65.0 or later because older releases refuse the key.
Prime pins the configured selectors, not the upstream versions behind aliases. Configuration edits take effect at the next prime, not in an already running process. A reviewer must not share its writer's model, including fallbacks. An alias and a known same-family version can overlap, so that pair is refused; two explicit different versions remain supported.
Autoloop does not manage proxies. Dispatch inherits the session's environment, which determines available models. An unavailable model moves the step to its fallback, as do a reviewer's usage limit and repeated failures.
<img src="docs/assets/autoloop-guardrails.svg" alt="Four Autoloop guardrails: maintainer-authorized input, fresh independent review, exact-head evidence, and human merge by default with explicit acknowledged solo exceptions">
| Guardrail | What it means |
|---|---|
| Trusted input | Only a maintainer applies loop-ready; the loop never does. Editing the issue afterwards revokes it. |
| Independent review | Nothing is reviewed by the process that wrote it. Plans get one fresh review; code and every fix get fresh review. |
| Exact evidence | Review, gate, remote head, and every CI check and status agree on one SHA. Red or pending blocks delivery. |
| Human merge | manual is the default. The ratified and auto policies are for acknowledged solo repositories only. |
Issue bodies, specs, PRs, and review comments are data, not instructions. They describe work; they cannot widen permissions, change policy, or authorize protected changes.
You need gh authenticated for the target repository, a POSIX shell, and one objective full gate command (tests, lint, build — ideally sandboxed with no credentials or network).
/plugin marketplace add fabioneves/autoloop
/plugin install autoloop@autoloop
/autoloop:setup
Step models, efforts and fallbacks are configuration; see Models.
The plugin bundles agent-skills. If you already have it installed from its own marketplace, keep either copy.
.autoloop/config.json (settings, overrides only) and .autoloop/STATE.md (your policy prose), and seeds docs/agentic/ARCH.md and LESSONS.md. That is all a repository carries: every tool and hook runs from the plugin, so a plugin update reaches every repository at its next session with no setup step. A repository set up by an earlier version converts once with setup's devendor, in its own PR.shape skill.loop-ready last./autoloop:dev and tell it to take ONE issue and stop. /loop 30m /goal <the stop condition in .autoloop/STATE.md>
A bare /autoloop:dev drains the whole eligible queue.
| Skill | Purpose |
|---|---|
setup | Init, devendor an earlier install, change config, or run the read-only doctor. |
shape | Turn a spec or description into PR-sized issues, or lint one. Never labels. |
queue-trace | Reconcile a spec against the issue queue. Read-only. |
dev | One loop-ready issue → one ready PR. |
pitcrew | Feedback, red CI, or conflict → the same PR, repaired and re-reviewed. |
lean-code | Source stays lean; rationale lives in commits and PRs. |
codebase-design | Deep-module and seam vocabulary for planners and reviewers. |
In a session that owns a run, the plugin draws autoloop's own workplace inside Claude Code.
#N · PR #M, role, round, model), the work (active, waiting, needs you) and the proxy (● ON · n requests, ◐ UP · no status when the loopback proxy answers without a status endpoint, ○ OFF when it does not answer), a phase row under a unit in flight, and a highlighted needs-you strip. While the pane is open the band is one row./autoloop, or opened unasked once per session under the fullscreen renderer) has four tabs, digits 1..4: Run (now, timeline of the unit's steps with models and times, needs you, waiting, delivered, proxy requests), Live (the stream of any running dispatch, a..e to pick one, f to follow, newest at the bottom), Units (every unit of the run with its PR, disposition and last step) and Log (the autoloop log, every event with its timestamp, newest first, with a filter row). The log is the loop's, apart from the agent's transcript.p pauses and resumes the loop; x cancels the shown dispatch after a y; an input under a needs-you row sends an answer; merge and authorize buttons sit on a delivered pull request when the loop's gate may act. Each control is one file the loop consumes (see Events and control) and one queued prompt, autoloop: control, that wakes a parked session. A refused control is toasted with its reason./autoloop draws the card in the transcript.Hotkeys work while the pane or the band holds the keyboard (ctrl+x tab, or a click); from the prompt they type. The pane docks beside the transcript under the fullscreen renderer (/tui fullscreen or CLAUDE_CODE_NO_FLICKER=1) and opens inline above the prompt otherwise. The mod reads one projection, node <plugin-tools>/status.mjs --json --session <id>, the live files it names, and the proxy's GET /status; it never calls GitHub and never writes a file: a control goes through status.mjs --control. A session that owns no run sees nothing. A project turns it off with "ui": { "status": false } in .autoloop/config.json.
Every change of the loop's state is one line appended to .git/autoloop/events.jsonl: a run start or end, a step with its round and model, a dispatch start (with its live file and pid) and end, a decision, a wait, a resume, a block, a delivery, a merge, a label, a pause. The projection reads it to show the worked unit's steps, the run's log, and the running dispatches. The operator's controls are files under .git/autoloop/control/: pause, unpause, answer, merge, label (applied by prime at its start; a refused one lands in control/refused/ with its reason) and cancel (applied by the dispatch waiter: the dispatch ends as died). While paused, prime reports eligible: [] and paused: true. A step announced with --pr <M> names the pull request beside its issue on every later ribbon. The contract is docs/specs/SPEC-events.md.
v0.72.0 uses schema 0.28.0. Settings live in .autoloop/config.json, overrides only — plugin defaults fill the rest: version, baseBranch, gate, merge, tracker, review, and the optional protectedPaths and ui. The repository owns it; plugin updates never overwrite it.
protectedPaths lists the repository's own human-authorization globs (* and ** only), added to the built-in families. The plugin protects only secret and credential paths (.env*, key material, a path segment that is the word secret, credential, token, or crypt; one rule, shared with the command guard), .github/, and the loop's control roots (.autoloop, .claude, .codex, .opencode, .agents, .githooks). Documentation, dependency manifests, lockfiles, build configs, container files, and data are ordinary files; list them in protectedPaths when the repository wants them protected. A dependency change is the one ordinary-file exception: a package.json that gains a dependency or an install script, or a lockfile that adds a package, is refused at the merge gate as human:authorize. A non-manual policy also sets merge.loopLogin, and ratified may set merge.reversiblePaths (default ["docs/"]). The review checklist is .autoloop/checklist.md when the repository keeps one, else the plugin's.
merge.policy | Behavior |
|---|---|
manual | Default. The loop marks the PR ready; a human merges. |
ratified | Solo only. Merges on a trusted human risk label, or when every changed path is on the reversible allowlist; a loop repair counts as approved through its parent's loop-ready. |
auto | Solo only. Merges a fully proven loop PR outside protected paths; a loop repair merges on its parent's loop-ready. |
Both non-manual policies require merge.unverifiedInvocationAcknowledged: true and merge.soloOperatorAcknowledged: true: you accept that no host can prove a human started the run, and that one login cannot separate writer from approver. A repair stops merging when its parent loses or changes loop-ready, is edited after it, is blocked, or is closed as not planned; a parent closed as completed after delivery keeps authorizing its open repairs. To take over a revoked repair, label it loop-ready: it is then an ordinary issue. Exact-head compare-and-swap merge, the green-checks floor, ownership binding, protected paths, the pre-merge record, and the loop-ready kill switch stay enforced regardless.
| Where | What |
|---|---|
.autoloop/config.json | Settings, overrides only. |
.autoloop/STATE.md | Policy prose: mission, invariants, protected ground. Every run reads it. |
.autoloop/checklist.md | Optional review criteria; the plugin's checklist when absent. |
docs/agentic/ARCH.md, docs/agentic/LESSONS.md | Architecture map and durable memory, yours to edit. |
| The plugin | Every tool, role brief and hook (hooks/hooks.json). A hook acts only for the session that owns a run, from its prime to /clear or exit; --close-run ends all but the command and edit guards. Every other session sees no hook effect. |
.git/autoloop/, /tmp/autoloop-* | Local run state and scratch: step records, the event file, the control directory. Never committed. |
| Issue labels and comments | loop-ready, loop-started, loop:NN-* step labels, loop-delivered / loop-blocked / loop-waiting / loop-obsolete / loop-decided / loop-repair, the loop-digest issue; lifecycle markers, the frozen plan, and the run record. |
v0.72.0 dispatches every role through one call:
node <plugin-tools>/dispatch.mjs --role <plan|plan-review|implement|simplify|diff-review|code-review|doubt-review|fix> --prompt-file <path>
Each role runs as a fresh, fixed-posture process. Reviewers are read-only and cannot be widened by a prompt. Untrusted text travels in files, never shell source. The command guard refuses only an irreversible or unauthorized act, and NEVER: a merge (gh pr merge, the REST merge endpoint, a GraphQL merge mutation, or a hand-posted commit status); a commit on or a push to the base branch except through a pull request; a GitHub API commit or ref mutation (contents, git/* writes, createCommitOnBranch, updateRef, createRef, deleteRef); git push --force/-f (--force-with-lease is allowed); a release-tag push or a GitHub release mutation; gh workflow run|enable or an actions dispatch endpoint; raw HTTP to the API with a write method; a branch-protection or ruleset mutation; git add or git commit naming a secret path; a Co-Authored-By trailer; AskUserQuestion while a run is live; a branch switch while a reviewer dispatch reads the checkout; a prime command that does anything else; and a hook payload it cannot read, for the session that owns a run. The rules also apply to the text a command runs: an in-command git or gh alias, a function, case or trap body, and a shell fed by a heredoc or a literal echo; a hidden alias is refused. An inline interpreter's program (python3 -c, node -e) is not judged. Every other command is allowed; step.mjs alone swaps step labels and keeps the ladder rules. Protected paths stop for human:authorize. Dispatch failures are typed and recorded. A read-only role retries a transient failure a bounded number of times and moves to its configured fallback on a usage limit; any role moves to it when its model is unavailable; a writer never retries blindly, and there is no "skip review" mode. On restart the loop rebuilds state from Git and GitHub, adopts only proven orphans, and finishes or blocks them before taking new work. With nothing to do, it stops rather than polls.
The command guard is not a sandbox and not a secrets boundary: it keeps a secret path out of a commit, and reads nothing else. Branch, tag, and release protection remain your server-side rules; give the loop a repository-scoped token without admin rights.
Root VERSION is canonical; manifests, the badge, the changelog heading, skill banners, and the annotated v<VERSION> tag must agree — node tools/release-verify.mjs checks that, and CI enforces it on tags.
See the contribution guide, security policy, changelog, and MIT License.
hooks/status/register.tsx 372 lines1// The status mod (docs/specs/SPEC-status-mod.md, docs/specs/SPEC-central.md):
2// a band above the prompt and, on request, a pane for the session that owns
3// a run. This module holds the hooks; the reads (the projection, the proxy,
4// the live tails) are in read.ts, the stream's renderer in render.ts, the
5// band's text in view.ts, the pane's tabs in central.ts, the drawings in
6// draw.tsx, the palette, glyphs and element builders in kit.ts. The band
7// draws only from a projection that is owned and enabled, so every other
8// session sees no change; the pane opens only on /autoloop.
9import type { EngineInterface, Register, Timer } from 'claude-code'
10
11import type { StatusProjection, StatusState, StatusStream, StatusTick } from '../../types'
12import { LOG_LENGTH, PANE_ID, PANE_TITLE, autoOpenDue, liveRoom, liveScroll, pendingOffered, shownDispatch } from './central'
13import type { ControlHost } from './control'
14import { refusedText, sendControl } from './control'
15import { bandTree, paneTree } from './draw'
16import { empty, plain } from './kit'
17import type { ProxyFetch, Runner } from './read'
18import {
19 LIVE_PERIOD_MS, STREAM_LINES_MAX, isDrawable, proxyBase, pruneStreams, readProjection, readProxy, runningDispatches, tailLive,
20} from './read'
21import { blockedUnits, spinnerWord, toastText } from './view'
22
23const STATUS = { plugin: 'autoloop', key: 'status' } as const
24// The tick's clock under its own key: written every tick, so `status` is
25// written (and its readers drawn again) only when a read changed it.
26const TICK = { plugin: 'autoloop', key: 'tick' } as const
27const PERIOD_MS = 3000
28/** A tick past this is reported once: the period is not raised by itself (tasks/plan-status-mod.md, risks). */
29const SLOW_TICK_MS = 500
30const EMPTY: StatusState = {
31 last: null, proxy: null, seenBlocked: [], seenRefused: [], pending: null, paneOpen: false, autoOpened: false, streams: {},
32 tab: 'run', logFilter: 'all', selected: null, follow: true, liveOffset: 0,
33}
34const NO_TICK: StatusTick = { lastOkAtMs: null, lastMs: null }
35/** The mod's state as last written, EMPTY before the first tick; a field an older module never wrote takes EMPTY's. */
36const readState = async ($: EngineInterface): Promise<StatusState> => ({ ...EMPTY, ...(await $.state.get(STATUS)).value })
37const readTick = async ($: EngineInterface): Promise<StatusTick> => ({ ...NO_TICK, ...(await $.state.get(TICK)).value })
38/**
39 * One write of the mod's state: `change` sees the state as last written
40 * (EMPTY's fields under it), and the fields it returns replace; null leaves
41 * the state as it stands, with no write and so no redraw. Written with the
42 * version read, again on a miss, so two handlers before a redraw both land.
43 */
44async function editState($: EngineInterface, change: (state: StatusState) => Partial<StatusState> | null): Promise<StatusState> {
45 for (;;) {
46 const held = await $.state.get(STATUS)
47 const state = { ...EMPTY, ...held.value }
48 const diff = change(state)
49 if (diff === null) return state
50 const next = { ...state, ...diff }
51 if ((await $.state.set(STATUS, next, { ifVersion: held.version })).isSet) return next
52 }
53}
54/** Whether the pane is open, written where the pane opens or closes; the band reads it to draw one row. */
55const setPaneOpen = ($: EngineInterface, paneOpen: boolean) => editState($, (state) => (state.paneOpen === paneOpen ? null : { paneOpen }))
56/** A toast: the text passes `plain`, since a needs-you text or a producer's error is not the mod's own. */
57const toast = ($: EngineInterface, text: string) => $.ui.toast(plain(text))
58/** The host beneath a control (control.ts holds no `$`): the producer as a child, the toasts, the prompt queue. */
59const controlHost = ($: EngineInterface): ControlHost => ({
60 run: (argv, init) => $.process.run(argv, init),
61 toast: (text) => toast($, text),
62 submit: (text) => $.prompt.submit({ text }),
63 pluginRoot: $.plugin.root,
64 sessionId: () => $.session.id(),
65})
66/** Why the pane and the command stay quiet while the projection is not drawable: not the owner, or the band turned off. */
67function quietReason(projection: StatusProjection | null): string {
68 return projection === null || projection.owned !== true
69 ? 'autoloop: this session owns no run'
70 : 'autoloop: the status band is off (ui.status is false in .autoloop/config.json)'
71}
72/**
73 * Whether the host draws the pane now, from its own record of this
74 * plugin's panes (`$.ui.panes()`): true or false when it answers, null
75 * when it does not offer the list, so the state's flag stands.
76 */
77async function panePlaced($: EngineInterface): Promise<boolean | null> {
78 try {
79 return (await $.ui.panes()).some((pane) => pane.id === PANE_ID && pane.isPlaced)
80 } catch {
81 return null
82 }
83}
84const same = (left: unknown, right: unknown): boolean => JSON.stringify(left) === JSON.stringify(right)
85
86export const register: Register = (on) => {
87 // Module state, not `$.state`: a reload drops the old timer with it.
88 let busy = false
89 let slowReported = false
90 let faultReported = false
91 let timer: Timer | null = null
92 // What the band's last draw said of the surface: whether it docks a pane; null until a surface said.
93 let isFullscreen: boolean | null = null
94 // The session's generation: bumped by session.start and session.end, so
95 // a tick in flight across either writes nothing of an ended session back.
96 let generation = 0
97 // The live tails: a 1 s timer while a dispatch runs, and the last chunk
98 // tail printed per dispatch, so growth is the chunk differing. The chunks
99 // are the tick's working memory, not a drawing's source: a reload drops
100 // them, and the next tick reads every file as grown once.
101 let liveBusy = false
102 let liveTimer: Timer | null = null
103 const chunks = new Map<string, string>()
104
105 on('session.start', async ($, e, next) => {
106 generation += 1
107 await $.command.register({ name: 'autoloop', description: 'Show the state of the run this session owns.' })
108 // The proxy is optional: without the variable the cell does not exist
109 // and nothing is fetched; a base that is not a loopback URL counts as
110 // unset. `claudeproxy` sets it before the session starts.
111 const base = proxyBase(await $.env.get('ANTHROPIC_BASE_URL'))
112 const run: Runner = (argv, init) => $.process.run(argv, init)
113 // $.http.fetch has no timeout of its own: the read races a clock wait,
114 // which the settled fetch then aborts.
115 const fetch: ProxyFetch = (url, { timeoutMs }) => {
116 const stop = new AbortController()
117 const timeout = $.clock.sleep(timeoutMs, { signal: stop.signal }).then(() => {
118 throw new Error(`no answer within ${timeoutMs} ms`)
119 })
120 return Promise.race([$.http.fetch(url), timeout]).finally(() => stop.abort())
121 }
122 // One live tick: tail every running dispatch's file, render, keep the
123 // last 200 lines, and stamp growth. A tail that fails keeps the last
124 // lines; a tick still running is skipped by the next second; a tick
125 // that sees no growth and no dispatch come or go writes nothing.
126 const liveTick = async () => {
127 if (liveBusy) return
128 liveBusy = true
129 const started = generation
130 try {
131 const running = runningDispatches((await readState($)).last)
132 if (running.length === 0) return
133 const nowMs = await $.clock.now()
134 const tailed = await Promise.all(running.map(async (dispatch) => ({ id: dispatch.dispatch, tail: await tailLive(run, dispatch.liveFile) })))
135 if (started !== generation) return
136 const grown: { id: string; lines: string[]; grew: boolean }[] = []
137 for (const { id, tail } of tailed) {
138 if (tail === null) continue
139 grown.push({ id, grew: chunks.get(id) !== tail.chunk, lines: tail.lines.slice(-STREAM_LINES_MAX) })
140 chunks.set(id, tail.chunk)
141 }
142 await editState($, (state) => {
143 const streams = { ...state.streams }
144 let changed = false
145 for (const { id, lines, grew } of grown) {
146 const before = state.streams[id]
147 if (before !== undefined && !grew) continue
148 changed = true
149 streams[id] = { lines, lastGrewAtMs: grew || before === undefined ? nowMs : before.lastGrewAtMs }
150 }
151 const kept = pruneStreams(streams, running.map((dispatch) => dispatch.dispatch))
152 return changed || !same(Object.keys(kept), Object.keys(state.streams)) ? { streams: kept } : null
153 })
154 } catch (error) {
155 if (!faultReported) {
156 faultReported = true
157 $.ui.log(`autoloop status: a live tick failed (${error instanceof Error ? error.message : String(error)})`)
158 }
159 } finally {
160 liveBusy = false
161 }
162 }
163 // The 1 s timer runs only while a dispatch runs: started by the 3 s tick
164 // that sees one, cancelled by the one that sees none.
165 const syncLive = (projection: StatusProjection | null) => {
166 const running = runningDispatches(projection).length > 0
167 if (running && liveTimer === null) {
168 liveTimer = $.clock.every(LIVE_PERIOD_MS, () => {
169 void liveTick()
170 })
171 } else if (!running && liveTimer !== null) {
172 liveTimer.cancel()
173 liveTimer = null
174 }
175 }
176 const tick = async () => {
177 if (busy) return
178 busy = true
179 const started = generation
180 try {
181 const startedAtMs = await $.clock.now()
182 // Ownership is the session's: the producer matches this id against
183 // the run latch of the Claude process it runs under. Read on every
184 // tick, since after a /clear the process goes on under a new id with
185 // no session.start.
186 const sessionId = await $.session.id()
187 // The Log tab is the whole log: 500 entries while it shows, the
188 // producer's default otherwise.
189 const logLength = (await readState($)).tab === 'log' ? LOG_LENGTH : null
190 const [read, proxy, placed] = await Promise.all([
191 readProjection(run, $.plugin.root, sessionId, logLength),
192 base === null ? null : readProxy(fetch, base),
193 panePlaced($),
194 ])
195 const nowMs = await $.clock.now()
196 const lastMs = nowMs - startedAtMs
197 // The session ended while the reads ran: nothing of it is written back.
198 if (started !== generation) return
199 // A unit blocked for the first time in this session is toasted once:
200 // the seen list lives in $.state, so a reload does not toast it again.
201 // The write goes through `editState`, so a pane flag written meanwhile
202 // is kept, and a tick whose reads changed nothing writes nothing but
203 // its clock. A failed projection read keeps the last projection;
204 // lastOkAtMs not moving is what marks it stale. The proxy read is
205 // taken as it came: UP and OFF are facts.
206 // A control the loop refused is toasted once the same way, by the
207 // control file's name (SPEC-central item 16). A pending control the
208 // run outgrew (its dispatch ended) is dropped, so its y sends nothing.
209 let fresh: ReturnType<typeof blockedUnits> = []
210 let refused: NonNullable<StatusProjection['controls']>['refused'] = []
211 await editState($, (state) => {
212 const paneOpen = placed ?? state.paneOpen
213 if (!read.ok) return same(proxy, state.proxy) && paneOpen === state.paneOpen ? null : { proxy, paneOpen }
214 if (same(read.projection, state.last) && same(proxy, state.proxy) && paneOpen === state.paneOpen) return null
215 const drawable = isDrawable(read.projection)
216 fresh = drawable ? blockedUnits(read.projection).filter((unit) => !state.seenBlocked.includes(unit.issue)) : []
217 refused = drawable ? (read.projection.controls?.refused ?? []).filter((entry) => !state.seenRefused.includes(entry.name)) : []
218 const pending = state.pending !== null && drawable && pendingOffered(read.projection, state.pending) ? state.pending : null
219 return {
220 last: read.projection, proxy, paneOpen, pending,
221 seenBlocked: [...state.seenBlocked, ...fresh.map((unit) => unit.issue)], seenRefused: [...state.seenRefused, ...refused.map((entry) => entry.name)],
222 }
223 })
224 await $.state.set(TICK, { lastOkAtMs: read.ok ? nowMs : (await readTick($)).lastOkAtMs, lastMs })
225 for (const unit of fresh) toast($, toastText({ issue: unit.issue, text: unit.needsYou.text }))
226 for (const entry of refused) toast($, refusedText(entry))
227 // The pane opens unasked once per session when a tick first sees an
228 // owned run under a surface that docks a pane (item 13), never with
229 // focus. The host holds the width rule: under its floor the open
230 // waits undrawn (`isPlaced` false: the pane is not open to the band),
231 // and the band keeps its card until the pane draws.
232 if (read.ok && isDrawable(read.projection) && autoOpenDue(await readState($), isFullscreen)) {
233 await editState($, () => ({ autoOpened: true }))
234 const opened = await $.ui.open({ id: PANE_ID, title: PANE_TITLE })
235 if (started !== generation) return
236 await setPaneOpen($, opened.isPlaced)
237 }
238 if (read.ok) syncLive(isDrawable(read.projection) ? read.projection : null)
239 // ui.status false turns the mod off for the project: the tick stops
240 // until the next session.start (spec item 6). An unowned projection
241 // keeps it, since the session may come to own a run.
242 if (read.ok && read.projection.owned === true && read.projection.enabled === false) timer?.cancel()
243 if (lastMs > SLOW_TICK_MS && !slowReported) {
244 slowReported = true
245 $.ui.log(`autoloop status: a tick took ${lastMs} ms (period ${PERIOD_MS} ms)`)
246 }
247 } catch (error) {
248 // A host call that rejects (the clock, the state) keeps the last
249 // state; the next period tries again. Reported once, not every 3 s.
250 if (!faultReported) {
251 faultReported = true
252 $.ui.log(`autoloop status: a tick failed (${error instanceof Error ? error.message : String(error)})`)
253 }
254 } finally {
255 busy = false
256 }
257 }
258 timer?.cancel()
259 liveTimer?.cancel()
260 liveTimer = null
261 timer = $.clock.every(PERIOD_MS, () => {
262 void tick()
263 })
264 return next(e)
265 })
266
267 // The conversation ends (a /clear goes on under a new session id with no
268 // session.start): the tick stops and the last projection goes, so the band
269 // never draws a run this conversation does not own. A tick still in flight
270 // sees the generation move and writes nothing back.
271 on('session.end', async ($, e, next) => {
272 generation += 1
273 timer?.cancel()
274 timer = null
275 liveTimer?.cancel()
276 liveTimer = null
277 chunks.clear()
278 await $.state.set(STATUS, EMPTY)
279 await $.state.set(TICK, NO_TICK)
280 return next(e)
281 })
282
283 // The pane opens on the person's ask alone. While it is open the band
284 // draws one row, so the facts are not drawn twice (SPEC-central item 7).
285 on('command.run', { command: 'autoloop' }, async ($, e) => {
286 const args = e.args.trim()
287 if (args === 'close') {
288 await $.ui.close({ id: PANE_ID })
289 await setPaneOpen($, false)
290 return { text: 'autoloop: status pane closed' }
291 }
292 if (args !== '') return { text: `autoloop: unknown argument "${args}"; use /autoloop or /autoloop close` }
293 const { last } = await readState($)
294 if (!isDrawable(last)) return { text: quietReason(last) }
295 const opened = await $.ui.open({ id: PANE_ID, title: PANE_TITLE })
296 await setPaneOpen($, opened.isPlaced)
297 return { text: opened.isPlaced ? 'autoloop: status pane opened' : `autoloop: status pane waits (${opened.reason})` }
298 })
299
300 // The person's own close (the close mark, ctrl+x x) reaches the mod here;
301 // the literal id, so `claude plugin validate` lists it.
302 on('ui.close', { id: 'autoloop' }, async ($, e, next) => {
303 const closed = await next(e)
304 await setPaneOpen($, false)
305 return closed
306 })
307
308 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
309 // Whether the surface docks a pane, for the unasked open (item 13):
310 // read on every draw, since a remote surface reports it once attached.
311 isFullscreen = e.viewport?.isFullscreen ?? null
312 if (e.props.hasSurvey) return next(e)
313 const state = await readState($)
314 if (!isDrawable(state.last)) return next(e)
315 return bandTree($.ui.resolve(e), { ...state, ...(await readTick($)), last: state.last }, await $.clock.now(), e.props.bodyColumns, state.paneOpen)
316 })
317
318 // The spinner's word while the session owns a run with a unit (item 4):
319 // the engine's suffix and time stay; without a unit the word is the engine's.
320 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
321 const state = await readState($)
322 const word = isDrawable(state.last) ? spinnerWord(state.last) : null
323 return word === null ? next(e) : next({ ...e, props: { ...e.props, word } })
324 })
325
326 // The /autoloop output row draws the band's card (item 5); the command's
327 // other answers (close, an unknown argument, a session that owns no run)
328 // keep their text. The transcript row has no body width: the viewport's.
329 on('ui.render', { component: 'CommandOutput', props: { command: 'autoloop' } }, async ($, e, next) => {
330 if (e.props.args.trim() !== '' || e.props.isErrored) return next(e)
331 const state = await readState($)
332 if (!isDrawable(state.last)) return next(e)
333 return bandTree($.ui.resolve(e), { ...state, ...(await readTick($)), last: state.last }, await $.clock.now(), e.viewport?.columns ?? 80, false)
334 })
335
336 // The person's scroll over the pane while the Live tab shows moves the
337 // window over the lines and turns follow off; the mod draws the window
338 // itself, so the engine's own is left where it is. The other tabs scroll
339 // as a pane does. (The test engine's `$.ui.scroll` raises `ui.scroll` with
340 // the input given, so central.test.ts drives this hook with a person's
341 // scroll.)
342 on('ui.scroll', { requestId: 'autoloop' }, async ($, e, next) => {
343 const state = await readState($)
344 if (state.tab !== 'live' || !isDrawable(state.last)) return next(e)
345 const id = shownDispatch(state.last, state.streams, state.selected)
346 const lineCount = id === null ? 0 : state.streams[id]?.lines.length ?? 0
347 const moved = liveScroll(state, e.by, lineCount, liveRoom(e.bodyRows, state.pending !== null))
348 await editState($, () => moved)
349 return {}
350 })
351
352 // The pane (SPEC-central items 8 to 16), drawn by draw.tsx from the state
353 // and the handlers given here. The literal, so `claude plugin validate`
354 // lists the pane's id (central.test.ts holds PANE_ID to it). The tick's
355 // clock is read too, so the elapsed times move with it.
356 on('ui.render', { component: 'Pane', requestId: 'autoloop' }, async ($, e) => {
357 const kit = $.ui.resolve(e)
358 const state = await readState($)
359 await readTick($)
360 if (!isDrawable(state.last)) return empty(kit, quietReason(state.last))
361 return paneTree(kit, { ...state, last: state.last }, {
362 nowMs: await $.clock.now(),
363 sessionId: await $.session.id(),
364 bodyColumns: e.props.bodyColumns,
365 bodyRows: e.props.scroll.bodyRows,
366 read: () => readState($),
367 edit: (change) => editState($, change),
368 send: (control) => sendControl(controlHost($), control),
369 })
370 })
371}
372hooks/status/central.ts 424 lines1// The central pane (docs/specs/SPEC-central.md, items 8 to 14): pure
2// builders from the projection, the proxy read, the streams and the state
3// to row models per tab. They hold no `$`; register.tsx draws what they
4// return with the kit. Each row hands back its glyph apart from its text,
5// so status is carried by the glyph and the words, never by color alone.
6import type { ProxyRead, StatusEvent, StatusPending, StatusProjection, StatusState, StatusStream, StatusUnit, StatusWait, StatusWorkedUnit } from '../../types'
7import { GLYPH } from './kit'
8import { isQuiet, runningDispatches } from './read'
9import { answerText, blockedUnits, duration, loopColumn, parts, ref, requestedName, roundLabel, shortModel, truncate } from './view'
10
11export const PANE_ID = 'autoloop'
12export const PANE_TITLE = 'autoloop'
13
14export type Tab = 'run' | 'live' | 'units' | 'log'
15export const TABS: readonly { tab: Tab; label: string; hotkey: string }[] = Object.freeze([
16 { tab: 'run', label: 'Run', hotkey: '1' },
17 { tab: 'live', label: 'Live', hotkey: '2' },
18 { tab: 'units', label: 'Units', hotkey: '3' },
19 { tab: 'log', label: 'Log', hotkey: '4' },
20])
21
22/** Under this many body columns the Run tab drops the Proxy section and the Timeline keeps names only (spec item 14). */
23export const NARROW_COLUMNS = 60
24/** The log length asked for while the Log tab is shown; the producer's default otherwise. */
25export const LOG_LENGTH = 500
26/** A unit's title is cut past this many characters on the Units tab. */
27const TITLE_MAX = 40
28
29/**
30 * The control a row offers (item 16): a `cancel` button beside a running
31 * dispatch, `merge` or `authorize` on a unit with a PR (each asked y or n
32 * as a pending control), an `answer` input under a block or decide (Enter
33 * is its confirmation). draw.tsx draws the element and sends the control;
34 * the row holds only the facts.
35 */
36export type RowAction = StatusPending | { kind: 'answer'; issue: number }
37
38export type Row = {
39 /** Drawn dim before the glyph: the Log tab's time. */
40 lead: string | null
41 /** The status glyph; '' for a row without one. */
42 glyph: string
43 text: string
44 /** Drawn dim at the right edge: `answer on #417`, `this session`. */
45 right: string | null
46 /** A dim row: a blocked unit's needs-you text under its row. */
47 dim: boolean
48 action: RowAction | null
49}
50
51export type Section = { title: string; rows: Row[] }
52
53const row = (glyph: string, text: string, right: string | null = null, dim = false, action: RowAction | null = null): Row =>
54 ({ lead: null, glyph, text, right, dim, action })
55
56/** The header's status line: the Loop cell's text, `paused` after it while the run is paused. */
57export function statusLine(projection: StatusProjection): string {
58 return parts([loopColumn(projection, true).head, projection.run?.paused === true ? 'paused' : null])
59}
60
61/**
62 * Whether a tick that sees an owned run opens the pane unasked (item 13):
63 * once per session, only where the surface said it docks a pane
64 * (`isFullscreen` true; false or unknown never), and not while it is open.
65 * The width rule is the host's: an open under the floor waits undrawn.
66 */
67export function autoOpenDue(held: Pick<StatusState, 'autoOpened' | 'paneOpen'>, isFullscreen: boolean | null): boolean {
68 return !held.autoOpened && !held.paneOpen && isFullscreen === true
69}
70
71/** The color key a glyph wears, so the drawing and the tests agree on it. */
72export function glyphTone(glyph: string): 'good' | 'warn' | 'crit' | 'ink3' | 'accent' {
73 if (glyph === GLYPH.needsYou || glyph === GLYPH.failed) return 'crit'
74 if (glyph === GLYPH.waiting || glyph === GLYPH.paused) return 'warn'
75 if (glyph === GLYPH.notYet || glyph === GLYPH.dot) return 'ink3'
76 if (glyph === GLYPH.brand || glyph === GLYPH.play) return 'accent'
77 return 'good'
78}
79
80// The Run tab (item 9).
81
82/** The `--log` clock's `HH:MM` of an ISO instant, in UTC as the producer prints it. */
83const utcMinute = (iso: string): string => `${iso.slice(11, 16)} UTC`
84
85/** What the Now section says without a unit: the next selection when the run is parked until a known time. */
86export function noUnitText(projection: StatusProjection): string {
87 const until = projection.run?.parkedUntil ?? null
88 return until === null ? 'no unit in flight' : `no unit in flight · next selection at ${utcMinute(until)}`
89}
90
91/** The worked unit's elapsed: since it started while in flight, else since it returned. */
92function unitElapsed(unit: StatusWorkedUnit, nowMs: number): string | null {
93 if (unit.inFlight) return duration(nowMs - unit.startedAtMs)
94 return unit.returnedAtMs === null ? null : `returned ${duration(nowMs - unit.returnedAtMs)} ago`
95}
96
97/** One row per running dispatch: `▶ #517 implement · OPUS 5.5 · 4m 0s`, with its cancel (item 16). */
98export function dispatchRows(projection: StatusProjection, nowMs: number): Row[] {
99 return runningDispatches(projection).map((dispatch) => {
100 const name = dispatchName(dispatch, dispatch.dispatch)
101 return row(GLYPH.play, parts([name, dispatch.model === null ? null : shortModel(dispatch.model), duration(nowMs - dispatch.startedAtMs)]), null, false,
102 { kind: 'cancel', dispatch: dispatch.dispatch, issue: dispatch.issue, name })
103 })
104}
105
106/** Now: the phase, unit, PR and title on one row; the role, round, model and elapsed on the next; then the running dispatches. */
107export function nowSection(projection: StatusProjection, nowMs: number): Section {
108 const unit = projection.unit ?? null
109 const dispatches = dispatchRows(projection, nowMs)
110 if (unit === null) return { title: 'Now', rows: dispatches }
111 const loop = loopColumn(projection, false)
112 return { title: 'Now', rows: [row(GLYPH.active, loop.head), row('', parts([loop.detail, unitElapsed(unit, nowMs)])), ...dispatches] }
113}
114
115/** `08-code-review` as `code-review`: the step's name less its order. */
116export const stepName = (step: string): string => step.replace(/^\d+-/u, '')
117
118/** Timeline: one row per step of the worked unit, `✓` returned or `●` in flight; names only when narrow. */
119export function timelineSection(projection: StatusProjection, nowMs: number, narrow: boolean): Section {
120 const steps = projection.unit?.steps ?? []
121 return {
122 title: 'Timeline',
123 rows: steps.map((step) => {
124 const returned = step.returnedAtMs !== null
125 const glyph = returned ? GLYPH.done : GLYPH.active
126 const name = stepName(step.step)
127 if (narrow) return row(glyph, name)
128 const spent = duration((step.returnedAtMs ?? nowMs) - step.startedAtMs)
129 return row(glyph, parts([name, roundLabel(step.round), spent, step.model === null ? null : shortModel(step.model)]))
130 }),
131 }
132}
133
134/**
135 * The control a unit with a PR offers (item 16): `merge` only when the
136 * loop's own gate may merge it (delivered, no human to answer), `authorize`
137 * only when it carries `human:authorize`; nothing otherwise.
138 */
139export function prAction(unit: StatusUnit): StatusPending | null {
140 if (unit.pr === null) return null
141 if (unit.needsYou?.kind === 'authorize') return { kind: 'authorize', pr: unit.pr }
142 return unit.needsYou === null && unit.disposition === 'delivered' ? { kind: 'merge', pr: unit.pr } : null
143}
144
145/** Needs you: every unit a human must answer, oldest first, its text and what answers it: an answer input, or the authorize button. */
146export function needsYouSection(projection: StatusProjection): Section {
147 return {
148 title: 'Needs you',
149 rows: blockedUnits(projection).map((unit) => row(GLYPH.needsYou, parts([ref(unit.issue, unit.pr), unit.needsYou.text]), answerText(unit), false,
150 unit.needsYou.kind === 'authorize' ? prAction(unit) : { kind: 'answer', issue: unit.issue })),
151 }
152}
153
154/** The question a pending control asks: `cancel #517 implement?`, `merge PR #700?`, `authorize PR #701?`. */
155export function pendingQuestion(pending: StatusPending): string {
156 return pending.kind === 'cancel' ? `cancel ${pending.name}?` : `${pending.kind} PR #${pending.pr}?`
157}
158
159/**
160 * Whether the projection still offers a pending control: its dispatch
161 * still runs, or a unit still offers that merge or authorize. A pending
162 * control the run outgrew is dropped by the tick, so its y sends nothing.
163 */
164export function pendingOffered(projection: StatusProjection, pending: StatusPending): boolean {
165 if (pending.kind === 'cancel') return runningDispatches(projection).some((dispatch) => dispatch.dispatch === pending.dispatch)
166 return (projection.units ?? []).some((unit) => {
167 const action = prAction(unit)
168 return action !== null && action.kind === pending.kind && action.pr === pending.pr
169 })
170}
171
172/** What a unit waits for: `waits on #399`, `waits on PR #204 · src/a.mjs`, `waits until 10:50 UTC`, `waits on a green base`. */
173export function waitText(wait: StatusWait): string {
174 if (wait.kind === 'issue') return `waits on #${wait.number}`
175 if (wait.kind === 'pr') return parts([`waits on PR #${wait.number}`, wait.paths === null ? null : wait.paths.join(', ')])
176 if (wait.kind === 'base') return 'waits on a green base'
177 return wait.until === null ? 'waits on time' : `waits until ${utcMinute(wait.until)}`
178}
179
180export function waitingSection(projection: StatusProjection): Section {
181 return {
182 title: 'Waiting',
183 rows: (projection.units ?? []).filter((unit) => unit.disposition === 'waiting')
184 .map((unit) => row(GLYPH.waiting, unit.waitingOn === null ? parts([`#${unit.issue}`, unit.detail]) : `#${unit.issue} ${waitText(unit.waitingOn)}`)),
185 }
186}
187
188/** Delivered: `#N · PR #M · <detail>`, `merge pending` without a detail. */
189export function deliveredSection(projection: StatusProjection): Section {
190 return {
191 title: 'Delivered',
192 rows: (projection.units ?? []).filter((unit) => unit.disposition === 'delivered')
193 .map((unit) => row(GLYPH.done, parts([ref(unit.issue, unit.pr), unit.detail ?? 'merge pending']), null, false, prAction(unit))),
194 }
195}
196
197/** Proxy: one row per active request, requested → served (requested alone while the served model is null), elapsed, `this session`. */
198export function proxySection(proxy: ProxyRead | null, sessionId: string): Section {
199 const requests = proxy === null ? [] : proxy.requests
200 return {
201 title: 'Proxy',
202 rows: requests.map((request) => {
203 const requested = requestedName(request)
204 const models = request.model === null ? requested : `${requested} → ${shortModel(request.model)}`
205 return row(GLYPH.active, parts([models, request.elapsedMs === null ? null : duration(request.elapsedMs)]),
206 request.sessionId !== null && request.sessionId === sessionId ? 'this session' : null)
207 }),
208 }
209}
210
211export type RunView = { sections: Section[]; empty: string | null }
212
213/** The Run tab: the sections in the spec's order, those with rows only; `empty` is the Now section's text without a unit. */
214export function runTab(projection: StatusProjection, proxy: ProxyRead | null, sessionId: string, nowMs: number, bodyColumns: number): RunView {
215 const narrow = bodyColumns < NARROW_COLUMNS
216 const sections = [
217 nowSection(projection, nowMs), timelineSection(projection, nowMs, narrow), needsYouSection(projection), waitingSection(projection),
218 deliveredSection(projection), ...(narrow ? [] : [proxySection(proxy, sessionId)]),
219 ].filter((section) => section.rows.length > 0)
220 return { sections, empty: (projection.unit ?? null) === null ? noUnitText(projection) : null }
221}
222
223// The Units tab (item 11).
224
225const UNIT_GLYPH: Record<StatusUnit['disposition'], string> = {
226 active: GLYPH.active, waiting: GLYPH.waiting, blocked: GLYPH.needsYou, returned: GLYPH.done, delivered: GLYPH.done,
227}
228
229/** When a unit last changed: its newest log event, else the worked unit's start; null when the run recorded none. */
230export function lastChangeMs(projection: StatusProjection, issue: number): number | null {
231 const event = (projection.log ?? []).find((entry) => entry.issue === issue)
232 if (event !== undefined) return event.at
233 const unit = projection.unit ?? null
234 return unit !== null && unit.issue === issue ? unit.startedAtMs : null
235}
236
237/** The Units tab: one row per unit in the run's order, a blocked unit's needs-you text as a dim row under it. */
238export function unitsTab(projection: StatusProjection, nowMs: number): Row[] {
239 return (projection.units ?? []).flatMap((unit) => {
240 const changed = lastChangeMs(projection, unit.issue)
241 const detail = unit.detail === unit.disposition ? null : unit.detail
242 const main = row(UNIT_GLYPH[unit.disposition], parts([
243 ref(unit.issue, unit.pr), unit.title === null ? null : truncate(unit.title, TITLE_MAX), unit.disposition, detail,
244 changed === null ? null : duration(nowMs - changed),
245 ]))
246 return unit.needsYou === null ? [main] : [main, row('', unit.needsYou.text, null, true)]
247 })
248}
249
250// The Log tab (item 12).
251
252export type LogFilter = 'all' | 'steps' | 'dispatches' | 'needsYou' | 'controls'
253export const LOG_FILTERS: readonly { filter: LogFilter; label: string }[] = Object.freeze([
254 { filter: 'all', label: 'all' },
255 { filter: 'steps', label: 'steps' },
256 { filter: 'dispatches', label: 'dispatches' },
257 { filter: 'needsYou', label: 'needs you' },
258 { filter: 'controls', label: 'controls' },
259])
260
261const FILTER_KINDS: Record<Exclude<LogFilter, 'all'>, string[]> = {
262 steps: ['step', 'decision', 'resume'],
263 dispatches: ['dispatch.start', 'dispatch.end'],
264 needsYou: ['block', 'wait'],
265 controls: ['control', 'pause', 'unpause', 'label', 'merge'],
266}
267
268export function passesFilter(event: StatusEvent, filter: LogFilter): boolean {
269 return filter === 'all' || FILTER_KINDS[filter].includes(event.kind)
270}
271
272/**
273 * `HH:MM:SS` of an instant in UTC: the hooks module has no clock zone to
274 * read, and the producer prints every instant in UTC too (`parkedUntil`,
275 * a wait's `until`), so the Log tab marks its column `UTC`.
276 */
277export const clockText = (atMs: number): string => new Date(atMs).toISOString().slice(11, 19)
278
279/** The round as the event file spells it (`fix-2`, `re-plan`, `open-findings`) in the band's words. */
280export function roundText(round: string): string {
281 const fix = /^fix-(\d+)$/u.exec(round)
282 return fix ? `fix ${fix[1]}` : round === 'open-findings' ? 'open findings' : round
283}
284
285const prText = (pr: number | undefined): string => (pr === undefined ? 'a PR' : `PR #${pr}`)
286
287/** The glyph and the one-line text of an event, by kind. */
288export function eventText(event: StatusEvent): { glyph: string; what: string } {
289 const role = event.role ?? 'dispatch'
290 switch (event.kind) {
291 case 'step': return { glyph: GLYPH.step, what: parts([stepName(event.step ?? ''), event.round === undefined ? null : roundText(event.round)]) }
292 case 'dispatch.start': return { glyph: GLYPH.play, what: parts([role, event.model === undefined ? null : shortModel(event.model)]) }
293 case 'dispatch.end': {
294 const spent = event.ms === undefined ? null : duration(event.ms)
295 return event.ok === true
296 ? { glyph: GLYPH.done, what: parts([`${role} returned`, spent]) }
297 : { glyph: GLYPH.failed, what: parts([`${role} ${event.reason ?? 'failed'}`, spent]) }
298 }
299 case 'decision': return { glyph: GLYPH.active, what: `decided: ${event.text ?? ''}` }
300 case 'wait': {
301 if (event.reason === 'pr') return { glyph: GLYPH.waiting, what: `waits on ${prText(event.pr)}${event.paths === undefined ? '' : ` (${event.paths.join(', ')})`}` }
302 return { glyph: GLYPH.waiting, what: event.reason === 'time' ? `waits ${event.text ?? 'on time'}` : `waits on ${event.text ?? event.reason ?? ''}` }
303 }
304 case 'resume': return { glyph: GLYPH.play, what: parts(['resumed', event.text]) }
305 case 'block': return { glyph: GLYPH.needsYou, what: parts([`blocked ${event.reason ?? ''}`.trim(), event.text]) }
306 case 'deliver': return { glyph: GLYPH.done, what: `delivered ${prText(event.pr)}` }
307 case 'merge': return event.ok === true
308 ? { glyph: GLYPH.done, what: `merged ${prText(event.pr)}` }
309 : { glyph: GLYPH.failed, what: `merge refused: ${event.reason ?? 'no reason'}` }
310 case 'label': return { glyph: GLYPH.dot, what: `label: ${event.text ?? ''}` }
311 case 'control': return { glyph: GLYPH.dot, what: `you: ${event.text ?? ''}` }
312 case 'pause': return { glyph: GLYPH.paused, what: 'paused' }
313 case 'unpause': return { glyph: GLYPH.play, what: 'resumed by you' }
314 case 'run.start': return { glyph: GLYPH.brand, what: 'run started' }
315 case 'run.end': return { glyph: GLYPH.brand, what: parts(['run ended', event.reason]) }
316 default: return { glyph: GLYPH.dot, what: event.kind }
317 }
318}
319
320/** The widest writer's name (`dispatch`), so the what column lines up. */
321const BY_WIDTH = 8
322
323/** One log row: `HH:MM:SS <glyph> <by> #N PR #M <what>`; the time is the row's lead. */
324export function logRow(event: StatusEvent): Row {
325 const { glyph, what } = eventText(event)
326 const issue = event.issue === undefined ? null : `#${event.issue}${event.pr === undefined ? '' : ` PR #${event.pr}`}`
327 return { lead: clockText(event.at), glyph, text: [event.by.padEnd(BY_WIDTH), issue, what].filter((part) => part !== null).join(' '), right: null, dim: false, action: null }
328}
329
330/** The Log tab: the projection's log as it comes (newest first), those the filter passes. */
331export function logTab(projection: StatusProjection, filter: LogFilter): Row[] {
332 return (projection.log ?? []).filter((event) => passesFilter(event, filter)).map(logRow)
333}
334
335// The Live tab (item 10).
336
337/** The rows the tab draws over the lines: the header, the tabs, the gap under them, the buttons row and the title; one more while a question shows. */
338const LIVE_CHROME_ROWS = 5
339const LIVE_HOTKEYS = ['a', 'b', 'c', 'd', 'e']
340
341export type LiveLine = { text: string; dim: boolean }
342export type LiveView = {
343 /** One per running dispatch: `#517 implement`, hotkeys `a`..`e` in order (none past the fifth), the selected one active. */
344 buttons: { id: string; label: string; hotkey: string | null; active: boolean }[]
345 /** The dispatch shown with its title row and the lines in the window; null when none has run; `cancel` while it runs (item 16). */
346 shown: { id: string; title: string; lines: LiveLine[]; cancel: StatusPending | null } | null
347}
348
349export type LiveState = Pick<StatusState, 'selected' | 'follow' | 'liveOffset' | 'pending'>
350
351/** `14 lines`, `2.1k lines` from a thousand. */
352export const linesText = (count: number): string => (count >= 1000 ? `${(count / 1000).toFixed(1)}k lines` : `${count} lines`)
353
354/** The dispatch the tab shows: the selected running one, else the first running one, else the ended stream that grew last. */
355export function shownDispatch(projection: StatusProjection, streams: Record<string, StatusStream>, selected: string | null): string | null {
356 const running = runningDispatches(projection).map((dispatch) => dispatch.dispatch)
357 if (running.length > 0) return selected !== null && running.includes(selected) ? selected : running[0] ?? null
358 const ended = Object.entries(streams).sort(([, left], [, right]) => right.lastGrewAtMs - left.lastGrewAtMs)
359 return ended[0]?.[0] ?? null
360}
361
362/** `#517 implement`: the dispatch's issue and role; the id when the projection names neither. */
363function dispatchName(dispatch: { issue: number | null; role: string | null }, id: string): string {
364 const words = [dispatch.issue === null ? null : `#${dispatch.issue}`, dispatch.role].filter((word): word is string => word !== null)
365 return words.length === 0 ? id : words.join(' ')
366}
367
368/** The title row of a dispatch: running with its elapsed, lines and `quiet`; ended with its outcome from the log; died when gone. */
369export function liveTitle(projection: StatusProjection, id: string, stream: StatusStream | undefined, nowMs: number, narrow: boolean): string {
370 const log = projection.log ?? []
371 const listed = (projection.dispatches ?? []).find((dispatch) => dispatch.dispatch === id) ?? null
372 const started = log.find((event) => event.kind === 'dispatch.start' && event.dispatch === id)
373 const end = log.find((event) => event.kind === 'dispatch.end' && event.dispatch === id)
374 // An ended dispatch is named by its start event, or by its end event when the log no longer holds the start.
375 const named = started ?? end
376 const facts = listed ?? { issue: named?.issue ?? null, role: named?.role ?? null, model: started?.model ?? null }
377 const name = dispatchName(facts, id)
378 const model = facts.model === null ? null : shortModel(facts.model)
379 if (listed !== null && listed.state === 'running') {
380 const lines = stream === undefined || narrow ? null : linesText(stream.lines.length)
381 const quiet = stream !== undefined && isQuiet(stream, listed, nowMs) ? `quiet ${duration(nowMs - stream.lastGrewAtMs)}` : null
382 return parts([name, model, duration(nowMs - listed.startedAtMs), lines, quiet])
383 }
384 if (end !== undefined) return parts([name, model, `ended ${duration(nowMs - end.at)} ago`, end.ok === true ? 'ok' : end.reason ?? 'failed'])
385 return parts([name, model, 'died', stream === undefined ? null : `last wrote ${duration(nowMs - stream.lastGrewAtMs)} ago`])
386}
387
388/** The window's rows for the lines: the body less the tab's chrome (and the question's row while one shows), one at least. */
389export const liveRoom = (bodyRows: number, pendingShown: boolean): number => Math.max(1, bodyRows - LIVE_CHROME_ROWS - (pendingShown ? 1 : 0))
390
391/** A scroll by `by` rows: follow goes off, and the window moves from where it stands, clamped to the lines. */
392export function liveScroll(state: Pick<LiveState, 'follow' | 'liveOffset'>, by: number, lineCount: number, room: number): Pick<LiveState, 'follow' | 'liveOffset'> {
393 const last = Math.max(0, lineCount - room)
394 const from = state.follow ? last : state.liveOffset
395 return { follow: false, liveOffset: Math.min(last, Math.max(0, from + by)) }
396}
397
398/** The Live tab: the buttons, and the shown dispatch's title and lines in the window (the end while following, else from the offset). */
399export function liveTab(
400 projection: StatusProjection, streams: Record<string, StatusStream>, state: LiveState, nowMs: number, bodyRows: number, bodyColumns: number,
401): LiveView {
402 const id = shownDispatch(projection, streams, state.selected)
403 const running = runningDispatches(projection)
404 const buttons = running.map((dispatch, index) => ({
405 id: dispatch.dispatch, label: dispatchName(dispatch, dispatch.dispatch), hotkey: LIVE_HOTKEYS[index] ?? null, active: dispatch.dispatch === id,
406 }))
407 if (id === null) return { buttons, shown: null }
408 const runs = running.find((dispatch) => dispatch.dispatch === id)
409 const cancel: StatusPending | null = runs === undefined ? null : { kind: 'cancel', dispatch: id, issue: runs.issue, name: dispatchName(runs, id) }
410 const stream = streams[id]
411 const lines = stream?.lines ?? []
412 const room = liveRoom(bodyRows, state.pending !== null)
413 const offset = state.follow ? Math.max(0, lines.length - room) : Math.min(state.liveOffset, Math.max(0, lines.length - room))
414 return {
415 buttons,
416 shown: {
417 id,
418 title: liveTitle(projection, id, stream, nowMs, bodyColumns < NARROW_COLUMNS),
419 lines: lines.slice(offset, offset + room).map((text) => ({ text, dim: text.startsWith(GLYPH.thinking) })),
420 cancel,
421 },
422 }
423}
424hooks/status/control.ts 103 lines1// The controls (docs/specs/SPEC-central.md item 16, SPEC-events items 7 to
2// 12): pause, unpause, cancel, answer, merge and label. The mod holds no
3// path and writes no file: it runs `tools/status.mjs --control`, which
4// validates the control and writes it atomically for the session that owns
5// the run. The answer is toasted, and a parked session is woken with one
6// prompt, so the loop's prime applies the control. The mod never runs gh,
7// git or a loop tool for a control.
8import type { Runner } from './read'
9
10/** The producer reads the projection (the ownership check) before it writes: longer than a read's 2 s. */
11export const CONTROL_TIMEOUT_MS = 10_000
12/** What the toast says when the producer gave no answer in time: the file may be written, and the loop applies it then. */
13export const NO_ANSWER = 'no answer: applied at the next prime if written'
14/** The prompt that wakes a parked session: the dev skill reads it as "run prime, which applies the control". */
15export const WAKE_PROMPT = 'autoloop: control'
16
17export type Control =
18 | { kind: 'pause' }
19 | { kind: 'unpause' }
20 | { kind: 'cancel'; dispatch: string; issue: number | null }
21 | { kind: 'answer'; issue: number; text: string }
22 | { kind: 'merge'; pr: number }
23 | { kind: 'label'; pr: number }
24
25/** The producer's argv for one control: the kind and its fields as `--kind`, `--pr`, `--issue`, `--dispatch`, `--text`. */
26export function controlArgv(pluginRoot: string, sessionId: string, control: Control): string[] {
27 const argv = ['node', `${pluginRoot}/tools/status.mjs`, '--control', '--session', sessionId, '--kind', control.kind]
28 if (control.kind === 'cancel') argv.push('--dispatch', control.dispatch, ...(control.issue === null ? [] : ['--issue', String(control.issue)]))
29 if (control.kind === 'answer') argv.push('--issue', String(control.issue), '--text', control.text)
30 if (control.kind === 'merge' || control.kind === 'label') argv.push('--pr', String(control.pr))
31 return argv
32}
33
34/** The control as the toasts name it: `pause`, `cancel #517`, `answer #417`, `merge PR #700`, `label PR #701`. */
35export function controlName(control: { kind: string | null; pr?: number | null; issue?: number | null; dispatch?: string }): string {
36 const kind = control.kind ?? 'control'
37 if (control.pr !== null && control.pr !== undefined) return `${kind} PR #${control.pr}`
38 if (control.issue !== null && control.issue !== undefined) return `${kind} #${control.issue}`
39 return control.dispatch === undefined ? kind : `${kind} ${control.dispatch}`
40}
41
42/** The toast for a control the loop refused (SPEC-central item 16). */
43export function refusedText(refused: { kind: string | null; pr: number | null; issue: number | null; reason: string }): string {
44 return `✗ ${controlName(refused)} refused: ${refused.reason}`
45}
46
47/** The producer's answer, or the reason it is not one. */
48export function parseControlAnswer(result: { exitCode: number; stdout: string }): { ok: true; name: string } | { ok: false; error: string } {
49 let parsed: unknown
50 try {
51 parsed = JSON.parse(result.stdout)
52 } catch {
53 return { ok: false, error: result.exitCode === 0 ? NO_ANSWER : `the producer exited ${result.exitCode}` }
54 }
55 const record = typeof parsed === 'object' && parsed !== null ? (parsed as Record<string, unknown>) : {}
56 if (record['ok'] === true && typeof record['name'] === 'string') return { ok: true, name: record['name'] }
57 return { ok: false, error: typeof record['error'] === 'string' ? record['error'] : 'the producer refused the control' }
58}
59
60/** The host beneath a control, injected by register.tsx: the producer's runner, the toasts, the prompt queue, the plugin root and the session id. */
61export type ControlHost = {
62 run: Runner
63 toast: (text: string) => void
64 submit: (text: string) => Promise<unknown>
65 pluginRoot: string
66 sessionId: () => Promise<string>
67}
68
69/**
70 * The controls the producer is running now, by kind and target: a repeat
71 * (an Enter twice on an answer) is dropped while the first runs. Module
72 * state: a reload drops the set with the runs it names.
73 */
74const inFlight = new Set<string>()
75
76/**
77 * One control: run the producer, toast the outcome, and on success queue
78 * the wake prompt (a turn of its own once the session is idle, so a parked
79 * loop primes and applies the control). Never throws: a host call that
80 * rejects (the producer killed at the timeout) is toasted as no answer,
81 * since the file may be written.
82 */
83export async function sendControl(host: ControlHost, control: Control): Promise<void> {
84 const name = controlName(control)
85 if (inFlight.has(name)) return
86 inFlight.add(name)
87 try {
88 const answer = parseControlAnswer(await host.run(controlArgv(host.pluginRoot, await host.sessionId(), control), { timeoutMs: CONTROL_TIMEOUT_MS }))
89 if (!answer.ok) {
90 host.toast(`✗ ${name}: ${answer.error}`)
91 return
92 }
93 host.toast(`✓ ${name} sent`)
94 // Not awaited: the call resolves when the queued turn starts, which
95 // waits on a running turn.
96 host.submit(WAKE_PROMPT).catch(() => undefined)
97 } catch (error) {
98 host.toast(`✗ ${name}: ${NO_ANSWER} (${error instanceof Error ? error.message : String(error)})`)
99 } finally {
100 inFlight.delete(name)
101 }
102}
103hooks/status/draw.tsx 248 lines1// The drawings (docs/specs/SPEC-central.md, items 7 to 16): the band's card
2// and the pane, as element trees over the surface's table. They hold no `$`:
3// register.tsx hands them the table it resolved, the state as last written
4// and, for the pane, the handlers behind its buttons and inputs. The texts
5// come from view.ts and central.ts; the small builders from kit.ts.
6import type { Elements, RenderChildren, RenderElement } from 'claude-code'
7
8import type { StatusPending, StatusProjection, StatusState, StatusTick } from '../../types'
9import type { Row, RowAction, Section } from './central'
10import { LOG_FILTERS, TABS, glyphTone, liveTab, logTab, pendingQuestion, runTab, statusLine, unitsTab } from './central'
11import type { Control } from './control'
12import type { Kit } from './kit'
13import { GLYPH, PALETTE, cell, empty, header, plain, rule, section, strip, tabs } from './kit'
14import { isStale } from './read'
15import { HINT, checkedAgo, columnWidths, headerMarks, isNarrow, loopColumn, needsYouRow, phaseRow, proxyColumn, workColumn } from './view'
16
17/** The state while the band or the pane draws: its last projection is drawable (read.ts `isDrawable`). */
18export type Drawable = StatusState & { last: StatusProjection }
19/** The band draws the tick's clock too: `checked Ns ago` and the stale mark. */
20export type BandState = Drawable & StatusTick
21
22/**
23 * The band's card (SPEC-central item 7): the header, the three cells, the
24 * phase row and the needs-you strip; one row (the header and the cells) when
25 * `oneRow`, as while the pane is open. Drawn above the prompt and, for the
26 * /autoloop output row, in the transcript.
27 */
28export function bandTree(kit: Kit, state: BandState, nowMs: number, bodyColumns: number, oneRow: boolean): RenderElement {
29 const { Box, Text } = kit
30 // Stale: the glyphs that mean "running now" draw hollow, since nothing
31 // confirms it (spec item 11).
32 const stale = isStale(state.lastOkAtMs, nowMs)
33 const live = (glyph: string) => (stale && glyph === GLYPH.active ? GLYPH.notYet : glyph)
34 // The one-row form takes the narrow texts whatever the width.
35 const narrow = isNarrow(bodyColumns) || oneRow
36 const loop = loopColumn(state.last, narrow)
37 const work = workColumn(state.last)
38 const proxy = state.proxy === null ? null : proxyColumn(state.proxy, narrow)
39 // ON is good; UP and OFF are warnings: the glyph and the word wear the same color.
40 const proxyColor = proxy?.state === 'on' ? PALETTE.good : PALETTE.warn
41 const brand = (
42 <Text color={PALETTE.ink}>
43 <Text color={PALETTE.accent}>{GLYPH.brand}</Text> autoloop · {checkedAgo(state.lastOkAtMs, nowMs)}
44 {headerMarks(state.last, stale).map((mark) => <Text> · <Text color={PALETTE.warn}>{mark}</Text></Text>)}
45 </Text>
46 )
47 const loopHead = (
48 <Text color={PALETTE.ink} wrap="truncate-end">
49 <Text color={PALETTE.good}>{live(loop.glyph)}</Text> {plain(loop.head)}
50 </Text>
51 )
52 const workText = (
53 <Text color={PALETTE.ink}>
54 {work.counts}{work.needsYou !== null && <Text> · <Text color={PALETTE.crit}>{work.needsYou}</Text></Text>}
55 </Text>
56 )
57 const proxyHead = proxy === null ? null : (
58 <Text color={PALETTE.ink}>
59 <Text color={proxyColor}>{live(proxy.glyph)}</Text> <Text color={proxyColor}>{proxy.word}</Text>{proxy.tail}
60 </Text>
61 )
62 const sep = <Text color={PALETTE.ink3}> {GLYPH.rule} </Text>
63 if (oneRow) {
64 return (
65 <Box borderStyle="round" borderColor={PALETTE.ink3} paddingX={1}>
66 <Text color={PALETTE.ink} wrap="truncate-end">
67 {brand}{sep}{loopHead}{sep}{workText}{proxyHead !== null && <Text>{sep}{proxyHead}</Text>} · <Text color={PALETTE.ink2}>{HINT}</Text>
68 </Text>
69 </Box>
70 )
71 }
72 const bodies: { title: string; rows: RenderChildren[] }[] = [
73 { title: 'loop', rows: [loopHead, loop.detail === null ? null : <Text color={PALETTE.ink2}>{plain(loop.detail)}</Text>] },
74 { title: 'work', rows: [workText] },
75 ...(proxy === null ? [] : [{ title: 'proxy', rows: [proxyHead, ...proxy.rows.map((row) => <Text color={PALETTE.ink2}>{row}</Text>)] }]),
76 ]
77 const height = 1 + Math.max(...bodies.map((body) => body.rows.filter((row) => row !== null).length))
78 const widths = columnWidths(bodyColumns, bodies.length)
79 const phases = phaseRow(state.last)
80 const needsYou = needsYouRow(state.last)
81 const phaseGlyph = (phase: 'done' | 'current' | 'notYet') =>
82 (phase === 'done' ? GLYPH.done : phase === 'current' ? live(GLYPH.active) : GLYPH.notYet)
83 return (
84 <Box flexDirection="column" borderStyle="round" borderColor={PALETTE.ink3}>
85 <Box paddingX={1}>{header(kit, brand, HINT)}</Box>
86 <Box>
87 {bodies.flatMap((body, index) => [
88 index === 0 ? null : rule(kit, height),
89 cell(kit, body.title, body.rows, widths[index] ?? 0),
90 ])}
91 </Box>
92 {phases !== null && (
93 <Box paddingX={1}>
94 <Text color={PALETTE.ink}>
95 {phases.map((phase, index) => {
96 const notYet = phase.state === 'notYet'
97 return (
98 <Text color={notYet ? PALETTE.ink3 : PALETTE.ink}>
99 {index === 0 ? '' : ' '}<Text color={notYet ? PALETTE.ink3 : PALETTE.good}>{phaseGlyph(phase.state)}</Text> {phase.label}
100 </Text>
101 )
102 })}
103 </Text>
104 </Box>
105 )}
106 {needsYou !== null && strip(kit,
107 [<Text color={PALETTE.crit} bold>{GLYPH.needsYou}</Text>, ' #', needsYou.issue, ' ', plain(needsYou.text)],
108 `${needsYou.right}${needsYou.more > 0 ? ` · +${needsYou.more} more` : ''}`)}
109 </Box>
110 )
111}
112
113/** The pane's kit: the three elements, and an Input where the surface draws fields (the mobile surface draws none). */
114export type PaneKit = Kit & Partial<Pick<Elements['terminal'], 'Input'>>
115
116/** What the pane's hook hands the drawing: the clock, the session, the body's size, and the handlers behind the controls. */
117export type PaneHost = {
118 nowMs: number
119 sessionId: string
120 bodyColumns: number
121 bodyRows: number
122 /** The state as written now: a handler reads it before it acts, never the value the drawing captured. */
123 read: () => Promise<StatusState>
124 /** One write of the mod's state: the fields `change` returns replace the state's. */
125 edit: (change: (state: StatusState) => Partial<StatusState>) => Promise<StatusState>
126 /** One control, sent through control.ts. */
127 send: (control: Control) => Promise<void>
128}
129
130/**
131 * The pane (items 8 to 14): the header with the pause button, the tabs, a
132 * pending control's question, and the tab's body. The controls (item 16)
133 * are its buttons and inputs: each sends one control through `host.send`.
134 * A cancel, a merge or an authorize is held as `pending` in the state until
135 * its y or n, so the question survives a redraw and a double press asks
136 * once; an answer's Enter is its confirmation.
137 */
138export function paneTree(kit: PaneKit, state: Drawable, host: PaneHost): RenderElement {
139 const { Box, Text, Button } = kit
140 // The mobile surface draws no field: a block or decide row then stands without its input.
141 const Input = kit.Input ?? null
142 const projection = state.last
143 const setPending = (pending: StatusPending | null) => host.edit(() => ({ pending }))
144 const paused = projection.run?.paused === true
145 // The press reads the run as last written, not as this drawing saw it: a
146 // tick between the draw and the press may have flipped it.
147 const pauseButton = (
148 <Button key="pause" label={paused ? `${GLYPH.play} resume` : `${GLYPH.paused} pause`} hotkey="p" plain
149 onPress={async () => host.send({ kind: (await host.read()).last?.run?.paused === true ? 'unpause' : 'pause' })} />
150 )
151 const confirmed = (pending: StatusPending): Control => {
152 if (pending.kind === 'cancel') return { kind: 'cancel', dispatch: pending.dispatch, issue: pending.issue }
153 return pending.kind === 'merge' ? { kind: 'merge', pr: pending.pr } : { kind: 'label', pr: pending.pr }
154 }
155 const confirmRow = (pending: StatusPending) => (
156 <Box gap={1}>
157 <Text color={PALETTE.ink}>{plain(pendingQuestion(pending))}</Text>
158 <Button key="confirm-yes" label="yes" hotkey="y" plain onPress={async () => {
159 await setPending(null)
160 await host.send(confirmed(pending))
161 }} />
162 <Button key="confirm-no" label="no" hotkey="n" plain onPress={() => setPending(null)} />
163 </Box>
164 )
165 // The element a row's action draws: a button at the right that asks y or n, or an Input on the row under.
166 const actionButton = (action: RowAction): RenderElement | null => {
167 if (action.kind === 'answer') return null
168 const key = action.kind === 'cancel' ? `cancel-${action.dispatch}` : `${action.kind}-${action.pr}`
169 return <Button key={key} label={action.kind} plain onPress={() => setPending(action)} />
170 }
171 const drawRow = (r: Row): RenderElement => {
172 const line = (
173 <Box justifyContent="space-between">
174 <Text color={r.dim ? PALETTE.ink2 : PALETTE.ink} wrap="truncate-end">
175 {r.lead !== null && <Text color={PALETTE.ink2}>{r.lead} </Text>}
176 {r.glyph !== '' && <Text color={PALETTE[glyphTone(r.glyph)]}>{r.glyph}</Text>}{r.glyph !== '' && ' '}{plain(r.text)}
177 </Text>
178 {r.right !== null && <Text color={PALETTE.ink2}> {plain(r.right)}</Text>}
179 {r.action !== null && actionButton(r.action)}
180 </Box>
181 )
182 if (r.action?.kind !== 'answer' || Input === null) return line
183 const issue = r.action.issue
184 return (
185 <Box flexDirection="column">
186 {line}
187 <Input key={`answer-${issue}`} placeholder="answer…" onSubmit={(value) => {
188 const text = value.trim()
189 if (text !== '') void host.send({ kind: 'answer', issue, text })
190 }} />
191 </Box>
192 )
193 }
194 const drawSection = (s: Section) => section(kit, s.title, s.rows.map(drawRow))
195 const body = (): RenderChildren[] => {
196 if (state.tab === 'run') {
197 const run = runTab(projection, state.proxy, host.sessionId, host.nowMs, host.bodyColumns)
198 // The Now section holds the no-unit text and the running dispatches together.
199 const now = [...(run.empty === null ? [] : [empty(kit, run.empty)]), ...(run.sections.find((s) => s.title === 'Now')?.rows ?? []).map(drawRow)]
200 return [now.length > 0 && section(kit, 'Now', now), ...run.sections.filter((s) => s.title !== 'Now').map(drawSection)]
201 }
202 if (state.tab === 'units') return unitsTab(projection, host.nowMs).map(drawRow)
203 if (state.tab === 'log') {
204 return [
205 <Box gap={1}>
206 {LOG_FILTERS.map(({ filter, label }) => (
207 <Button key={`filter-${filter}`} label={label} plain {...(state.logFilter === filter ? { variant: 'primary' as const } : {})}
208 onPress={() => host.edit(() => ({ logFilter: filter }))} />
209 ))}
210 <Text color={PALETTE.ink2}>UTC</Text>
211 </Box>,
212 ...logTab(projection, state.logFilter).map(drawRow),
213 ]
214 }
215 // Live: a button per running dispatch, `f` for follow, the shown
216 // dispatch's title and its lines in the window.
217 const view = liveTab(projection, state.streams, state, host.nowMs, host.bodyRows, host.bodyColumns)
218 const shown = view.shown
219 if (shown === null) return [empty(kit, 'no dispatch has run yet')]
220 return [
221 <Box gap={1}>
222 {view.buttons.map((button) => (
223 <Button key={`live-${button.id}`} label={plain(button.label)} plain {...(button.hotkey === null ? {} : { hotkey: button.hotkey })}
224 {...(button.active ? { variant: 'primary' as const } : {})} onPress={() => host.edit(() => ({ selected: button.id }))} />
225 ))}
226 <Button key="follow" label="follow" hotkey="f" plain {...(state.follow ? { variant: 'primary' as const } : {})}
227 onPress={() => host.edit((written) => ({ follow: !written.follow }))} />
228 {shown.cancel !== null && <Button key="cancel" label="cancel" hotkey="x" plain onPress={() => setPending(shown.cancel)} />}
229 </Box>,
230 <Text color={PALETTE.ink} wrap="truncate-end">{plain(shown.title)}</Text>,
231 ...shown.lines.map((line) => <Text color={line.dim ? PALETTE.ink2 : PALETTE.ink} wrap="truncate-end">{plain(line.text)}</Text>),
232 ]
233 }
234 return (
235 <Box flexDirection="column" gap={1}>
236 <Box flexDirection="column">
237 <Box justifyContent="space-between">
238 <Text color={PALETTE.ink} wrap="truncate-end"><Text color={PALETTE.accent}>{GLYPH.brand}</Text> autoloop · {plain(statusLine(projection))}</Text>
239 {pauseButton}
240 </Box>
241 {tabs(kit, TABS.map(({ tab, label, hotkey }) => ({ label, hotkey, active: state.tab === tab, onPress: () => host.edit(() => ({ tab })) })))}
242 {state.pending !== null && confirmRow(state.pending)}
243 </Box>
244 <Box flexDirection="column" {...(state.tab === 'run' ? { gap: 1 } : {})}>{body()}</Box>
245 </Box>
246 )
247}
248hooks/status/kit.ts 123 lines1// The kit (docs/specs/SPEC-central.md, item 15): the palette, the glyphs and
2// small pure builders over the surface's element table. Status is carried by
3// a glyph and a word, never by color alone: text wears the theme's text
4// colors, and the status colors go on glyphs and on the words ON, UP, OFF
5// and needs you. Every builder returns a plain element tree and holds no `$`.
6import type { Elements, RenderChildren, RenderElement } from 'claude-code'
7
8/** Theme keys only, so the drawing follows the person's theme (light or dark). */
9export const PALETTE = Object.freeze({
10 ink: 'text',
11 ink2: 'subtle',
12 ink3: 'inactive',
13 /** The needs-you strip's background. */
14 card: 'inactive',
15 accent: 'claude',
16 good: 'success',
17 warn: 'warning',
18 crit: 'error',
19})
20
21export const GLYPH = Object.freeze({
22 /** The band's own mark, before the word autoloop. */
23 brand: '◆',
24 active: '●',
25 waiting: '◷',
26 needsYou: '!',
27 done: '✓',
28 failed: '✗',
29 /** Not yet; also OFF and stale, which draw hollow. */
30 notYet: '○',
31 /** Half: a proxy that answers without a status. */
32 half: '◐',
33 /** Running, resumed, a control that resumes. */
34 play: '▶',
35 /** A reasoning tick. */
36 thinking: '⋯',
37 /** Between the band's cells. */
38 rule: '│',
39 /** A step announced, in the log. */
40 step: '→',
41 /** The run paused. */
42 paused: '⏸',
43 /** A log event with no state of its own: a label, a control. */
44 dot: '·',
45})
46
47// What the text it draws may not carry (an operand of an event, a stream
48// line, an issue title: none is the mod's own): the escape sequences a
49// terminal would act on, and the controls that move or hide text.
50const STRINGS = /\u001b[\]PX^_][^\u0007\u001b]*(?:\u0007|\u001b\\)?/gu
51const CSI = /\u001b\[[0-?]*[ -/]*[@-~]/gu
52const CONTROLS = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/gu
53const BIDI = /[\u200b-\u200f\u202a-\u202e\u2066-\u2069]/gu
54
55/**
56 * The text with its control sequences removed, nothing in their place: the
57 * OSC, DCS, APC, PM and SOS strings, the CSI sequences, the C0 set but the
58 * tab, DEL, the C1 set, and the bidi and zero-width controls. Applied where
59 * a text from outside the mod reaches a Text or a toast.
60 */
61export function plain(text: string): string {
62 return text.replace(STRINGS, '').replace(CSI, '').replace(CONTROLS, '').replace(BIDI, '')
63}
64
65/** The three elements every builder draws with, as `$.ui.resolve(e)` hands them out. */
66export type Kit = Pick<Elements['terminal'], 'Box' | 'Text' | 'Button'>
67
68/** A header row: `left` at the edge, `right` dim at the other, one row. */
69export function header(k: Kit, left: RenderChildren, right: RenderChildren | null = null): RenderElement {
70 return k.Box({ justifyContent: 'space-between', children: [
71 k.Text({ color: PALETTE.ink, wrap: 'truncate-end', children: left }),
72 right === null ? null : k.Text({ color: PALETTE.ink2, children: right }),
73 ] })
74}
75
76/** A dim upper-case label row. */
77export function label(k: Kit, text: string): RenderElement {
78 return k.Text({ color: PALETTE.ink2, dimColor: true, children: text.toUpperCase() })
79}
80
81/** A section: its dim label over its rows; stack sections in a column with `gap={1}`. */
82export function section(k: Kit, title: string, rows: RenderChildren[]): RenderElement {
83 return k.Box({ flexDirection: 'column', children: [label(k, title), ...rows] })
84}
85
86export type Tab = { label: string; hotkey: string; active: boolean; onPress: () => void }
87
88/** A row of plain digit-hotkey buttons, the active one primary. */
89export function tabs(k: Kit, items: Tab[]): RenderElement {
90 return k.Box({ gap: 1, children: items.map((tab) => k.Button({
91 key: `tab-${tab.hotkey}`, label: tab.label, hotkey: tab.hotkey, plain: true,
92 ...(tab.active ? { variant: 'primary' as const } : {}), onPress: tab.onPress,
93 })) })
94}
95
96/** A number (or a short text) right-aligned in a fixed width. */
97export function num(k: Kit, value: number | string, width: number): RenderElement {
98 return k.Text({ color: PALETTE.ink, children: String(value).padStart(width) })
99}
100
101/** What an empty section or tab says, dim. */
102export function empty(k: Kit, text: string): RenderElement {
103 return k.Text({ color: PALETTE.ink2, children: text })
104}
105
106/** A padded, labelled cell of a fixed width: the label row over the body rows; no label row when `title` is null. */
107export function cell(k: Kit, title: string | null, body: RenderChildren[], width: number): RenderElement {
108 return k.Box({ flexDirection: 'column', width, paddingX: 1, children: [title === null ? null : label(k, title), ...body] })
109}
110
111/** The rule between two cells: one `│` per row of the tallest neighbour. */
112export function rule(k: Kit, rows: number): RenderElement {
113 return k.Box({ flexDirection: 'column', width: 1, children: Array.from({ length: rows }, () => k.Text({ color: PALETTE.ink3, children: GLYPH.rule })) })
114}
115
116/** A full-width row with its own background: `left` at the edge, `right` dim at the other. */
117export function strip(k: Kit, left: RenderChildren, right: RenderChildren | null = null): RenderElement {
118 return k.Box({ justifyContent: 'space-between', backgroundColor: PALETTE.card, paddingX: 1, children: [
119 k.Text({ color: PALETTE.ink, wrap: 'truncate-end', children: left }),
120 right === null ? null : k.Text({ color: PALETTE.ink2, children: right }),
121 ] })
122}
123hooks/status/read.ts 217 lines1// The three reads (docs/specs/SPEC-status-mod.md, items 1 to 4, and
2// SPEC-central.md item 2): the projection, `tools/status.mjs --json
3// --session <id>` run as a child of the Claude process and held to its
4// schema; the proxy, `GET <base>/status` without auth; and the live tails
5// of running dispatches. Each is pure over an injected runner or fetch, so
6// a test doubles the host and not the producer.
7import type { ProxyRead, ProxyRequest, StatusDispatch, StatusProjection, StatusStream } from '../../types'
8import { createRenderer } from './render'
9
10export const STATUS_SCHEMA = 'autoloop.status/1'
11export const READ_TIMEOUT_MS = 2000
12export const PROXY_TIMEOUT_MS = 2000
13export const STALE_AFTER_MS = 30_000
14
15export type Runner = (
16 argv: readonly string[],
17 init: { timeoutMs: number },
18) => Promise<{ exitCode: number; stdout: string }>
19
20export type ProjectionRead = { ok: true; projection: StatusProjection } | { ok: false }
21
22/** The producer's argv; `logLength` asks for that many log entries (the Log tab's 500), the producer's default otherwise. */
23export function projectionArgv(pluginRoot: string, sessionId: string, logLength: number | null = null): string[] {
24 return ['node', `${pluginRoot}/tools/status.mjs`, '--json', '--session', sessionId, ...(logLength === null ? [] : ['--log', String(logLength)])]
25}
26
27/** The text as one JSON object, or null when it is not one. */
28function parseRecord(text: string): Record<string, unknown> | null {
29 let parsed: unknown
30 try {
31 parsed = JSON.parse(text)
32 } catch {
33 return null
34 }
35 if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return null
36 return parsed as Record<string, unknown>
37}
38
39/** The producer's stdout as a projection, or null when it is not this mod's schema. */
40export function parseProjection(text: string): StatusProjection | null {
41 const record = parseRecord(text)
42 if (record === null || record['schema'] !== STATUS_SCHEMA || typeof record['owned'] !== 'boolean') return null
43 return record as StatusProjection
44}
45
46/**
47 * One read: a child that cannot start, exits non-zero, prints another schema
48 * or reports a fault (`owned: false` with `error`, exit 0: the producer never
49 * throws) is a failed read, so the last projection is kept and goes stale.
50 */
51export async function readProjection(run: Runner, pluginRoot: string, sessionId: string, logLength: number | null = null): Promise<ProjectionRead> {
52 try {
53 const result = await run(projectionArgv(pluginRoot, sessionId, logLength), { timeoutMs: READ_TIMEOUT_MS })
54 if (result.exitCode !== 0) return { ok: false }
55 const projection = parseProjection(result.stdout)
56 return projection === null || projection.error !== undefined ? { ok: false } : { ok: true, projection }
57 } catch {
58 return { ok: false }
59 }
60}
61
62/**
63 * Whether the band may draw from a projection: the session owns a run and
64 * the project has not turned the band off. The schema is checked again
65 * here because `$.state` outlives the module: a value an older module
66 * version wrote may carry another schema.
67 */
68export function isDrawable(projection: StatusProjection | null): projection is StatusProjection {
69 return projection !== null && projection.schema === STATUS_SCHEMA
70 && projection.owned === true && projection.enabled === true
71}
72
73/** Stale: no successful read yet, or none within the last 30 s. */
74export function isStale(lastOkAtMs: number | null, nowMs: number): boolean {
75 return lastOkAtMs === null || nowMs - lastOkAtMs > STALE_AFTER_MS
76}
77
78export type ProxyFetch = (
79 url: string,
80 init: { timeoutMs: number },
81) => Promise<{ status: number; text: string }>
82
83const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '[::1]', '::1'])
84
85/**
86 * The proxy base `ANTHROPIC_BASE_URL` names, or null: unset, not a URL, not
87 * http or https, or not a loopback host (spec item 3). The read carries no
88 * auth, so it never leaves this machine: a public gateway gets no column and
89 * no fetch.
90 */
91export function proxyBase(value: string | undefined): string | null {
92 if (value === undefined || value === '') return null
93 let url: URL
94 try {
95 url = new URL(value)
96 } catch {
97 return null
98 }
99 if (url.protocol !== 'http:' && url.protocol !== 'https:') return null
100 return LOOPBACK_HOSTS.has(url.hostname) ? value : null
101}
102
103export function proxyUrl(base: string): string {
104 return `${base.replace(/\/+$/u, '')}/status`
105}
106
107function stringOrNull(value: unknown): string | null {
108 return typeof value === 'string' ? value : null
109}
110
111/** The proxy's body as its active requests, or null when it is not `{ ok: true, active_requests: [...] }`. */
112export function parseProxyStatus(text: string): ProxyRequest[] | null {
113 const record = parseRecord(text)
114 if (record === null || record['ok'] !== true || !Array.isArray(record['active_requests'])) return null
115 const requests: ProxyRequest[] = []
116 for (const entry of record['active_requests'] as unknown[]) {
117 if (typeof entry !== 'object' || entry === null) return null
118 const fields = entry as Record<string, unknown>
119 requests.push({
120 id: String(fields['id'] ?? ''),
121 startedAt: stringOrNull(fields['started_at']),
122 elapsedMs: typeof fields['elapsed_ms'] === 'number' ? fields['elapsed_ms'] : null,
123 requestedModel: stringOrNull(fields['requested_model']),
124 model: stringOrNull(fields['model']),
125 sessionId: stringOrNull(fields['session_id']),
126 })
127 }
128 return requests
129}
130
131/**
132 * One proxy read: a 200 with a parsed body is `on`; any other answer (a
133 * non-200, a body that does not parse) is `up`, the port answers without a
134 * status; a rejected or timed-out fetch is `off`.
135 */
136export async function readProxy(fetch: ProxyFetch, base: string): Promise<ProxyRead> {
137 let result: { status: number; text: string }
138 try {
139 result = await fetch(proxyUrl(base), { timeoutMs: PROXY_TIMEOUT_MS })
140 } catch {
141 return { state: 'off', requests: [] }
142 }
143 const requests = result.status === 200 ? parseProxyStatus(result.text) : null
144 return requests === null ? { state: 'up', requests: [] } : { state: 'on', requests }
145}
146
147// The live tails (docs/specs/SPEC-central.md, item 2): the last 64 KiB of
148// a running dispatch's live file through `tail -c`, since `$.fs.read` is
149// capped at 4 MiB and a stream can be larger; the complete lines rendered
150// as tools/dispatch-render.mjs renders them (render.ts).
151
152export const TAIL_BYTES = 65536
153export const TAIL_TIMEOUT_MS = 2000
154export const LIVE_PERIOD_MS = 1000
155/** A running dispatch whose file did not grow for this long is quiet. */
156export const QUIET_AFTER_MS = 120_000
157/** Rendered lines kept per dispatch. */
158export const STREAM_LINES_MAX = 200
159
160/** `tail -c 65536 -- <file>`: the `--` so a path can never read as a flag. */
161export function tailArgv(liveFile: string): string[] {
162 return ['tail', '-c', String(TAIL_BYTES), '--', liveFile]
163}
164
165const BYTES = new TextEncoder()
166
167/**
168 * The complete lines of a chunk. A chunk that fills the cap starts wherever
169 * tail cut the file, so its first line is a cut line and goes (a cut
170 * multi-byte character at its head decodes to a replacement character, so
171 * the byte count is read with a margin); a chunk under the cap is the whole
172 * file. The text after the last newline is a line still being written.
173 */
174export function splitTail(chunk: string): string[] {
175 const cut = BYTES.encode(chunk).length >= TAIL_BYTES - 3
176 const lines = chunk.split('\n')
177 lines.pop()
178 return cut ? lines.slice(1) : lines
179}
180
181export type Tailed = { chunk: string; lines: string[] }
182
183/** One tail of a live file, rendered; null when tail could not run or failed, so the last lines are kept. */
184export async function tailLive(run: Runner, liveFile: string): Promise<Tailed | null> {
185 try {
186 const result = await run(tailArgv(liveFile), { timeoutMs: TAIL_TIMEOUT_MS })
187 if (result.exitCode !== 0) return null
188 const render = createRenderer()
189 return { chunk: result.stdout, lines: splitTail(result.stdout).flatMap(render) }
190 } catch {
191 return null
192 }
193}
194
195/** The dispatches whose live file the mod tails: running, with an absolute file path (a relative one is not a dispatch's). */
196export function runningDispatches(projection: StatusProjection | null): (StatusDispatch & { liveFile: string })[] {
197 return (projection?.dispatches ?? []).filter((dispatch): dispatch is StatusDispatch & { liveFile: string } =>
198 dispatch.state === 'running' && dispatch.liveFile !== null && dispatch.liveFile.startsWith('/'))
199}
200
201/**
202 * The streams kept: every running dispatch's, and of the ended ones the
203 * one that grew last (the Live tab shows the last ended dispatch's tail),
204 * so a long session does not carry every dispatch's lines in its state.
205 */
206export function pruneStreams(streams: Record<string, StatusStream>, running: string[]): Record<string, StatusStream> {
207 const ended = Object.entries(streams).filter(([id]) => !running.includes(id))
208 .sort(([, left], [, right]) => right.lastGrewAtMs - left.lastGrewAtMs)
209 const kept = new Set([...running, ...ended.slice(0, 1).map(([id]) => id)])
210 return Object.fromEntries(Object.entries(streams).filter(([id]) => kept.has(id)))
211}
212
213/** Quiet: the dispatch runs and its file did not grow for 120 s. A died or ended dispatch is never quiet. */
214export function isQuiet(stream: StatusStream, dispatch: StatusDispatch, nowMs: number): boolean {
215 return dispatch.state === 'running' && nowMs - stream.lastGrewAtMs >= QUIET_AFTER_MS
216}
217hooks/status/view.ts 224 lines1// The band's view (docs/specs/SPEC-status-mod.md, items 7 to 12, 16 and 17,
2// and release two items 8, 10 and 13): pure functions from the projection
3// and the proxy read to cell and row text. They hold no `$`; register.tsx
4// draws what they return. Status is carried by a glyph and a word, so each
5// cell hands back its glyph apart from its text.
6import type { ProxyRead, ProxyRequest, StatusProjection, StatusRound, StatusUnit, StatusWorkedUnit } from '../../types'
7import { GLYPH } from './kit'
8
9/** Under this many body columns the band drops its wider texts (spec item 12). */
10export const NARROW_COLUMNS = 120
11
12export function isNarrow(bodyColumns: number): boolean {
13 return bodyColumns < NARROW_COLUMNS
14}
15
16/** The parts that are text, joined with ` · `. */
17export const parts = (list: (string | null | undefined)[]): string =>
18 list.filter((part): part is string => typeof part === 'string' && part !== '').join(' · ')
19
20/** `#412 · PR #702`, or `#412` without a PR. */
21export const ref = (issue: number, pr: number | null | undefined): string => (pr === null || pr === undefined ? `#${issue}` : `#${issue} · PR #${pr}`)
22
23/** The text cut to `max` characters, an ellipsis as the last. */
24export const truncate = (text: string, max: number): string => (text.length > max ? `${text.slice(0, max - 1)}…` : text)
25
26/**
27 * A model id as a short name: tools/step.mjs `shortModel`, kept identical
28 * so the band and the card name a model the same way (view.test.ts holds
29 * the parity list).
30 */
31export function shortModel(id: string | null | undefined): string {
32 const model = String(id ?? '').toLowerCase().replace(/^[a-z]+-gateway-/u, '').replace(/\[1m\]$/u, '')
33 const claude = /^claude-([a-z]+)-(\d+)(?:-(\d+))?(?:-\d{8})?$/u.exec(model)
34 if (claude) return `${(claude[1] ?? '').toUpperCase()} ${claude[3] ? `${claude[2]}.${claude[3]}` : claude[2]}`
35 const gpt = /^gpt-(\d+(?:\.\d+)?)-([a-z]+)$/u.exec(model)
36 if (gpt) return `${(gpt[2] ?? '').toUpperCase()} ${gpt[1]}`
37 return model.toUpperCase()
38}
39
40/** A span as step.mjs `duration` prints it (`45s`, `3m 2s`, `1h 05m`), kept identical. */
41export function duration(ms: number): string {
42 const seconds = Math.max(0, Math.round(ms / 1000))
43 if (seconds < 60) return `${seconds}s`
44 const minutes = Math.floor(seconds / 60)
45 if (minutes < 60) return `${minutes}m ${seconds % 60}s`
46 return `${Math.floor(minutes / 60)}h ${String(minutes % 60).padStart(2, '0')}m`
47}
48
49/** The header's age of the last successful read. */
50export function checkedAgo(lastOkAtMs: number | null, nowMs: number): string {
51 if (lastOkAtMs === null) return 'not checked yet'
52 return `checked ${Math.max(0, Math.floor((nowMs - lastOkAtMs) / 1000))}s ago`
53}
54
55/** The header's marks after the age, each drawn in the warning color: `stale`, `paused`. */
56export function headerMarks(projection: StatusProjection, stale: boolean): string[] {
57 return [stale ? 'stale' : null, projection.run?.paused === true ? 'paused' : null]
58 .filter((mark): mark is string => mark !== null)
59}
60
61/** The command the hint on the right names. */
62export const HINT = '/autoloop: central'
63
64/** The round as the `--round` grammar names it: `fix 2/3`, `re-plan`, `open findings`, `full`; null without one. */
65export function roundLabel(round: StatusRound | null): string | null {
66 if (round === null) return null
67 if (round.kind === 'fix') return `fix ${round.n ?? '?'}${round.budget === undefined ? '' : `/${round.budget}`}`
68 return round.kind === 'open-findings' ? 'open findings' : round.kind
69}
70
71/** A model's short name, marked when it ran as a fallback: `OPUS 5.5 (fallback)`. */
72export function modelName(model: string, fallback: boolean): string {
73 return fallback ? `${shortModel(model)} (fallback)` : shortModel(model)
74}
75
76/** The model a proxy request asked for; `unknown` when it named none. */
77export function requestedName(request: ProxyRequest): string {
78 return request.requestedModel === null ? 'unknown' : shortModel(request.requestedModel)
79}
80
81/** The model the Loop column names: the one that ran under a fallback, else the role's pinned one. */
82function modelLabel(unit: StatusWorkedUnit, models: Record<string, string>): string | null {
83 const pinned = unit.role === null ? undefined : models[unit.role]
84 const named = unit.fallback ? unit.model ?? pinned : pinned ?? unit.model
85 return named === undefined || named === null ? null : modelName(named, unit.fallback)
86}
87
88export type LoopColumn = {
89 glyph: string
90 /** `review #412 · PR #702 · Session recovery`, or `Idle · last delivered #416`. */
91 head: string
92 /** `code-review · fix 2/3 · ASTRA 6`; null while idle. */
93 detail: string | null
94}
95
96export function loopColumn(projection: StatusProjection, narrow: boolean): LoopColumn {
97 const unit = projection.unit ?? null
98 if (unit === null) {
99 // The newest delivered unit: issue numbers rise with time, and the
100 // projection carries no time on a unit.
101 const delivered = (projection.units ?? []).filter((entry) => entry.disposition === 'delivered')
102 .map((entry) => entry.issue)
103 const last = delivered.length === 0 ? null : Math.max(...delivered)
104 return { glyph: GLYPH.notYet, head: last === null ? 'Idle' : `Idle · last delivered #${last}`, detail: null }
105 }
106 const title = narrow || unit.title === null ? '' : ` · ${unit.title}`
107 const detail = parts([unit.role, roundLabel(unit.round), modelLabel(unit, projection.models ?? {})])
108 return { glyph: GLYPH.active, head: `${unit.phase} ${ref(unit.issue, unit.pr)}${title}`, detail: detail === '' ? null : detail }
109}
110
111/** The spinner's word while the session owns a run with a unit (SPEC-central item 4): `autoloop · build #517 · PR #530 · implement`; null without a unit. */
112export function spinnerWord(projection: StatusProjection): string | null {
113 const unit = projection.unit ?? null
114 if (unit === null) return null
115 return parts(['autoloop', `${unit.phase} ${ref(unit.issue, unit.pr)}`, unit.role])
116}
117
118export type WorkColumn = {
119 counts: string
120 /** `6 needs you`, drawn in the critical color; null while no unit needs a human (spec item 7). */
121 needsYou: string | null
122}
123
124export function workColumn(projection: StatusProjection): WorkColumn {
125 const counts = projection.counts ?? { active: 0, waiting: 0, blocked: 0 }
126 return {
127 counts: `${counts.active} active · ${counts.waiting} waiting`,
128 needsYou: counts.blocked > 0 ? `${counts.blocked} needs you` : null,
129 }
130}
131
132/** The body less the box's two border columns and one rule between each pair of cells, shared over the cells, the last one taking the remainder. */
133export function columnWidths(bodyColumns: number, count: number): number[] {
134 const inner = Math.max(count, bodyColumns - 2 - (count - 1))
135 const each = Math.floor(inner / count)
136 return Array.from({ length: count }, (_, index) => (index === count - 1 ? inner - each * (count - 1) : each))
137}
138
139/** The requests grouped by requested model, most first, ties by name; a request with no model is `unknown`. */
140export function modelCounts(requests: ProxyRequest[]): { model: string; count: number }[] {
141 const counts = new Map<string, number>()
142 for (const request of requests) {
143 const model = requestedName(request)
144 counts.set(model, (counts.get(model) ?? 0) + 1)
145 }
146 return [...counts].map(([model, count]) => ({ model, count }))
147 .sort((left, right) => right.count - left.count || left.model.localeCompare(right.model))
148}
149
150export type ProxyColumn = {
151 state: ProxyRead['state']
152 /** `●` ON, `◐` UP, `○` OFF. */
153 glyph: string
154 word: 'ON' | 'UP' | 'OFF'
155 /** After the word: ` · 3 requests` (narrow ` · 3 req`), ` · no status`, or nothing. */
156 tail: string
157 /** `OPUS 5.5 ×2`, one per model; none unless ON and not narrow. */
158 rows: string[]
159}
160
161/** The three states of release two, item 10: `● ON · n requests`, `◐ UP · no status`, `○ OFF`. */
162export function proxyColumn(proxy: ProxyRead, narrow: boolean): ProxyColumn {
163 if (proxy.state === 'off') return { state: 'off', glyph: GLYPH.notYet, word: 'OFF', tail: '', rows: [] }
164 if (proxy.state === 'up') return { state: 'up', glyph: GLYPH.half, word: 'UP', tail: ' · no status', rows: [] }
165 const count = proxy.requests.length
166 return {
167 state: 'on', glyph: GLYPH.active, word: 'ON', tail: narrow ? ` · ${count} req` : ` · ${count} requests`,
168 rows: narrow ? [] : modelCounts(proxy.requests).map(({ model, count: n }) => `${model} ×${n}`),
169 }
170}
171
172export type PhaseCell = { label: string; state: 'done' | 'current' | 'notYet' }
173
174/** The producer's phases against the worked unit's: null without a unit (spec item 8). */
175export function phaseRow(projection: StatusProjection): PhaseCell[] | null {
176 const unit = projection.unit ?? null
177 if (unit === null) return null
178 const phases = projection.phases ?? []
179 const current = phases.indexOf(unit.phase)
180 return phases.map((label, index) => ({ label, state: index < current ? 'done' : index === current ? 'current' : 'notYet' }))
181}
182
183export type BlockedUnit = StatusUnit & { needsYou: NonNullable<StatusUnit['needsYou']> }
184
185/** The units a human must answer, oldest first: issue numbers rise with time, and the projection carries no time on a unit. */
186export function blockedUnits(projection: StatusProjection): BlockedUnit[] {
187 return (projection.units ?? []).filter((unit): unit is BlockedUnit => unit.needsYou !== null)
188 .sort((left, right) => left.issue - right.issue)
189}
190
191/** The PR a human must merge or label: an authorization that carries one; null when the answer goes on the issue. */
192export function prToAuthorize(unit: StatusUnit): number | null {
193 return unit.needsYou?.kind === 'authorize' ? unit.pr : null
194}
195
196/** What answers a unit that needs a human: `answer on #N`, or `merge or label PR #M` for an authorization with its PR. */
197export function answerText(unit: StatusUnit): string {
198 const pr = prToAuthorize(unit)
199 return pr === null ? `answer on #${unit.issue}` : `merge or label PR #${pr}`
200}
201
202export type NeedsYouRow = {
203 issue: number
204 text: string
205 /** What answers it: `answer on #N`, or `merge or label PR #M` for an authorization with its PR. */
206 right: string
207 /** How many more units need a human. */
208 more: number
209}
210
211/** The band's needs-you row for the oldest blocked unit (spec item 9); null while none is blocked. */
212export function needsYouRow(projection: StatusProjection): NeedsYouRow | null {
213 const [oldest, ...rest] = blockedUnits(projection)
214 if (oldest === undefined) return null
215 return { issue: oldest.issue, text: oldest.needsYou.text, right: answerText(oldest), more: rest.length }
216}
217
218const TOAST_TEXT_MAX = 60
219
220/** The toast for a unit newly blocked (spec item 16): `#N needs you: <short>`. */
221export function toastText(unit: { issue: number; text: string }): string {
222 return `#${unit.issue} needs you: ${truncate(unit.text, TOAST_TEXT_MAX)}`
223}
224hooks/status/render.ts 147 lines1// The live stream's renderer: tools/dispatch-render.mjs `createRenderer`,
2// line for line, in the mod's own environment. The host lets a hooks
3// module import its own files and `claude-code` only, and dispatch-render
4// imports node:child_process for its `--follow` mode, so the renderer is
5// held here, and both sides are held to one stream (fixtures/stream.ts):
6// live.test.ts holds this renderer to RENDERED, the tool's self-test holds
7// the tool to it. Two differences: a path is shortened against the
8// stream's own `cwd` or to its last three segments, never against a git
9// checkout, since the mod reads no file system; and a text's control
10// sequences are removed (kit.ts `plain`), since the lines go to a Text
11// and not to a terminal of the person's own.
12//
13// Contract, as the tool's: never throw. An unknown shape degrades to a
14// compact type marker, and garbage passes through truncated.
15import { plain } from './kit'
16
17/** A reasoning tick is printed every this many estimated tokens. */
18const THINKING_TICK = 2000
19const MAX_LINE = 400
20
21type Record = { [key: string]: unknown }
22
23const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value)
24
25function compact(value: unknown, limit = 160): string {
26 const text = typeof value === 'string' ? value : JSON.stringify(value) ?? ''
27 const flat = plain(text).replace(/\s+/gu, ' ').trim()
28 return flat.length > limit ? `${flat.slice(0, limit)}…` : flat
29}
30
31function textLines(text: unknown, prefix: string): string[] {
32 return plain(String(text))
33 .split('\n')
34 .map((line) => line.trimEnd())
35 .filter((line) => line.trim().length > 0)
36 .map((line) => `${prefix}${line.length > MAX_LINE ? `${line.slice(0, MAX_LINE)}…` : line}`)
37}
38
39function duration(ms: number): string {
40 const seconds = Math.round(ms / 1000)
41 return seconds >= 60 ? `${Math.floor(seconds / 60)}m ${seconds % 60}s` : `${seconds}s`
42}
43
44/** Relative to the stream's cwd; else an absolute path keeps its last three segments. */
45function shortPath(value: unknown, cwd: string | null): string {
46 const path = String(value ?? '')
47 if (cwd !== null && path === cwd) return '.'
48 if (cwd !== null && path.startsWith(`${cwd}/`)) return path.slice(cwd.length + 1)
49 const segments = path.split('/').filter(Boolean)
50 return path.startsWith('/') && segments.length > 3 ? `…/${segments.slice(-3).join('/')}` : path
51}
52
53function toolLine(name: unknown, input: Record, cwd: string | null): string {
54 const where = (path: unknown) => (path ? ` in ${shortPath(path, cwd)}` : '')
55 if (name === 'Read') {
56 const from = Number(input['offset'])
57 const range = Number.isFinite(from) && Number.isFinite(Number(input['limit']))
58 ? ` :${from}-${from + Number(input['limit'])}`
59 : ''
60 return `▸ read ${shortPath(input['file_path'], cwd)}${range}`
61 }
62 if (name === 'Grep') return `▸ grep /${compact(input['pattern'], 80)}/${where(input['path'])}`
63 if (name === 'Glob') return `▸ glob ${compact(input['pattern'], 80)}${where(input['path'])}`
64 if (name === 'Bash') return `▸ $ ${compact(String(input['command'] ?? '').split('\n')[0], 140)}`
65 if (name === 'Edit' || name === 'Write' || name === 'MultiEdit' || name === 'NotebookEdit') {
66 return `▸ ${name.toLowerCase()} ${shortPath(input['file_path'] ?? input['notebook_path'], cwd)}`
67 }
68 if (name === 'StructuredOutput') {
69 const findings = input['findings']
70 return typeof input['verdict'] === 'string'
71 ? `■ verdict ${input['verdict']}${Array.isArray(findings) ? ` · ${findings.length} findings` : ''}`
72 : '■ structured output'
73 }
74 return `▸ ${String(name ?? 'tool')} ${compact(input, 100)}`
75}
76
77/** Stateful, so thinking ticks throttle across lines and paths shorten against the stream's cwd. Returns the rendered lines for one raw line. */
78export function createRenderer(): (raw: string) => string[] {
79 let lastThinkingTick = 0
80 let cwd: string | null = null
81 return function renderLine(raw: string): string[] {
82 try {
83 const line = String(raw).trim()
84 if (line.length === 0) return []
85 let event: unknown
86 try {
87 event = JSON.parse(line)
88 } catch {
89 return [`· ${compact(line, 120)}`]
90 }
91 if (!isRecord(event)) return []
92
93 // claude stream-json
94 if (event['type'] === 'system' && event['subtype'] === 'thinking_tokens') {
95 const total = Number(event['estimated_tokens'])
96 if (!Number.isFinite(total)) return []
97 if (total - lastThinkingTick < THINKING_TICK) return []
98 lastThinkingTick = total
99 return [`⋯ thinking ~${Math.round(total / 1000)}k tok`]
100 }
101 if (event['type'] === 'system' && event['subtype'] === 'init') {
102 if (typeof event['cwd'] === 'string') cwd = event['cwd']
103 const message = event['message']
104 const model = event['model'] ?? (isRecord(message) ? message['model'] : undefined) ?? ''
105 return [`■ engine up${model ? ` · ${String(model)}` : ''}`]
106 }
107 if (event['type'] === 'assistant' || event['type'] === 'user') {
108 const message = event['message']
109 const parts = isRecord(message) ? message['content'] : undefined
110 if (!Array.isArray(parts)) return []
111 const lines: string[] = []
112 for (const part of parts as unknown[]) {
113 if (!isRecord(part)) continue
114 if (part['type'] === 'text' && typeof part['text'] === 'string') {
115 lines.push(...textLines(part['text'], '│ '))
116 } else if (part['type'] === 'tool_use') {
117 lines.push(toolLine(part['name'], isRecord(part['input']) ? part['input'] : {}, cwd))
118 } else if (part['type'] === 'tool_result' && part['is_error'] === true) {
119 lines.push(` ✖ ${compact(part['content'] ?? '', 160)}`)
120 }
121 }
122 return lines
123 }
124 if (event['type'] === 'result') {
125 const ms = event['duration_ms']
126 const turns = event['num_turns']
127 const cost = event['total_cost_usd']
128 const facts = [
129 String(event['subtype'] ?? 'result'),
130 typeof ms === 'number' && Number.isFinite(ms) ? duration(ms) : null,
131 typeof turns === 'number' && Number.isFinite(turns) ? `${turns} turns` : null,
132 typeof cost === 'number' && Number.isFinite(cost) ? `$${cost.toFixed(2)}` : null,
133 ].filter((fact): fact is string => fact !== null)
134 return [`■ done · ${facts.join(' · ')}`]
135 }
136
137 if (event['type'] === 'error') return [`✖ ${compact(event['message'] ?? event, 200)}`]
138 if (event['type'] === 'rate_limit_event' || event['type'] === 'system') return []
139
140 const label = [event['type'], event['subtype']].filter(Boolean).map(String).join('/')
141 return label ? [`· ${label}`] : []
142 } catch {
143 return []
144 }
145 }
146}
147types/index.d.ts 217 lines1// The status mod's state contract (docs/specs/SPEC-status-mod.md): what the
2// plugin keeps in `$.state` under its own name, so a hot reload of the module
3// keeps the last projection and the toasts already shown.
4
5/** One unit of the run as the projection lists it. */
6export type StatusUnit = {
7 issue: number
8 title: string | null
9 disposition: 'active' | 'waiting' | 'blocked' | 'returned' | 'delivered'
10 phase: string | null
11 role: string | null
12 model: string | null
13 round: StatusRound | null
14 detail: string | null
15 waitingOn: StatusWait | null
16 pr: number | null
17 decisions: number
18 needsYou: { kind: 'block' | 'decide' | 'authorize'; text: string } | null
19}
20
21export type StatusRound = { kind: 'full' | 'fix' | 're-plan' | 'open-findings'; n?: number; budget?: number }
22
23export type StatusWait = { kind: 'issue' | 'pr' | 'base' | 'time'; number: number | null; paths: string[] | null; until: string | null }
24
25/** The worked unit: the newest unstaged step, in flight or returned. */
26export type StatusWorkedUnit = {
27 issue: number
28 title: string | null
29 phase: string
30 role: string | null
31 model: string | null
32 fallback: boolean
33 round: StatusRound | null
34 inFlight: boolean
35 startedAtMs: number
36 returnedAtMs: number | null
37 waitingOn: StatusWait | null
38 decisions: number
39 /** The open loop pull request, from the scan or the deliver event. */
40 pr: number | null
41 /** The unit's steps this run, from the event file (SPEC-events item 5). */
42 steps: StatusStep[]
43}
44
45/** One step of the worked unit as the event file recorded it. */
46export type StatusStep = {
47 step: string
48 role: string | null
49 round: StatusRound | null
50 model: string | null
51 startedAtMs: number
52 /** When its dispatch returned; null while it runs or when none ran. */
53 returnedAtMs: number | null
54}
55
56/** One event of the run's log: the event file's line less `v` and `run`, with the PR beside the issue when known. */
57export type StatusEvent = {
58 at: number
59 kind: string
60 by: string
61 issue?: number
62 pr?: number
63 step?: string
64 round?: string
65 role?: string
66 model?: string
67 dispatch?: string
68 liveFile?: string
69 pid?: number
70 ms?: number
71 ok?: boolean
72 reason?: string
73 text?: string
74 paths?: string[]
75}
76
77/** A control the loop refused, as the producer lists it from `control/refused/` (SPEC-events item 9). */
78export type StatusRefused = {
79 /** The control file's name, the key the mod toasts it once by. */
80 name: string
81 kind: string | null
82 pr: number | null
83 issue: number | null
84 reason: string
85}
86
87/** A dispatch with a start and no end: running while its pid lives, died when it is gone. */
88export type StatusDispatch = {
89 dispatch: string
90 issue: number | null
91 pr: number | null
92 role: string | null
93 model: string | null
94 liveFile: string | null
95 pid: number | null
96 startedAtMs: number
97 state: 'running' | 'died'
98}
99
100/**
101 * What `tools/status.mjs --json --session <id>` prints: `owned: false` alone
102 * for a session that owns no run, the rest for the owner.
103 */
104export type StatusProjection = {
105 schema: string
106 owned: boolean
107 error?: string
108 enabled?: boolean
109 run?: {
110 openedAtMs: number
111 /** True while the operator's pause control holds the next selection. */
112 paused: boolean
113 parkedUntil: string | null
114 closedAt: string | null
115 snapshotScannedAt: string | null
116 snapshotAuthority: 'current' | 'history' | 'none'
117 }
118 phases?: string[]
119 unit?: StatusWorkedUnit | null
120 units?: StatusUnit[]
121 counts?: { active: number; waiting: number; blocked: number; delivered: number; eligible: number | null }
122 models?: Record<string, string>
123 /** The run's last 50 events, newest first. */
124 log?: StatusEvent[]
125 dispatches?: StatusDispatch[]
126 controls?: { refused: StatusRefused[] }
127}
128
129/** One active request as the proxy's `GET /status` lists it (its snake_case fields, read leniently). */
130export type ProxyRequest = {
131 id: string
132 startedAt: string | null
133 elapsedMs: number | null
134 requestedModel: string | null
135 model: string | null
136 sessionId: string | null
137}
138
139/**
140 * The proxy read, three states (SPEC-status-mod release two, item 10): `on`
141 * is a 200 with a parsed body and carries the active requests; `up` is any
142 * other answer (a 404, a body that does not parse); `off` is no answer within
143 * 2 s. Each is a fact, not a stale value: a read drops the last one.
144 */
145export type ProxyRead = { state: 'on' | 'up' | 'off'; requests: ProxyRequest[] }
146
147/**
148 * One dispatch's live tail as the mod keeps it (SPEC-central item 2). Growth
149 * is the tail's chunk differing from the last one, held in the tick's own
150 * memory: the chunk's length is no test of it, since at the 64 KiB cap every
151 * tail is the same length.
152 */
153export type StatusStream = {
154 /** The last 200 rendered lines of the live file, oldest first. */
155 lines: string[]
156 /** When the chunk last differed from the one before: quiet 120 s past it while the dispatch runs. */
157 lastGrewAtMs: number
158}
159
160/**
161 * A control awaiting its y or n (SPEC-central item 16): a cancel with the
162 * dispatch, its issue and its name as the question says it; a merge or an
163 * authorize (the `label` control) with its PR.
164 */
165export type StatusPending =
166 | { kind: 'cancel'; dispatch: string; issue: number | null; name: string }
167 | { kind: 'merge'; pr: number }
168 | { kind: 'authorize'; pr: number }
169
170/**
171 * The tick's clock, written on every tick under its own key so the state
172 * below is written only when a read changed it: the band draws `checked
173 * Ns ago` and the stale mark from here.
174 */
175export type StatusTick = {
176 /** When the last successful read finished; stale past 30 s. */
177 lastOkAtMs: number | null
178 /** How long the last tick took, successful or not. */
179 lastMs: number | null
180}
181
182/** The mod's session state: the last reads and what it has already shown. */
183export type StatusState = {
184 /** The last projection that parsed, kept through failed reads. */
185 last: StatusProjection | null
186 /** The last proxy read; null while ANTHROPIC_BASE_URL is unset (no column) or before the first tick. */
187 proxy: ProxyRead | null
188 /** Issues whose needs-you toast was shown in this session. */
189 seenBlocked: number[]
190 /** Refused controls (by file name) whose toast was shown in this session. */
191 seenRefused: string[]
192 /** A control the operator pressed and has not confirmed or dropped. */
193 pending: StatusPending | null
194 /** True while the pane `autoloop` is open and placed: the band then draws one row. */
195 paneOpen: boolean
196 /** True once the pane was opened unasked in this session (SPEC-central item 13): never twice. */
197 autoOpened: boolean
198 /** The live tails by dispatch id: every running dispatch's, and the last ended one's until session.end. */
199 streams: Record<string, StatusStream>
200 /** The central pane's tab, kept across a redraw (SPEC-central item 8). */
201 tab: 'run' | 'live' | 'units' | 'log'
202 /** The Log tab's filter. */
203 logFilter: 'all' | 'steps' | 'dispatches' | 'needsYou' | 'controls'
204 /** The Live tab's dispatch, by id; null means the first running one. */
205 selected: string | null
206 /** The Live tab follows the stream's end; a scroll turns it off. */
207 follow: boolean
208 /** The first stream line the Live tab shows while not following. */
209 liveOffset: number
210}
211
212declare module 'claude-code' {
213 interface PluginState {
214 autoloop: { status: StatusState; tick: StatusTick }
215 }
216}
217