SLOPSHOPPER

autoloop

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

newpanebandspinnerrowscommand
v0.72.0MITupdated 2026-10-14fabioneves/autoloop
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · autoloop
│ ┃ autoloop ✕ › fix the failing auth test and add an audit log call │ ┃ autoloop: this session owns no run │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /autoloop │ ⎿ autoloop: autoloop: this session owns no run │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · autoloop
autoloop: this session owns no run
README

Autoloop

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.

How it works

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:

  • A genuine human decision stops the unit: a trust or irreversible act (merge, secrets, destructive operations, protected paths, 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.
  • Something the loop can fix, it fixes. Work outside the unit's scope is filed as a loop-repair issue under the unit's own loop-ready, and the loop takes it next.
  • A judgment call — ambiguous wording, a design choice, a scope correction, a Critical at the review cap — it decides: it takes the recommended option, records the choice and why on the issue (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.

Models

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:

StepRoleModelEffortFallback
02 planplanastra[1m]highopus[1m]
03 plan reviewplan-reviewfable[1m]highsonnet[1m]
05 implementimplementopus[1m]highfable[1m]
06 simplifysimplifyfable[1m]highopus[1m]
07 diff reviewdiff-reviewastra[1m]highsonnet[1m]
08 code / doubt reviewcode-review, doubt-reviewastra[1m]highsonnet[1m]
08 fixesfixopus[1m]highfable[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.

Guardrails

<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">

GuardrailWhat it means
Trusted inputOnly a maintainer applies loop-ready; the loop never does. Editing the issue afterwards revokes it.
Independent reviewNothing is reviewed by the process that wrote it. Plans get one fresh review; code and every fix get fresh review.
Exact evidenceReview, gate, remote head, and every CI check and status agree on one SHA. Red or pending blocks delivery.
Human mergemanual 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.

Install

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.

First run

  1. Run setup. It writes .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.
  2. Write one small issue with objective acceptance criteria — by hand, or from a spec with the shape skill.
  3. Read it, finish it, then apply loop-ready last.
  4. Run one supervised unit with /autoloop:dev and tell it to take ONE issue and stop.
  5. Review and merge the PR like a teammate's.
  6. Then pick a cadence. Claude Code self-prompts:
   /loop 30m /goal <the stop condition in .autoloop/STATE.md>

A bare /autoloop:dev drains the whole eligible queue.

Skills

SkillPurpose
setupInit, devendor an earlier install, change config, or run the read-only doctor.
shapeTurn a spec or description into PR-sized issues, or lint one. Never labels.
queue-traceReconcile a spec against the issue queue. Read-only.
devOne loop-ready issue → one ready PR.
pitcrewFeedback, red CI, or conflict → the same PR, repaired and re-reviewed.
lean-codeSource stays lean; rationale lives in commits and PRs.
codebase-designDeep-module and seam vocabulary for planners and reviewers.

Central

In a session that owns a run, the plugin draws autoloop's own workplace inside Claude Code.

  • The band above the prompt is one card: the loop (phase, #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.
  • The pane (/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.
  • Controls: 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.
  • The spinner names the loop's state while a run is owned, and /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.

Events and control

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.

Configuration and merge policy

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.policyBehavior
manualDefault. The loop marks the PR ready; a human merges.
ratifiedSolo 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.
autoSolo 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.

What lives where

WhereWhat
.autoloop/config.jsonSettings, overrides only.
.autoloop/STATE.mdPolicy prose: mission, invariants, protected ground. Every run reads it.
.autoloop/checklist.mdOptional review criteria; the plugin's checklist when absent.
docs/agentic/ARCH.md, docs/agentic/LESSONS.mdArchitecture map and durable memory, yours to edit.
The pluginEvery 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 commentsloop-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.

How it stays safe

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.

Releases

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.

Source 9 files
hooks/status/register.tsx 372 lines
1// 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}
372
hooks/status/central.ts 424 lines
1// 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}
424
hooks/status/control.ts 103 lines
1// 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}
103
hooks/status/draw.tsx 248 lines
1// 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}
248
hooks/status/kit.ts 123 lines
1// 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}
123
hooks/status/read.ts 217 lines
1// 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}
217
hooks/status/view.ts 224 lines
1// 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}
224
hooks/status/render.ts 147 lines
1// 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}
147
types/index.d.ts 217 lines
1// 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