SLOPSHOPPER

rabe

Watch Claude Code's background work: subagents, Codex jobs, shells, monitors, cron jobs and workflows.

newpanebandspinnerguardcommand
v0.1.0MITupdated 2026-10-09lorenzh/rabe
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · rabe
│ ┃ Rabe ✕ › fix the failing auth test and add an audit log call │ ┃ 1: Items 0 2: Cost 3: Effects 4: Timelin │ ┃ ─━━━━━━━━━━───────────────────────────────── ⏺ Read(src/auth.ts) │ ┃ [ s ] [ x: stop ] [ g: stop group ] ⎿ Read 6 lines │ ┃ search: title, kind or command ⏎ filter ⏺ Update(src/auth.ts) │ ┃ Nothing runs in the background. ⎿ 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 │ ┃ │ ┃ › /rabe │ ┃ ⎿ rabe: Rabe opened. │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Rabe
1: Items 0 2: Cost 3: Effects 4: Timeline ─━━━━━━━━━━───────────────────────────────────────────── [ s ] [ x: stop ] [ g: stop group ] search: title, kind or command ⏎ filter Nothing runs in the background. tab to select · esc close
README

<img src="docs/assets/banner-light.svg" alt="rabe" width="308" height="84">

rabe

See what Claude Code runs in the background.

Rabe (German for raven) is a Claude Code mod. It shows your subagents, Codex jobs, background shells, monitors, cron jobs and workflows in one place. A short band above the prompt shows what runs now. The /rabe command opens a pane with the full detail.

Rabe in a Claude Code session: the band above the prompt lists two agents, three shells (one of them started by an agent), a monitor, a cron job and the cost; /rabe opens the pane, the arrow keys move through the list and the detail beside it follows, a click selects the agent's shell and a second click opens it, b goes back, then the Cost and Timeline tabs show, and Esc closes the pane

Status: early development. Version 0.1.0.

What Rabe shows

KindIn the bandIn the pane
Claude subagentsName and run timeTokens, share of the session, the prompt it got, each turn with its tool calls
WorkflowsCurrent phase and agent countPhases in order, the agents of each phase in start order with tokens and time; select an agent to open it
Codex jobs (from the Codex plugin)Name and run timeModel, effort and sandbox, tokens, the prompt, each message and command with its exit code
Background shellsCommand and the port it servesOutput lines, exit code, the guessed port
MonitorsNameEach line with the time Rabe received it
Cron jobs and /loop wakeupsPrompt and the time of the next run (due for a wakeup that waits)The next five runs
CostSession cost and tokens of the background workCost tab: tokens per worker, workers that look stuck

The band puts failures from the last 10 minutes first. Shells and monitors that an agent started come after the others, with the name of that agent. When nothing runs, it draws nothing. When its rows do not fit, it draws one line of counts.

The pane has four tabs:

  • Items: all items grouped by kind, with a search field. Shells and monitors that an agent started show under the name of that agent. If the pane is 90 columns or wider, the selected item shows beside the list.
  • Cost: the session cost as /cost totals it, and a bar of tokens per agent and Codex job. Agents that look slow or stuck show under "Load".
  • Effects: files that agents edited, a warning when two agents edit the same file, worktrees, and open ports with the ssh -L command to reach them. Enter or a click on a file opens the agent that edited it last; on an ssh -L line it copies the line (c copies the first).
  • Timeline: a bar per item over the session, who started what, and a summary of the previous session in this project.

Install

Type this at a Claude Code prompt:

/plugin install rabe --marketplace lorenzh/rabe

Then run /reload-plugins or start a new session: a plugin you install does not load in the running session. The Claude desktop app needs this too.

Usage

The band shows by itself while background work runs. Type /rabe to open the pane. In the terminal the pane docks beside the transcript. Drag its left edge to make it wider.

KeyWhat it does
↑ / ↓Move to the previous / next row; the detail beside the list follows
EnterOpen the row that has the focus, also after a click selected another row
Click on a rowSelect the row; click it again to open it. A click on the row that has the focus opens it at once
Tab / Shift+TabMove the focus through rows, tabs and buttons
bGo back to the list
1 to 4Switch tab
sSearch
xStop the selected item
gStop the group the selected row is in (the rows the search shows), or the workflow run of the selected run or workflow agent
mSend a message to an agent
cCopy the command, the prompt or the ssh -L line
dDelete a cron job
Mouse wheelScroll the pane
EscClose the pane

The buttons under the tabs show the keys of the current tab. A key that cannot act now is dim, and pressing it does nothing. A letter with no button goes to the prompt.

With the mouse, click a row once to select it and again to open it. Click a tab to switch to it, a group name to fold the group, a button to press it, or a row on the other tabs to open it. The wheel scrolls the pane. A click does not move the focus, so Enter still opens the row that has the focus. A click also does not give the pane the keys: ctrl+x tab does. The mouse works only in Claude Code's fullscreen layout: type /tui fullscreen to turn it on. Without it, use the keys.

On the list, the stop keys x and g work only after you move onto a row yourself, with an arrow, Tab or a click. Until then, and after the view or the selected row changes without you, they are dim, so a key press never stops something you did not pick. If they stay dim after a click, the pane does not have the keys yet: press ctrl+x tab, then move onto the row with Tab or an arrow.

While the pane is open, rows and buttons keep their places, so Enter acts on what you see under the focus. Items that start after you open the pane go to a NEW group at the end (on the Effects tab, to a NEW section). A row that is gone stays as a dim gone slot. Type /rabe again to sort the lists.

Commands:

  • /rabe: open the pane.
  • /rabe-stop codex:<job id>: cancel a running Codex job. x in the pane does the same.
  • /rabe:report-bug: write a bug report for this repository.
  • /rabe:feedback: write an idea or a comment for this repository.

The two report commands show you a draft first. They create a public GitHub issue only after you say yes. Without the gh CLI they give you a link to open instead.

Rabe hides Claude Code's own count of background work, because the band shows it. That is the 2 shells, 1 monitor · ↓ to manage part under the prompt, and still running at the end of a turn. To keep them, turn off the option hideBuiltinTasks in /plugin. Rabe cannot hide the agent list under the prompt.

Where the data comes from

Rabe gets most data from the mod API: hooks for tool calls, agent starts, agent steps and task notifications. The rest comes from files that Claude Code and Codex write while they work. These files have no documentation and can change in any release. A value that Rabe cannot read shows as n/a.

Rabe cannot see some things:

  • Dollars per agent or per Codex job. No source gives them, and Rabe has no price table yet.
  • Work that started before Rabe loaded shows only what the agent list, CronList and the files tell.
  • The port of a shell is a guess from its output.
  • Codex jobs started outside the Codex plugin, and Codex sessions that Codex already deleted.
  • Retries inside a workflow. Rabe can stop a whole workflow run, but not one agent of it.

What Rabe can see lists each source and each limit.

Privacy

Rabe runs on your computer only. It makes no network requests and sends nothing. The two report commands create an issue only after you approve the draft.

Rabe reads these files:

  • The meta files of subagents, next to the session transcript in ~/.claude/projects/.
  • The script of a workflow run, for its phase names.
  • The output files of background shells and monitors, under /tmp/claude-<uid>/.
  • The Codex plugin's job files under ~/.claude/plugins/data/codex-openai-codex/, and ~/.claude/plugins/installed_plugins.json.
  • Codex session files under ~/.codex/sessions/ (or CODEX_HOME).

Rabe keeps its items in the session state of Claude Code. When a session ends, Rabe keeps a short summary for the next session in the same project. The summary holds counts per kind, tokens, cost and the names of failed items. It runs tail and grep to read files over 4 MiB. When you stop a Codex job, it runs the Codex plugin's own cancel script.

Requirements

  • Claude Code 2.1.295. We build and test Rabe with this version. Other versions can show n/a where a file changed.
  • A terminal for the colored band and pane. The Claude desktop app draws the same pane with plain text and buttons (tested by the maintainer on Windows with Rabe 0.1.0).
  • Optional: the Codex plugin (codex@openai-codex) for Codex jobs, and the gh CLI for the report commands.

Development

Install the tools with Bun 1.4. Then start Claude Code with the mod from this folder. When you save a file, the mod reloads.

bun install
claude --plugin-dir .

Run all checks (lint, type check, manifest check and tests), as CI does:

bun run check

AGENTS.md has the rules for contributors and coding agents. How Rabe is built describes the code.

Feedback and bugs

In Claude Code, /rabe:report-bug and /rabe:feedback draft an issue for this repo. You check the draft before anything is sent. They use the gh CLI when you are logged in, and otherwise give you a link to a prefilled issue. You can also open an issue directly.

License

MIT

Source 31 files
hooks/register.tsx 14 lines
1import type { Register } from 'claude-code'
2
3import { sources } from './sources'
4import { band } from './ui/band'
5import { builtin } from './ui/builtin'
6import { pane } from './ui/pane'
7
8export const register: Register = (on, options) => {
9  sources(on)
10  band(on)
11  pane(on)
12  if (options.hideBuiltinTasks !== false) builtin(on)
13}
14
hooks/sources/index.ts 20 lines
1import type { On } from 'claude-code'
2
3import { agents } from './agents'
4import { codex } from './codex'
5import { crons } from './crons'
6import { monitors } from './monitors'
7import { shells } from './shells'
8import { workflows } from './workflows'
9
10export type Source = (on: On) => void
11
12export function sources(on: On): void {
13  agents(on)
14  workflows(on)
15  codex(on)
16  shells(on)
17  monitors(on)
18  crons(on)
19}
20
hooks/ui/band.tsx 24 lines
1import type { On } from 'claude-code'
2
3import { render } from './render'
4import { bounded, NO_SELECTION } from './view'
5import { bandView } from './views/band'
6
7export function band(on: On): void {
8  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
9    if (e.props.hasSurvey) return next(e)
10    const { value: items = [] } = await $.state.get({ plugin: 'rabe', key: 'items' })
11    if (!items.some(item => item.status === 'running')) return next(e)
12    const usage = await $.session.usage().catch(() => undefined)
13    const model = { items, turns: {}, lines: {}, now: await $.clock.now(), usd: usage?.cost?.usd }
14    const size = bounded({
15      columns: e.props.bodyColumns,
16      rows: e.props.maxRows,
17      surface: e.surface,
18      hasInput: false,
19    })
20
21    return render($.ui.resolve(e), e.surface, bandView(model, size, NO_SELECTION), () => {}, 'band')
22  })
23}
24
hooks/ui/builtin.tsx 57 lines
1import type { On } from 'claude-code'
2
3const TASK =
4  /^\d+ (background |dynamic )?(shell|monitor|agent|subagent|workflow|task|teammate|MCP task|cloud session)s?$/
5
6// Removes the part of Claude Code's prompt hint that counts background work
7// ("2 shells, 1 monitor") and its "↓ to manage"; keeps every other part.
8export function stripTasks(hint: string): string {
9  return hint
10    .split(' · ')
11    .filter(part => part !== '↓ to manage' && !part.split(', ').every(one => TASK.test(one)))
12    .join(' · ')
13}
14
15// The engine's duration format on the turn line: 5s, 1m 4s, 1h 2m 5s.
16export function turnDuration(ms: number): string {
17  const s = Math.floor(ms / 1000)
18  const h = Math.floor(s / 3600)
19  const m = Math.floor(s / 60) % 60
20  if (h > 0) return `${h}h ${m}m ${s % 60}s`
21
22  return m > 0 ? `${m}m ${s % 60}s` : `${s}s`
23}
24
25// Hides Claude Code's own count of background work, since the band shows it.
26export function builtin(on: On): void {
27  on('ui.render', { component: 'PromptHint' }, async (_$, e, next) => {
28    const hint = stripTasks(e.props.hint)
29
30    return hint === e.props.hint ? next(e) : next({ ...e, props: { ...e.props, hint } })
31  })
32
33  // The line's "N shells, M monitors still running" part is not a prop, so the
34  // hook draws the line itself, only while Rabe knows of a running shell or
35  // monitor and no running agent or workflow (then the engine waits for those
36  // and draws no such part). That line leaves out "done 1:16": no prop has it.
37  on('ui.render', { component: 'TurnDuration' }, async ($, e, next) => {
38    const { value: items = [] } = await $.state.get({ plugin: 'rabe', key: 'items' })
39    const running = items.filter(item => item.status === 'running')
40    const isWaiting = running.some(item => item.kind === 'agent' || item.kind === 'workflow')
41    const hasTasks = running.some(item => item.kind === 'shell' || item.kind === 'monitor')
42    if (isWaiting || !hasTasks) return next(e)
43    const { Box, Text } = $.ui.resolve(e)
44
45    return (
46      <Box flexDirection="row" marginTop={1} width="100%">
47        <Box minWidth={2}>
48          <Text dimColor>✻</Text>
49        </Box>
50        <Text dimColor>
51          {e.props.word} for {turnDuration(e.props.durationMs)}
52        </Text>
53      </Box>
54    )
55  })
56}
57
hooks/ui/pane.tsx 442 lines
1import type { EngineInterface, On, RenderSurface } from 'claude-code'
2
3import type { RabePrevious } from '../../types'
4import { KIND_LABEL, orderOf, previousOf } from './lists'
5import { type At, type Held, hold, render } from './render'
6import {
7  type Action,
8  type ArmEvent,
9  type Arming,
10  arm,
11  bounded,
12  DISARMED,
13  landing,
14  landingOf,
15  layout,
16  type Model,
17  rowKeys,
18  type Selection,
19  stepRow,
20  targetsOf,
21  taskIdOf,
22} from './view'
23import { fallbackOf, isLiveRow, paneView, selectsOnPress } from './views/pane'
24
25const PANE = 'rabe'
26
27// Esc may have closed the pane before the delayed focus call.
28async function refocus($: EngineInterface): Promise<void> {
29  const pane = (await $.ui.panes()).find(one => one.id === PANE)
30  if (!pane || pane.isFocused) return
31  await $.ui.open({ id: PANE, title: 'Rabe', focus: true, closeOnEscape: true })
32}
33
34async function tick($: EngineInterface): Promise<void> {
35  const { value: items = [] } = await $.state.get({ plugin: 'rabe', key: 'items' })
36  if (items.some(item => item.status === 'running')) $.ui.invalidate('ui.render')
37}
38
39// The store is the plugin's own JSON; an older Rabe may have written another shape.
40function asPrevious(value: unknown): RabePrevious | undefined {
41  const prev = value as RabePrevious | undefined
42  return typeof prev?.endedAt === 'number' && Array.isArray(prev.failed) && prev.counts
43    ? prev
44    : undefined
45}
46
47async function previous($: EngineInterface): Promise<RabePrevious | undefined> {
48  return asPrevious(await $.store.get(`previous:${await $.session.cwd()}`))
49}
50
51// Keeps this session's summary for the next one in this project; a session
52// with no background work leaves the last summary in place.
53async function remember($: EngineInterface): Promise<void> {
54  const { value: items = [] } = await $.state.get({ plugin: 'rabe', key: 'items' })
55  if (items.length === 0) return
56  const usage = await $.session.usage()
57  const summary = previousOf(items, await $.clock.now(), {
58    startedAt: usage.startedAt,
59    usd: usage.cost?.usd,
60  })
61  await $.store.set(`previous:${await $.session.cwd()}`, summary)
62}
63
64async function stop($: EngineInterface, ids: string[]): Promise<void> {
65  const { value: items = [] } = await $.state.get({ plugin: 'rabe', key: 'items' })
66  const targets = items.filter(item => ids.includes(item.id))
67  const one = targets.length === 1 ? targets[0] : undefined
68  if (one) $.ui.toast(`Stopping ${KIND_LABEL[one.kind]} ${one.title}…`)
69  let stopped = 0
70  let ended = 0
71  let refused: string | undefined
72  for (const item of targets) {
73    if (item.kind === 'codex' && item.status === 'running') {
74      const { text = 'n/a' } = await $.command
75        .run({ command: 'rabe-stop', args: item.id })
76        .catch((error: unknown) => ({ text: `Stop refused: ${String(error)}` }))
77      if (text.startsWith('Stopped')) stopped += 1
78      else refused = text.replace(/^Stop refused: /, '')
79      continue
80    }
81    const task = taskIdOf(item)
82    if (item.status !== 'running' || !task) {
83      ended += 1
84      continue
85    }
86    try {
87      const result = await $.tool.call({ tool: 'TaskStop', task_id: task })
88      if (result.deny) refused = result.deny
89      else if (result.isError) refused = result.text ?? 'n/a'
90      else stopped += 1
91    } catch (error) {
92      refused = error instanceof Error ? error.message : String(error)
93    }
94  }
95  if (one && refused) $.ui.toast(`Stop refused: ${refused}`)
96  else if (one) $.ui.toast(ended ? `${one.title} had already finished` : `Stopped ${one.title}`)
97  else {
98    const done = ended ? `; ${ended} had already finished` : ''
99    const no = refused ? `; refused: ${refused}` : ''
100    $.ui.toast(`Stopped ${stopped} of ${targets.length}${done}${no}`)
101  }
102}
103
104// Whether stop and delete act: the ring is known to sit on a safe element.
105// A module value, so a reload, which also drops the hold, starts disarmed.
106let arming: Arming = DISARMED
107
108// Where the last `ui.focus` on the pane landed, as the pane's focus hook read it.
109let landedOn: string | undefined
110
111// The element that holds the focus ring, as far as the pane's focus hook saw
112// it land; unknown after a reset, whose view puts another element at its index.
113let ringOn: string | undefined
114
115function feed($: EngineInterface, event: ArmEvent): void {
116  const was = arming
117  arming = arm(arming, event)
118  if (event.type === 'reset') ringOn = undefined
119  if (arming.isArmed !== was.isArmed || arming.isListArmed !== was.isListArmed) {
120    $.ui.invalidate('ui.render')
121  }
122}
123
124// Moves the ring onto one of the pane's safe elements. Refused while the pane
125// does not hold the keys (Enter then goes to the prompt) or when another hook
126// says no: either way the ring may sit anywhere, so the pane disarms.
127async function focusOn($: EngineInterface, key: string): Promise<boolean> {
128  landedOn = undefined
129  const result = await $.ui.focus({ requestId: PANE, key }).catch(() => ({ deny: 'threw' }))
130  const isMoved = !('deny' in result && result.deny) && landedOn === key
131  feed($, { type: 'landed', isMoved })
132
133  return isMoved
134}
135
136// A new view starts its hold anew, but the ring keeps its index, where the new
137// view may draw a stop; so the pane disarms and the ring moves (`landing`).
138async function land($: EngineInterface, keys: string[]): Promise<void> {
139  for (const key of keys) if (!(await focusOn($, key))) return
140}
141
142// A press on a live row of the Items list away from the ring selects it: a
143// click moves no ring, so the press is the person's choice of that row. The
144// ring's row and the selected row open.
145async function choose($: EngineInterface, id: string): Promise<boolean> {
146  const { model, selection } = await look($, true)
147  if (!selectsOnPress(model, selection, id, ringOn)) return false
148  await $.state.set({ plugin: 'rabe', key: 'selected' }, id)
149  feed($, { type: 'press' })
150
151  return true
152}
153
154async function act($: EngineInterface, action: Action, surface: RenderSurface): Promise<void> {
155  if (action.type === 'open' && (await choose($, action.id))) return
156  if (landing(action, '').length > 0) feed($, { type: 'reset' })
157  switch (action.type) {
158    case 'tab':
159      await $.state.set({ plugin: 'rabe', key: 'tab' }, action.tab)
160      return land($, landing(action, ''))
161    case 'fold': {
162      const { value: folded = [] } = await $.state.get({ plugin: 'rabe', key: 'folded' })
163      const next = folded.includes(action.group)
164        ? folded.filter(group => group !== action.group)
165        : [...folded, action.group]
166      await $.state.set({ plugin: 'rabe', key: 'folded' }, next)
167      return land($, landing(action, ''))
168    }
169    case 'open': {
170      // Back selects the item that was open: the focus may have moved onto a
171      // row inside its detail (a workflow's agents).
172      const { value: was = '' } = await $.state.get({ plugin: 'rabe', key: 'open' })
173      const id = action.id || was
174      if (id) {
175        await $.state.set({ plugin: 'rabe', key: 'selected' }, id)
176        await $.state.set({ plugin: 'rabe', key: 'tab' }, 'items')
177      }
178      await $.state.set({ plugin: 'rabe', key: 'open' }, action.id)
179      return land($, landing(action, was))
180    }
181    case 'query':
182      await $.state.set({ plugin: 'rabe', key: 'query' }, action.text)
183      return land($, landing(action, ''))
184    case 'focus':
185      await focusOn($, action.key)
186      return
187    case 'stop':
188      await stop($, action.ids)
189      return
190    case 'delete': {
191      const { value: items = [] } = await $.state.get({ plugin: 'rabe', key: 'items' })
192      const item = items.find(one => one.id === action.id)
193      if (item?.kind !== 'cron' || item.detail.scheduledFor !== undefined) return
194      const result = await $.tool.call({ tool: 'CronDelete', id: item.detail.jobId })
195      const reason = result.deny ?? (result.isError ? (result.text ?? 'n/a') : undefined)
196      $.ui.toast(reason ? `Delete refused: ${reason}` : `Deleted cron ${item.title}`)
197      return
198    }
199    case 'copy': {
200      const result = await $.ui.copy({ text: action.text, surface })
201      $.ui.toast(result.isCopied ? `Copied: ${action.text}` : `Copy failed: ${result.reason}`)
202      return
203    }
204    case 'none':
205      return
206    case 'message': {
207      const { value: items = [] } = await $.state.get({ plugin: 'rabe', key: 'items' })
208      const item = items.find(one => one.id === action.id)
209      if (item?.kind !== 'agent' || !action.text.trim()) return
210      const sent = await $.session.send({ to: { agentId: item.detail.agentId }, text: action.text })
211      $.ui.toast(
212        sent.isDelivered
213          ? `Message sent to claude ${item.title}`
214          : `Message not sent: ${sent.reason}`,
215      )
216      return
217    }
218  }
219}
220
221// What the pane draws from: the sources' values and the person's place.
222async function look(
223  $: EngineInterface,
224  isFocused: boolean,
225): Promise<{ model: Model; selection: Selection }> {
226  const { value: items = [] } = await $.state.get({ plugin: 'rabe', key: 'items' })
227  const { value: turns = {} } = await $.state.get({ plugin: 'rabe', key: 'turns' })
228  const { value: lines = {} } = await $.state.get({ plugin: 'rabe', key: 'lines' })
229  const { value: tab = 'items' } = await $.state.get({ plugin: 'rabe', key: 'tab' })
230  const { value: query = '' } = await $.state.get({ plugin: 'rabe', key: 'query' })
231  const { value: folded = [] } = await $.state.get({ plugin: 'rabe', key: 'folded' })
232  const { value: selected = '' } = await $.state.get({ plugin: 'rabe', key: 'selected' })
233  const { value: open = '' } = await $.state.get({ plugin: 'rabe', key: 'open' })
234  const { value: order } = await $.state.get({ plugin: 'rabe', key: 'order' })
235  const usage = await $.session.usage().catch(() => undefined)
236  const model = {
237    items,
238    turns,
239    lines,
240    now: await $.clock.now(),
241    usd: usage?.cost?.usd,
242    previous: await previous($).catch(() => undefined),
243  }
244
245  const selection = {
246    tab,
247    query,
248    folded,
249    selected,
250    open,
251    isFocused,
252    isArmed: arming.isArmed,
253    isListArmed: arming.isListArmed,
254    ...(order && { order }),
255  }
256
257  return { model, selection }
258}
259
260// The view the person chose. A change of it is theirs, so its hold starts anew.
261const scopeOf = (sel: Selection) => JSON.stringify([sel.tab, sel.open, sel.query, sel.folded])
262
263// Per surface, the focusable keys the pane drew since it opened, for one view
264// (`scope`). A render hook may not write `$.state` (drawing is pure), so this
265// is a module value: a reload starts the hold anew, as a new drawing would.
266const holds = new Map<string, { scope: string; keys: Held[] }>()
267
268// Per surface, where the pane sat and whether it held the keys at the last
269// drawing. A move between dock and inline takes the keys from the pane.
270const seats = new Map<string, { placement: string; isFocused: boolean }>()
271
272// The presses and Input events under way, per surface and element, oldest
273// first. Each event's hook adds its own slot before `next(e)`; the closure
274// beneath, which the engine runs with the event, fills the oldest empty slot of
275// its element, so an event carries out only an action of its own element.
276// ponytail: two events on one element in flight at once may swap their
277// actions; both are that element's, and each runs exactly once.
278type Slot = { press?: { action: Action; surface: RenderSurface } }
279const waiting = new Map<string, Slot[]>()
280
281const slotOf = (at: At) => `${at.surface} ${at.element}`
282
283function leave(action: Action, at: At): void {
284  const slot = waiting.get(slotOf(at))?.find(one => !one.press)
285  if (slot) slot.press = { action, surface: at.surface }
286}
287
288// Runs a press or an Input event beneath the hook, then carries out the action
289// its closure left in this event's own slot with the hook's `$`.
290async function carry<R>($: EngineInterface, at: At, run: () => Promise<R>): Promise<R> {
291  const key = slotOf(at)
292  const mine: Slot = {}
293  waiting.set(key, [...(waiting.get(key) ?? []), mine])
294  let result: R
295  try {
296    result = await run()
297  } finally {
298    const rest = (waiting.get(key) ?? []).filter(one => one !== mine)
299    if (rest.length > 0) waiting.set(key, rest)
300    else waiting.delete(key)
301  }
302  if (mine.press) await act($, mine.press.action, mine.press.surface)
303
304  return result
305}
306
307const heldOf = (surface: RenderSurface, sel: Selection): Held[] | undefined => {
308  const mine = holds.get(surface)
309  return mine?.scope === scopeOf(sel) ? mine.keys : undefined
310}
311
312// An arrow key in a pane taller than its body scrolls it a row; Rabe moves
313// the focus to the next or previous row instead, and the pane follows the
314// focus. Past either end the scroll goes on, to show what is above or below.
315async function arrow($: EngineInterface, by: number, bodyRows: number): Promise<boolean> {
316  const { model, selection } = await look($, true)
317  const size = { columns: 80, rows: bodyRows, surface: 'terminal', hasInput: true } as const
318  const drawn = layout(paneView(model, size, selection))
319  const key = stepRow(
320    rowKeys(hold(drawn, heldOf('terminal', selection)).list),
321    selection.selected,
322    by,
323  )
324  if (!key) return false
325  feed($, { type: 'step', key })
326  void focusOn($, key)
327
328  return true
329}
330
331export function pane(on: On): void {
332  on('session.start', async ($, e, next) => {
333    await $.command.register({
334      name: 'rabe',
335      description:
336        'Show background work: agents, Codex jobs, shells, monitors, cron jobs and workflows',
337    })
338    $.clock.every(1000, () => void tick($))
339
340    return next(e)
341  })
342
343  // Each open sorts the lists once; then they hold their order (see `stable`).
344  on('command.run', { command: 'rabe' }, async $ => {
345    const { value: items = [] } = await $.state.get({ plugin: 'rabe', key: 'items' })
346    await $.state.set({ plugin: 'rabe', key: 'order' }, orderOf(items))
347    holds.clear()
348    feed($, { type: 'reset' })
349    await $.ui.open({ id: PANE, title: 'Rabe', closeOnEscape: true })
350    $.clock.after(1500, () => void refocus($).catch(() => undefined))
351
352    return { text: 'Rabe opened.' }
353  })
354
355  on('session.end', { reason: /^/ }, async ($, e, next) => {
356    await remember($)
357
358    return next(e)
359  }).catch((_$, e, next) => next(e))
360
361  // The ring lands where the engine's link received the move (`landingOf`),
362  // maybe not `e.element`; the selection follows it. A move that never
363  // reached the engine left the ring where it was, so the pane disarms.
364  on('ui.focus', { requestId: PANE }, async ($, e, next) => {
365    const { value: before = '' } = await $.state.get({ plugin: 'rabe', key: 'selected' })
366    const asked = e.element?.startsWith('row:') ? e.element.slice(4) : before
367    if (asked !== before) await $.state.set({ plugin: 'rabe', key: 'selected' }, asked)
368    const result = await next(e)
369    const landing = landingOf(next.trace, 'deny' in result && !!result.deny)
370    const landed = landing?.element
371    const selected = landed?.startsWith('row:') ? landed.slice(4) : before
372    if (selected !== asked) await $.state.set({ plugin: 'rabe', key: 'selected' }, selected)
373    landedOn = landed
374    if (landing) ringOn = landed
375    if (!landing) feed($, { type: 'landed', isMoved: false })
376    else if (landed) {
377      const { model, selection } = await look($, true)
378      feed($, {
379        type: 'focus',
380        byPerson: e.origin.kind === 'person',
381        key: landed,
382        requested: e.element,
383        isLiveRow: isLiveRow(model, selection, landed),
384        selected: selection.selected,
385      })
386    }
387
388    return result
389  }).catch((_$, e, next) => next(e))
390
391  // Rabe's own `$.ui.focus` from a Button's or Input's closure reaches no
392  // hook of Rabe's, so its landings would never count; calls from these do.
393  on('ui.press', { requestId: PANE }, ($, e, next) => carry($, e, () => next(e))).catch(
394    (_$, e, next) => next(e),
395  )
396  on('ui.input', { requestId: PANE }, ($, e, next) => carry($, e, () => next(e))).catch(
397    (_$, e, next) => next(e),
398  )
399
400  on('ui.scroll', { requestId: PANE }, async ($, e, next) => {
401    if (e.pointer || Math.abs(e.by) !== 1) return next(e)
402    if (await arrow($, e.by, e.bodyRows).catch(() => false)) return {}
403
404    return next(e)
405  })
406
407  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
408    const ui = $.ui.resolve(e)
409    const seat = seats.get(e.surface)
410    seats.set(e.surface, { placement: e.props.placement, isFocused: e.props.isFocused })
411    if (seat?.isFocused && seat.placement !== e.props.placement) {
412      $.clock.after(500, () => void refocus($).catch(() => undefined))
413    }
414    const { model, selection: seen } = await look($, e.props.isFocused)
415    const size = bounded({
416      columns: e.props.bodyColumns,
417      rows: e.props.scroll?.bodyRows || 24,
418      surface: e.surface,
419      hasInput: 'Input' in ui,
420      ...(e.props.scroll && {
421        window: { top: e.props.scroll.offset, rows: e.props.scroll.bodyRows },
422      }),
423    })
424
425    // What x and g would act on decides whether they may.
426    const armed = paneView(model, size, { ...seen, isArmed: true, isListArmed: true })
427    const { selected } = seen
428    feed($, {
429      type: 'drawn',
430      fallback: fallbackOf(model, seen),
431      targets: targetsOf(armed),
432      selected,
433    })
434    const selection = { ...seen, isArmed: arming.isArmed, isListArmed: arming.isListArmed }
435    const drawn = arming.isArmed && arming.isListArmed ? armed : paneView(model, size, selection)
436    const out = hold(layout(drawn), heldOf(e.surface, selection))
437    holds.set(e.surface, { scope: scopeOf(selection), keys: out.held })
438
439    return render(ui, e.surface, out.list, leave)
440  })
441}
442
hooks/sources/agents.ts 380 lines
1import type {
2  AgentInfo,
3  AgentStatus,
4  EngineInterface,
5  On,
6  TurnCompleteReason,
7  TurnStepResult,
8  TurnUsage,
9} from 'claude-code'
10
11import type { RabeToolUse, RabeTurn } from '../../types'
12import {
13  clip,
14  type EndStatus,
15  itemId,
16  type RabeItem,
17  type RabeItemOf,
18  type RabeItemStatus,
19  type RabeTokens,
20} from '../model'
21import { addItem, type Change, commit, endItem, pastEnd, prune, updateItem } from '../registry'
22
23type Turns = Record<string, RabeTurn[]>
24type AgentItem = RabeItemOf<'agent'>
25
26const POLL_MS = 3000
27const MAX_TURNS = 30
28const MAX_EDITS = 100
29const MAX_TEXT = 300
30const MAX_PROMPT = 600
31const MAX_SUMMARY = 80
32const SUMMARY_FIELDS = ['file_path', 'command', 'pattern', 'path', 'url', 'query', 'description']
33const META_FIELDS = ['cwd', 'worktreePath', 'worktreeBranch', 'workflowPhase'] as const
34
35const LISTED: Record<AgentStatus, RabeItemStatus> = {
36  pending: 'running',
37  running: 'running',
38  waiting: 'running',
39  idle: 'running',
40  completed: 'done',
41  failed: 'failed',
42  killed: 'stopped',
43}
44
45const ENDED: Record<TurnCompleteReason, EndStatus> = {
46  answer: 'done',
47  aborted: 'stopped',
48  refusal: 'failed',
49  error: 'failed',
50}
51
52export function toolSummary(input: unknown): string | undefined {
53  if (!input || typeof input !== 'object') return undefined
54  const fields = input as Record<string, unknown>
55  const name = SUMMARY_FIELDS.find(one => typeof fields[one] === 'string')
56  if (!name) return undefined
57  const line = String(fields[name]).split('\n')[0] ?? ''
58
59  // Effects tells files apart by the whole path.
60  return name === 'file_path' ? line : line.slice(0, MAX_SUMMARY)
61}
62
63export function metaPatch(text: string): Partial<AgentItem['detail']> | undefined {
64  try {
65    const meta = JSON.parse(text) as Record<string, unknown>
66
67    return Object.fromEntries(
68      META_FIELDS.filter(name => typeof meta[name] === 'string').map(name => [name, meta[name]]),
69    )
70  } catch {
71    return undefined
72  }
73}
74
75export function metaPath(item: AgentItem, items: RabeItem[]): string | undefined {
76  if (item.detail.transcriptPath)
77    return item.detail.transcriptPath.replace(/\.jsonl$/, '.meta.json')
78  const parent = items.find(one => one.id === item.parentId)
79  if (parent?.kind !== 'workflow' || !parent.detail.transcriptDir) return undefined
80
81  return `${parent.detail.transcriptDir}/agent-${item.detail.agentId}.meta.json`
82}
83
84export function agentTranscript(path: string, agentId: string): string | undefined {
85  if (path.endsWith(`/agent-${agentId}.jsonl`)) return path
86  if (!path.endsWith('.jsonl')) return undefined
87
88  return `${path.slice(0, -'.jsonl'.length)}/subagents/agent-${agentId}.jsonl`
89}
90
91// The file an Edit or Write changed: the engine ran it (a result, no error) and
92// did not only stage it for review. The path comes from the result when it has one.
93export function changedFile(
94  answer: { result?: unknown; isError?: boolean },
95  input: string,
96): string | undefined {
97  if (answer.isError || !answer.result || typeof answer.result !== 'object') return undefined
98  const result = answer.result as { filePath?: unknown; staged?: unknown }
99  if (result.staged === true) return undefined
100
101  return typeof result.filePath === 'string' ? result.filePath : input
102}
103
104// An Edit or Write that changed a file for an agent; its newest MAX_EDITS are kept.
105function edited(items: RabeItem[], id: string, path: string, at: number): RabeItem[] {
106  const agent = asAgent(items, id)
107  if (!agent) return items
108  const edits = [...(agent.detail.edits ?? []), { path, at }].slice(-MAX_EDITS)
109
110  return updateItem(items, id, { detail: { edits } })
111}
112
113export function addTurn(turns: Turns, id: string, turn: RabeTurn): Turns {
114  return { ...turns, [id]: [...(turns[id] ?? []), turn].slice(-MAX_TURNS) }
115}
116
117function addUsage(tokens: RabeTokens | undefined, usage: TurnUsage): RabeTokens {
118  return {
119    input:
120      (tokens?.input ?? 0) +
121      usage.input_tokens +
122      usage.cache_creation_input_tokens +
123      usage.cache_read_input_tokens,
124    output: (tokens?.output ?? 0) + usage.output_tokens,
125    cached: (tokens?.cached ?? 0) + usage.cache_read_input_tokens,
126  }
127}
128
129function asAgent(items: RabeItem[], id: string): AgentItem | undefined {
130  const item = items.find(one => one.id === id)
131
132  return item?.kind === 'agent' ? item : undefined
133}
134
135// Statuses of a turn in flight: an ended agent in one was resumed. `idle` is
136// not one: a teammate is idle after each turn, which already ended its item.
137const RESUMED: AgentStatus[] = ['pending', 'running', 'waiting']
138
139// An ended agent Rabe holds no item for takes `end` (see `pastEnd`), and none
140// is added when `end` is undefined, so a poll never gives one a new recency.
141function listed(
142  items: RabeItem[],
143  info: AgentInfo,
144  now: number,
145  end: number | undefined,
146): RabeItem[] {
147  const id = itemId('agent', info.id)
148  const status = LISTED[info.status] ?? 'running'
149  const held = asAgent(items, id)
150  if (!held) {
151    if (status !== 'running' && end === undefined) return items
152    return addItem(
153      items,
154      {
155        id,
156        kind: 'agent',
157        title: info.description || info.type,
158        status,
159        ...(status !== 'running' && { endedAt: end }),
160        parentId: info.parentId ? itemId('agent', info.parentId) : undefined,
161        detail: { agentId: info.id, type: info.type, description: info.description },
162      },
163      now,
164    )
165  }
166  if (held.status !== 'running' && RESUMED.includes(info.status)) {
167    return updateItem(items, id, { status: 'running', endedAt: undefined })
168  }
169
170  return status === 'running' ? items : endItem(items, id, status, now)
171}
172
173function stepped(
174  items: RabeItem[],
175  id: string,
176  result: TurnStepResult,
177  tools: RabeToolUse[],
178  now: number,
179): RabeItem[] {
180  const item = asAgent(items, id)
181  if (!item) return items
182  const last = tools.at(-1)
183
184  return updateItem(items, id, {
185    status: 'running',
186    endedAt: undefined,
187    tokens: result.usage ? addUsage(item.tokens, result.usage) : item.tokens,
188    detail: {
189      model: result.usage?.model ?? item.detail.model,
190      toolCount: (item.detail.toolCount ?? 0) + tools.length,
191      lastTool: last?.name ?? item.detail.lastTool,
192      lastToolAt: last ? now : item.detail.lastToolAt,
193    },
194  })
195}
196
197async function write($: Pick<EngineInterface, 'state'>, change: Change): Promise<void> {
198  for (;;) {
199    const { value, version } = await $.state.get({ plugin: 'rabe', key: 'items' })
200    const next = commit(value, change)
201    if (next === undefined) return
202    const { isSet } = await $.state.set({ plugin: 'rabe', key: 'items' }, next.items, {
203      ifVersion: version,
204    })
205    if (isSet) return forget($, next.dropped)
206  }
207}
208
209// Drops the lines and turns of the items a write dropped.
210async function forget($: Pick<EngineInterface, 'state'>, dropped: string[]): Promise<void> {
211  if (dropped.length === 0) return
212  for (;;) {
213    const { value = {}, version } = await $.state.get({ plugin: 'rabe', key: 'lines' })
214    const { value: items = [] } = await $.state.get({ plugin: 'rabe', key: 'items' })
215    const next = prune(value, dropped, items)
216    if (next === value) break
217    const lines = await $.state.set({ plugin: 'rabe', key: 'lines' }, next, { ifVersion: version })
218    if (lines.isSet) break
219  }
220  for (;;) {
221    const { value = {}, version } = await $.state.get({ plugin: 'rabe', key: 'turns' })
222    const { value: items = [] } = await $.state.get({ plugin: 'rabe', key: 'items' })
223    const next = prune(value, dropped, items)
224    if (next === value) return
225    const turns = await $.state.set({ plugin: 'rabe', key: 'turns' }, next, { ifVersion: version })
226    if (turns.isSet) return
227  }
228}
229
230async function writeTurns(
231  $: Pick<EngineInterface, 'state'>,
232  change: (turns: Turns) => Turns,
233): Promise<void> {
234  for (;;) {
235    const { value = {}, version } = await $.state.get({ plugin: 'rabe', key: 'turns' })
236    const { isSet } = await $.state.set({ plugin: 'rabe', key: 'turns' }, change(value), {
237      ifVersion: version,
238    })
239    if (isSet) return
240  }
241}
242
243async function readMeta($: EngineInterface, id: string): Promise<void> {
244  const { value: items = [] } = await $.state.get({ plugin: 'rabe', key: 'items' })
245  const item = asAgent(items, id)
246  const path = item && metaPath(item, items)
247  if (!path) return
248  const text = await $.fs.read(path).catch(() => undefined)
249  const patch = typeof text === 'string' ? metaPatch(text) : undefined
250  if (patch) await write($, list => updateItem(list, id, { detail: patch }))
251}
252
253async function refresh($: EngineInterface): Promise<void> {
254  const agents = await $.agent.list().catch(() => [])
255  const now = await $.clock.now()
256  await write($, items => {
257    const end = pastEnd(items, now)
258
259    return agents.reduce((list, info) => listed(list, info, now, end), items)
260  })
261  const { value: items = [] } = await $.state.get({ plugin: 'rabe', key: 'items' })
262  for (const item of items) {
263    if (item.kind === 'agent' && item.status === 'running') await readMeta($, item.id)
264  }
265}
266
267async function recordStep($: EngineInterface, agentId: string, result: TurnStepResult) {
268  const id = itemId('agent', agentId)
269  const { value: items = [] } = await $.state.get({ plugin: 'rabe', key: 'items' })
270  if (!asAgent(items, id)) return
271  const now = await $.clock.now()
272  const tools = result.toolUses.map(use => ({ name: use.name, summary: toolSummary(use.input) }))
273  await write($, list => stepped(list, id, result, tools, now))
274  if (!result.answer && tools.length === 0) return
275  await writeTurns($, turns => {
276    const index = (turns[id]?.at(-1)?.index ?? 0) + 1
277    const turn = { index, at: now, text: clip(result.answer, MAX_TEXT), tools }
278
279    return addTurn(turns, id, turn)
280  })
281}
282
283export function agents(on: On): void {
284  on('session.start', { cwd: /^/ }, async ($, e, next) => {
285    $.clock.every(POLL_MS, () => refresh($))
286    await refresh($)
287
288    return next(e)
289  })
290
291  on('agent.spawn', { subagentType: /^/ }, async ($, e, next) => {
292    const result = await next(e)
293    const agentId = result.agentId
294    if (!agentId) return result
295    const now = await $.clock.now()
296    const runId = e.workflow?.runId
297    const parent = e.parentAgentId ? itemId('agent', e.parentAgentId) : undefined
298    await write($, items =>
299      addItem(
300        items,
301        {
302          id: itemId('agent', agentId),
303          kind: 'agent',
304          title: e.description || e.subagentType,
305          status: 'running',
306          startedAt: now,
307          parentId: runId ? itemId('workflow', runId) : parent,
308          detail: {
309            agentId,
310            type: e.subagentType,
311            model: result.model,
312            description: e.description,
313            prompt: clip(e.prompt, MAX_PROMPT),
314            workflowIndex: e.workflow?.agentIndex,
315          },
316        },
317        now,
318      ),
319    )
320
321    return result
322  }).catch((_$, e, next) => next(e))
323
324  on('classic.SubagentStart', { agent_id: /^/ }, async ($, e, next) => {
325    const cwd = await $.session.cwd().catch(() => e.cwd)
326    const transcriptPath = agentTranscript(e.transcript_path, e.agent_id)
327    const detail = {
328      agentId: e.agent_id,
329      ...(transcriptPath && { transcriptPath }),
330      ...(e.cwd && { cwd: e.cwd }),
331      ...(e.cwd && e.cwd !== cwd && { worktreePath: e.cwd }),
332    }
333    const id = itemId('agent', e.agent_id)
334    const now = await $.clock.now()
335    await write($, items =>
336      items.some(item => item.id === id)
337        ? updateItem(items, id, { detail })
338        : addItem(
339            items,
340            { id, kind: 'agent', title: e.agent_type, status: 'running', startedAt: now, detail },
341            now,
342          ),
343    )
344
345    return next(e)
346  }).catch((_$, e, next) => next(e))
347
348  on('turn.step', { agentId: /^/ }, async function* ($, e, next) {
349    const result = yield* next(e)
350    if (e.agentId) await recordStep($, e.agentId, result)
351
352    return result
353  })
354
355  on('tool.call', { tool: ['Edit', 'Write'] }, async ($, e, next) => {
356    const answer = await next(e)
357    try {
358      const path = (e.tool === 'Edit' || e.tool === 'Write') && changedFile(answer, e.file_path)
359      if (e.agentId && path) {
360        const id = itemId('agent', e.agentId)
361        const now = await $.clock.now()
362        await write($, items => edited(items, id, path, now))
363      }
364    } catch {}
365
366    return answer
367  })
368
369  on('turn.complete', { agentId: /^/ }, async ($, e, next) => {
370    const result = await next(e)
371    const agentId = e.agentId
372    if (!agentId) return result
373    const now = await $.clock.now()
374    await write($, items => endItem(items, itemId('agent', agentId), ENDED[e.reason], now))
375    await readMeta($, itemId('agent', agentId))
376
377    return result
378  })
379}
380
hooks/sources/codex.ts 417 lines
1import type { EngineInterface, On } from 'claude-code'
2
3import type { RabeCodexStep, RabeTokens } from '../../types'
4import {
5  clip,
6  type EndStatus,
7  itemId,
8  type NewItem,
9  type RabeItem,
10  type RabeItemOf,
11} from '../model'
12import { addItem, type Change, commit, endItem, prune } from '../registry'
13
14const POLL_MS = 2000
15const CODEX_DATA = 'plugins/data/codex-openai-codex'
16const CODEX_PLUGIN = 'codex@openai-codex'
17const MAX_READ = 4 * 1024 * 1024
18const TAIL_LINES = 200
19const MAX_STEPS = 50
20const MAX_TEXT = 300
21const DAY = 24 * 60 * 60 * 1000
22
23type Rec = Record<string, unknown>
24
25export type CodexJob = Rec & { id: string }
26
27export type Rollout = {
28  model?: string
29  effort?: string
30  sandbox?: string
31  prompt?: string
32  tokens?: RabeTokens
33  commandCount: number
34  steps: RabeCodexStep[]
35}
36
37export type CodexSession = {
38  path?: string
39  updatedAt?: number
40  isMissing?: boolean
41  isPartial?: boolean
42  rollout?: Rollout
43}
44
45function rec(value: unknown): Rec {
46  return value && typeof value === 'object' && !Array.isArray(value) ? (value as Rec) : {}
47}
48
49function list(value: unknown): unknown[] {
50  return Array.isArray(value) ? value : []
51}
52
53function str(value: unknown): string | undefined {
54  return typeof value === 'string' && value !== '' ? value : undefined
55}
56
57function num(value: unknown): number | undefined {
58  return typeof value === 'number' ? value : undefined
59}
60
61function parseJson(text: string): Rec | undefined {
62  try {
63    const value = JSON.parse(text)
64    return value && typeof value === 'object' ? (value as Rec) : undefined
65  } catch {
66    return undefined
67  }
68}
69
70function defined<T extends object>(value: T): T {
71  return Object.fromEntries(Object.entries(value).filter(([, one]) => one !== undefined)) as T
72}
73
74function texts(content: unknown, type: string): string {
75  return list(content)
76    .map(rec)
77    .filter(part => part.type === type)
78    .map(part => str(part.text) ?? '')
79    .join('')
80}
81
82function shellLine(command: unknown): string {
83  const argv = list(command).map(String)
84  return argv.length === 3 && argv[1] === '-lc' ? (argv[2] as string) : argv.join(' ')
85}
86
87function pendingCommand(input: unknown): string {
88  const text = String(input ?? '')
89  const quoted = /cmd:\s*("(?:[^"\\]|\\.)*")/.exec(text)?.[1]
90  if (quoted) {
91    try {
92      return JSON.parse(quoted)
93    } catch {}
94  }
95
96  return text.split('\n')[0] ?? ''
97}
98
99function lineCount(output: unknown): number | undefined {
100  const text = str(output)
101
102  return text === undefined ? undefined : text.replace(/\n$/, '').split('\n').length
103}
104
105export function parseRollout(text: string): Rollout {
106  const out: Rollout = { commandCount: 0, steps: [] }
107  const pending = new Map<string, RabeCodexStep>()
108  for (const raw of text.split('\n')) {
109    const record = parseJson(raw)
110    if (!record) continue
111    const payload = rec(record.payload)
112    if (record.type === 'turn_context') {
113      const settings = rec(rec(payload.collaboration_mode).settings)
114      out.model = str(payload.model) ?? str(settings.model) ?? out.model
115      out.effort = str(payload.effort) ?? str(settings.reasoning_effort) ?? out.effort
116      out.sandbox = str(rec(payload.sandbox_policy).type) ?? out.sandbox
117    } else if (record.type === 'event_msg' && payload.type === 'token_count') {
118      const usage = rec(rec(payload.info).total_token_usage)
119      const input = num(usage.input_tokens)
120      const output = num(usage.output_tokens)
121      if (input !== undefined && output !== undefined) {
122        out.tokens = defined({ input, output, cached: num(usage.cached_input_tokens) })
123      }
124    } else if (record.type === 'event_msg' && payload.type === 'item_completed') {
125      const item = rec(payload.item)
126      if (item.type === 'UserMessage') out.prompt ??= str(texts(item.content, 'text'))
127      if (item.type === 'CommandExecution') {
128        out.commandCount += 1
129        out.steps.push(
130          defined({
131            kind: 'command' as const,
132            text: clip(shellLine(item.command), MAX_TEXT),
133            exitCode: num(item.exit_code),
134            lines: lineCount(item.aggregated_output),
135          }),
136        )
137      }
138    } else if (record.type === 'response_item') {
139      if (payload.type === 'message' && payload.role === 'assistant') {
140        const message = texts(payload.content, 'output_text')
141        if (message) out.steps.push({ kind: 'message', text: clip(message, MAX_TEXT) })
142      } else if (payload.type === 'reasoning') {
143        const summary = list(payload.summary)
144          .map(part => str(rec(part).text))
145          .filter(Boolean)
146          .join('\n')
147        if (summary) out.steps.push({ kind: 'reasoning', text: clip(summary, MAX_TEXT) })
148      } else if (payload.type === 'custom_tool_call') {
149        const step: RabeCodexStep = {
150          kind: 'command',
151          text: clip(pendingCommand(payload.input), MAX_TEXT),
152          isRunning: true,
153        }
154        pending.set(String(payload.call_id), step)
155        out.steps.push(step)
156      } else if (payload.type === 'custom_tool_call_output') {
157        const step = pending.get(String(payload.call_id))
158        out.steps = out.steps.filter(one => one !== step)
159      }
160    }
161  }
162  out.steps = out.steps.slice(-MAX_STEPS)
163
164  return defined(out)
165}
166
167export function jobEnd(status: unknown): EndStatus | undefined {
168  if (status === 'completed') return 'done'
169  if (status === 'failed') return 'failed'
170  if (status === 'cancelled') return 'stopped'
171
172  return undefined
173}
174
175function jobStart(job: CodexJob): number | undefined {
176  return Date.parse(str(job.startedAt) ?? str(job.createdAt) ?? '') || undefined
177}
178
179export function codexItem(job: CodexJob, session: CodexSession | undefined): NewItem {
180  const rollout = session?.rollout
181  // an unchanged session file is not parsed again; the held item keeps what it gave
182  const request = session?.path && !rollout ? {} : rec(job.request)
183
184  return defined({
185    id: itemId('codex', job.id),
186    kind: 'codex' as const,
187    title: str(String(job.summary ?? '').split('\n')[0]) ?? str(job.title) ?? 'Codex job',
188    status: 'running' as const,
189    startedAt: jobStart(job),
190    tokens: rollout?.tokens,
191    detail: defined({
192      jobId: job.id,
193      jobKind: str(job.kindLabel),
194      threadId: str(job.threadId),
195      model: rollout?.model ?? str(request.model),
196      effort: rollout?.effort ?? str(request.effort),
197      sandbox: rollout?.sandbox,
198      prompt: str(request.prompt) ?? rollout?.prompt,
199      workspaceRoot: str(job.workspaceRoot),
200      logPath: str(job.logFile),
201      sessionPath: session?.path,
202      sessionUpdatedAt: session?.updatedAt,
203      isSessionMissing: session?.isMissing,
204      isSessionPartial: session?.isPartial,
205      commandCount: rollout?.commandCount,
206      steps: rollout?.steps,
207    }),
208  })
209}
210
211async function write($: Pick<EngineInterface, 'state'>, change: Change): Promise<void> {
212  for (;;) {
213    const { value, version } = await $.state.get({ plugin: 'rabe', key: 'items' })
214    const next = commit(value, change)
215    if (next === undefined) return
216    const { isSet } = await $.state.set({ plugin: 'rabe', key: 'items' }, next.items, {
217      ifVersion: version,
218    })
219    if (isSet) return forget($, next.dropped)
220  }
221}
222
223// Drops the lines and turns of the items a write dropped.
224async function forget($: Pick<EngineInterface, 'state'>, dropped: string[]): Promise<void> {
225  if (dropped.length === 0) return
226  for (;;) {
227    const { value = {}, version } = await $.state.get({ plugin: 'rabe', key: 'lines' })
228    const { value: items = [] } = await $.state.get({ plugin: 'rabe', key: 'items' })
229    const next = prune(value, dropped, items)
230    if (next === value) break
231    const lines = await $.state.set({ plugin: 'rabe', key: 'lines' }, next, { ifVersion: version })
232    if (lines.isSet) break
233  }
234  for (;;) {
235    const { value = {}, version } = await $.state.get({ plugin: 'rabe', key: 'turns' })
236    const { value: items = [] } = await $.state.get({ plugin: 'rabe', key: 'items' })
237    const next = prune(value, dropped, items)
238    if (next === value) return
239    const turns = await $.state.set({ plugin: 'rabe', key: 'turns' }, next, { ifVersion: version })
240    if (turns.isSet) return
241  }
242}
243
244async function homes($: EngineInterface): Promise<{ claude: string; codex: string }> {
245  const home = (await $.env.get('HOME')) ?? ''
246
247  return {
248    claude: (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${home}/.claude`,
249    codex: (await $.env.get('CODEX_HOME')) ?? `${home}/.codex`,
250  }
251}
252
253async function findRollout(
254  $: EngineInterface,
255  codexHome: string,
256  threadId: string,
257  startedAt: number,
258): Promise<string | undefined> {
259  for (const offset of [0, -DAY, DAY]) {
260    const day = new Date(startedAt + offset).toISOString().slice(0, 10).replaceAll('-', '/')
261    const dir = `${codexHome}/sessions/${day}`
262    const entries = await $.fs.list(dir).catch(() => [])
263    const found = entries.find(entry => entry.name.endsWith(`-${threadId}.jsonl`))
264    if (found) return `${dir}/${found.name}`
265  }
266
267  return undefined
268}
269
270async function readSession(
271  $: EngineInterface,
272  codexHome: string,
273  job: CodexJob,
274  held: RabeItemOf<'codex'> | undefined,
275): Promise<CodexSession | undefined> {
276  const threadId = str(job.threadId)
277  if (!threadId) return undefined
278  const path =
279    held?.detail.sessionPath ??
280    (await findRollout($, codexHome, threadId, jobStart(job) ?? (await $.clock.now())))
281  const stat = path ? await $.fs.stat(path).catch(() => undefined) : undefined
282  if (!path || !stat) return jobEnd(job.status) ? { isMissing: true } : undefined
283  const updatedAt = stat.mtimeMs
284  if (updatedAt === held?.detail.sessionUpdatedAt) return { path, updatedAt }
285  // no updatedAt on failure, so the next poll tries again
286  if (stat.size <= MAX_READ) {
287    const text = await $.fs.read(path).catch(() => undefined)
288
289    return text === undefined ? { path } : { path, updatedAt, rollout: parseRollout(String(text)) }
290  }
291  const head = await $.process
292    .run(['grep', '-m', '2', '-E', '"type":"(turn_context|UserMessage)"', path])
293    .catch(() => undefined)
294  const tail = await $.process.run(['tail', '-n', String(TAIL_LINES), path]).catch(() => undefined)
295  if (!head || !tail || tail.exitCode !== 0) return { path }
296
297  return {
298    path,
299    updatedAt,
300    isPartial: true,
301    rollout: parseRollout(`${head.stdout}\n${tail.stdout}`),
302  }
303}
304
305async function refresh(
306  $: EngineInterface,
307  sessionId: string,
308  codexHome: string,
309  jobPath: string,
310  entry: CodexJob,
311  items: RabeItem[],
312): Promise<void> {
313  const file = parseJson(String(await $.fs.read(jobPath).catch(() => '')))
314  const job: CodexJob = { ...entry, ...file, id: entry.id }
315  if (job.sessionId !== sessionId) return
316  const end = jobEnd(job.status)
317  const now = await $.clock.now()
318  const endedAt = Date.parse(str(job.completedAt) ?? '') || now
319  const change = (item: NewItem) => (list: RabeItem[]) => {
320    const added = addItem(list, item, now)
321
322    return end ? endItem(added, item.id, end, endedAt) : added
323  }
324  const held = items.find((one): one is RabeItemOf<'codex'> => one.id === itemId('codex', job.id))
325  // a finished job the cap would drop again: its session file is not read
326  if (!held && commit(items, change(codexItem(job, undefined))) === undefined) return
327  const session = await readSession($, codexHome, job, held)
328  await write($, change(codexItem(job, session)))
329}
330
331async function poll($: EngineInterface): Promise<void> {
332  const { claude, codex } = await homes($)
333  const stateRoot = `${claude}/${CODEX_DATA}/state`
334  const sessionId = await $.session.id()
335  const { startedAt: since } = await $.session.usage()
336  const { value: items = [] } = await $.state.get({ plugin: 'rabe', key: 'items' })
337  // ponytail: one stat per workspace folder per poll; keep a folder list in $.state if there are hundreds
338  for (const dir of await $.fs.list(stateRoot).catch(() => [])) {
339    if (dir.kind !== 'dir') continue
340    const statePath = `${stateRoot}/${dir.name}/state.json`
341    const stat = await $.fs.stat(statePath).catch(() => undefined)
342    if (!stat || stat.mtimeMs < since) continue
343    const state = parseJson(String(await $.fs.read(statePath).catch(() => '')))
344    for (const entry of list(state?.jobs).map(rec)) {
345      const id = str(entry.id)
346      // the plugin's state.json can hold a bare status patch; its job file has the session
347      if (!id || (entry.sessionId !== undefined && entry.sessionId !== sessionId)) continue
348      const held = items.find((one): one is RabeItemOf<'codex'> => one.id === itemId('codex', id))
349      if (held && held.status !== 'running') continue
350      await refresh(
351        $,
352        sessionId,
353        codex,
354        `${stateRoot}/${dir.name}/jobs/${id}.json`,
355        { ...entry, id },
356        items,
357      ).catch(() => undefined)
358    }
359  }
360}
361
362function firstLine(text: string): string {
363  return text.trim().split('\n')[0] ?? ''
364}
365
366async function cancel($: EngineInterface, id: string): Promise<{ text: string }> {
367  const { value: items = [] } = await $.state.get({ plugin: 'rabe', key: 'items' })
368  const item = items.find((one: RabeItem): one is RabeItemOf<'codex'> => one.id === id)
369  if (item?.status !== 'running') return { text: `No running Codex job ${id}.` }
370  const { claude } = await homes($)
371  const installed = parseJson(
372    String(await $.fs.read(`${claude}/plugins/installed_plugins.json`).catch(() => '')),
373  )
374  const root = str(rec(list(rec(installed?.plugins)[CODEX_PLUGIN])[0]).installPath)
375  if (!root) return { text: 'Stop refused: the Codex plugin is not installed.' }
376  const { workspaceRoot } = item.detail
377  const argv = [
378    'node',
379    `${root}/scripts/codex-companion.mjs`,
380    'cancel',
381    item.detail.jobId,
382    '--json',
383    ...(workspaceRoot ? ['--cwd', workspaceRoot] : []),
384  ]
385  const result = await $.process
386    .run(argv, { env: { CLAUDE_PLUGIN_DATA: `${claude}/${CODEX_DATA}` } })
387    .catch((error: unknown) => ({ exitCode: 1, stdout: '', stderr: String(error) }))
388  if (result.exitCode !== 0) {
389    return { text: `Stop refused: ${firstLine(result.stderr) || `exit ${result.exitCode}`}` }
390  }
391  const now = await $.clock.now()
392  await write($, held => endItem(held, id, 'stopped', now))
393
394  return { text: `Stopped codex ${item.title}` }
395}
396
397export function codex(on: On): void {
398  on('session.start', { isInteractive: true }, async ($, e, next) => {
399    const started = await next(e)
400    await $.command.register({
401      name: 'rabe-stop',
402      description: 'Stop a background item that Rabe shows, by its id',
403      argumentHint: '<item id>',
404      immediate: true,
405    })
406    $.clock.every(POLL_MS, () => {
407      poll($).catch(() => undefined)
408    })
409
410    return started
411  })
412
413  on('command.run', { command: 'rabe-stop', args: /^\s*codex:/ }, async ($, e) =>
414    cancel($, e.args.trim()),
415  )
416}
417
hooks/sources/crons.ts 238 lines
1import type { EngineInterface, On } from 'claude-code'
2
3import { type EndStatus, itemId, type RabeItem, type RabeItemOf } from '../model'
4import { addItem, type Change, commit, endItem, prune } from '../registry'
5
6const LOOP_SENTINEL = '<<autonomous-loop-dynamic>>'
7const SENTINEL = /^<<[\w.-]+>>$/
8const WAKEUP = 'wakeup-'
9// A wakeup fires when the session is idle, up to 90 s early for :00 and :30.
10const FIRE_SLACK_MS = 90_000
11
12// Each event takes one hook without a matcher per plugin; these match every event.
13const ANY_START = { isInteractive: [true, false] }
14const ANY_STOP = { stop_hook_active: [true, false] }
15
16type Job = { id: string; schedule: string; prompt: string; humanSchedule?: string }
17
18async function write($: Pick<EngineInterface, 'state'>, change: Change): Promise<void> {
19  for (;;) {
20    const { value, version } = await $.state.get({ plugin: 'rabe', key: 'items' })
21    const next = commit(value, change)
22    if (next === undefined) return
23    const { isSet } = await $.state.set({ plugin: 'rabe', key: 'items' }, next.items, {
24      ifVersion: version,
25    })
26    if (isSet) return forget($, next.dropped)
27  }
28}
29
30// Drops the lines and turns of the items a write dropped.
31async function forget($: Pick<EngineInterface, 'state'>, dropped: string[]): Promise<void> {
32  if (dropped.length === 0) return
33  for (;;) {
34    const { value = {}, version } = await $.state.get({ plugin: 'rabe', key: 'lines' })
35    const { value: items = [] } = await $.state.get({ plugin: 'rabe', key: 'items' })
36    const next = prune(value, dropped, items)
37    if (next === value) break
38    const lines = await $.state.set({ plugin: 'rabe', key: 'lines' }, next, { ifVersion: version })
39    if (lines.isSet) break
40  }
41  for (;;) {
42    const { value = {}, version } = await $.state.get({ plugin: 'rabe', key: 'turns' })
43    const { value: items = [] } = await $.state.get({ plugin: 'rabe', key: 'items' })
44    const next = prune(value, dropped, items)
45    if (next === value) return
46    const turns = await $.state.set({ plugin: 'rabe', key: 'turns' }, next, { ifVersion: version })
47    if (turns.isSet) return
48  }
49}
50
51function runningCrons(items: RabeItem[]): RabeItemOf<'cron'>[] {
52  return items.flatMap(item => (item.kind === 'cron' && item.status === 'running' ? [item] : []))
53}
54
55function isWakeup(item: RabeItemOf<'cron'>): boolean {
56  return item.detail.jobId.startsWith(WAKEUP)
57}
58
59function endWakeups(items: RabeItem[], status: EndStatus, now: number, until = Infinity) {
60  return dueWakeups(items, until).reduce((next, item) => endItem(next, item.id, status, now), items)
61}
62
63function dueWakeups(items: RabeItem[], until: number) {
64  return runningCrons(items).filter(
65    item => isWakeup(item) && (item.detail.scheduledFor ?? 0) <= until,
66  )
67}
68
69// A due wakeup ends only on evidence that it fired: its own prompt, or the
70// sentinel it was given (`<<autonomous-loop-dynamic>>`), which its expanded
71// tick names. Other text, such as a recurring loop's tick, ends none.
72function fired(items: RabeItem[], text: string, now: number): RabeItem[] {
73  const prompt = text.trim()
74  const ends = dueWakeups(items, now + FIRE_SLACK_MS).filter(item => {
75    const own = item.detail.prompt.trim()
76
77    return own === prompt || (SENTINEL.test(own) && text.includes(own))
78  })
79
80  return ends.reduce((next, item) => endItem(next, item.id, 'done', now), items)
81}
82
83function sync(items: RabeItem[], jobs: Job[], now: number): RabeItem[] {
84  const listed = new Set(jobs.map(job => itemId('cron', job.id)))
85  const running = runningCrons(items)
86  const wakeupPrompts = new Set(running.filter(isWakeup).map(item => item.detail.prompt))
87  const ended = running
88    .filter(item => !isWakeup(item) && !listed.has(item.id))
89    .reduce((next, item) => endItem(next, item.id, 'done', now), items)
90
91  return jobs.reduce((next, job) => {
92    const id = itemId('cron', job.id)
93    if (wakeupPrompts.has(job.prompt) || next.some(item => item.id === id)) return next
94
95    return addItem(
96      next,
97      {
98        id,
99        kind: 'cron',
100        title: job.prompt,
101        status: 'running',
102        detail: {
103          jobId: job.id,
104          prompt: job.prompt,
105          schedule: job.schedule,
106          ...(job.humanSchedule && { humanSchedule: job.humanSchedule }),
107        },
108      },
109      now,
110    )
111  }, ended)
112}
113
114async function list($: EngineInterface): Promise<void> {
115  try {
116    const answer = await $.tool.call({ tool: 'CronList' })
117    if (!answer.result || answer.isError) return
118    const jobs = answer.result.jobs.map(job => ({
119      id: job.id,
120      schedule: job.cron,
121      prompt: job.prompt,
122      humanSchedule: job.humanSchedule,
123    }))
124    const now = await $.clock.now()
125    await write($, held => sync(held, jobs, now))
126  } catch {}
127}
128
129export function crons(on: On): void {
130  on('tool.call', { tool: 'CronCreate' }, async ($, e, next) => {
131    const answer = await next(e)
132    try {
133      if (e.tool === 'CronCreate' && answer.result && !answer.isError) {
134        const { id, humanSchedule } = answer.result as { id: string; humanSchedule?: string }
135        const now = await $.clock.now()
136        await write($, held =>
137          addItem(
138            held,
139            {
140              id: itemId('cron', id),
141              kind: 'cron',
142              title: e.prompt,
143              status: 'running',
144              startedAt: now,
145              ...(e.agentId && { parentId: itemId('agent', e.agentId) }),
146              detail: {
147                jobId: id,
148                prompt: e.prompt,
149                schedule: e.cron,
150                ...(humanSchedule && { humanSchedule }),
151              },
152            },
153            now,
154          ),
155        )
156      }
157    } catch {}
158
159    return answer
160  })
161
162  on('tool.call', { tool: 'CronDelete' }, async ($, e, next) => {
163    const answer = await next(e)
164    try {
165      if (e.tool === 'CronDelete' && answer.result && !answer.isError) {
166        const now = await $.clock.now()
167        await write($, held => endItem(held, itemId('cron', e.id), 'stopped', now))
168      }
169    } catch {}
170
171    return answer
172  })
173
174  on('tool.call', { tool: 'ScheduleWakeup' }, async ($, e, next) => {
175    const answer = await next(e)
176    try {
177      if (e.tool === 'ScheduleWakeup' && answer.result && !answer.isError) {
178        const { scheduledFor, stopped } = answer.result as {
179          scheduledFor: number
180          stopped?: boolean
181        }
182        const now = await $.clock.now()
183        const prompt = e.prompt ?? ''
184        await write($, held =>
185          stopped || e.stop
186            ? endWakeups(held, 'stopped', now)
187            : addItem(
188                endWakeups(held, 'done', now),
189                {
190                  id: itemId('cron', `${WAKEUP}${scheduledFor}`),
191                  kind: 'cron',
192                  title: prompt === LOOP_SENTINEL ? 'autonomous loop' : prompt,
193                  status: 'running',
194                  startedAt: now,
195                  ...(e.agentId && { parentId: itemId('agent', e.agentId) }),
196                  detail: { jobId: `${WAKEUP}${scheduledFor}`, prompt, scheduledFor },
197                },
198                now,
199              ),
200        )
201      }
202    } catch {}
203
204    return answer
205  })
206
207  on('prompt.submit', { origin: { kind: 'scheduled-trigger' } }, async ($, e, next) => {
208    try {
209      const now = await $.clock.now()
210      await write($, held => fired(held, e.text, now))
211    } catch {}
212
213    return next(e)
214  })
215
216  on('classic.Stop', ANY_STOP, async ($, e, next) => {
217    try {
218      if (e.session_crons) {
219        const jobs = e.session_crons.map(job => ({
220          id: job.id,
221          schedule: job.schedule,
222          prompt: job.prompt,
223        }))
224        const now = await $.clock.now()
225        await write($, held => sync(held, jobs, now))
226      }
227    } catch {}
228
229    return next(e)
230  })
231
232  on('session.start', ANY_START, async ($, e, next) => {
233    await list($)
234
235    return next(e)
236  })
237}
238
hooks/sources/monitors.ts 218 lines
1import type { EngineInterface, On } from 'claude-code'
2
3import { type EndStatus, itemId, type RabeItem } from '../model'
4import { addItem, type Change, commit, endItem, prune, updateItem } from '../registry'
5import { appendLines, endStatus, parseNotifications, parseOutput, taskOutput } from '../tasks'
6
7const POLL_MS = 2000
8const MAX_READ = 4 * 1024 * 1024
9
10// Each event takes one hook without a matcher per plugin; these match every event.
11const ANY_START = { isInteractive: [true, false] }
12const ANY_STOP = { stop_hook_active: [true, false] }
13
14type BackgroundTask = { id: string; type: string; description: string; command?: string }
15
16async function write($: Pick<EngineInterface, 'state'>, change: Change): Promise<void> {
17  for (;;) {
18    const { value, version } = await $.state.get({ plugin: 'rabe', key: 'items' })
19    const next = commit(value, change)
20    if (next === undefined) return
21    const { isSet } = await $.state.set({ plugin: 'rabe', key: 'items' }, next.items, {
22      ifVersion: version,
23    })
24    if (isSet) return forget($, next.dropped)
25  }
26}
27
28// Drops the lines and turns of the items a write dropped.
29async function forget($: Pick<EngineInterface, 'state'>, dropped: string[]): Promise<void> {
30  if (dropped.length === 0) return
31  for (;;) {
32    const { value = {}, version } = await $.state.get({ plugin: 'rabe', key: 'lines' })
33    const { value: items = [] } = await $.state.get({ plugin: 'rabe', key: 'items' })
34    const next = prune(value, dropped, items)
35    if (next === value) break
36    const lines = await $.state.set({ plugin: 'rabe', key: 'lines' }, next, { ifVersion: version })
37    if (lines.isSet) break
38  }
39  for (;;) {
40    const { value = {}, version } = await $.state.get({ plugin: 'rabe', key: 'turns' })
41    const { value: items = [] } = await $.state.get({ plugin: 'rabe', key: 'items' })
42    const next = prune(value, dropped, items)
43    if (next === value) return
44    const turns = await $.state.set({ plugin: 'rabe', key: 'turns' }, next, { ifVersion: version })
45    if (turns.isSet) return
46  }
47}
48
49// ponytail: reads files up to 4 MiB whole; a larger monitor output is not followed
50async function follow(
51  $: EngineInterface,
52  id: string,
53  path: string,
54): Promise<EndStatus | undefined> {
55  try {
56    const { size } = await $.fs.stat(path)
57    if (size > MAX_READ) return undefined
58    const { lines, ended } = parseOutput(String(await $.fs.read(path)))
59    const now = await $.clock.now()
60    for (;;) {
61      const { value = {}, version } = await $.state.get({ plugin: 'rabe', key: 'lines' })
62      const next = appendLines(value[id], lines, now)
63      if (!next) break
64      const { isSet } = await $.state.set(
65        { plugin: 'rabe', key: 'lines' },
66        { ...value, [id]: next },
67        { ifVersion: version },
68      )
69      if (isSet) break
70    }
71
72    return ended
73  } catch {
74    return undefined
75  }
76}
77
78function runningMonitors(items: RabeItem[]) {
79  return items.flatMap(item => (item.kind === 'monitor' && item.status === 'running' ? [item] : []))
80}
81
82async function poll($: EngineInterface): Promise<void> {
83  try {
84    const { value: items = [] } = await $.state.get({ plugin: 'rabe', key: 'items' })
85    for (const item of runningMonitors(items)) {
86      const path =
87        item.detail.outputPath ?? (item.detail.taskId && taskOutput(items, item.detail.taskId))
88      if (!path) continue
89      const ended = await follow($, item.id, path)
90      const now = await $.clock.now()
91      await write($, held => {
92        const next = updateItem(held, item.id, { detail: { outputPath: path } })
93
94        return ended ? endItem(next, item.id, ended, now) : next
95      })
96    }
97  } catch {}
98}
99
100async function onNotifications($: EngineInterface, text: string): Promise<void> {
101  const { value: items = [] } = await $.state.get({ plugin: 'rabe', key: 'items' })
102  for (const one of parseNotifications(text)) {
103    const id = itemId('monitor', one.taskId)
104    if (!one.status || !items.some(item => item.id === id)) continue
105    if (one.outputFile) await follow($, id, one.outputFile)
106    const now = await $.clock.now()
107    await write($, held =>
108      endItem(
109        updateItem(held, id, { detail: one.outputFile ? { outputPath: one.outputFile } : {} }),
110        id,
111        endStatus(one.status),
112        now,
113      ),
114    )
115  }
116}
117
118async function correct($: EngineInterface, tasks: readonly BackgroundTask[]): Promise<void> {
119  const { value: items = [] } = await $.state.get({ plugin: 'rabe', key: 'items' })
120  const inFlight = new Set(tasks.map(task => task.id))
121  const now = await $.clock.now()
122  for (const item of runningMonitors(items)) {
123    if (!item.detail.taskId || inFlight.has(item.detail.taskId)) continue
124    // A subagent's task may be missing from the main session's list; the poll
125    // ends it from the exit line of its output file.
126    if (item.parentId?.startsWith('agent:')) continue
127    const ended = item.detail.outputPath && (await follow($, item.id, item.detail.outputPath))
128    await write($, held => endItem(held, item.id, ended || 'stopped', now))
129  }
130  await write($, held =>
131    tasks.reduce((next, task) => {
132      const id = itemId('monitor', task.id)
133      if (task.type !== 'monitor' || next.some(item => item.id === id)) return next
134      const outputPath = taskOutput(next, task.id)
135
136      return addItem(
137        next,
138        {
139          id,
140          kind: 'monitor',
141          title: task.description,
142          status: 'running',
143          detail: {
144            command: task.command ?? task.description,
145            description: task.description,
146            taskId: task.id,
147            ...(outputPath && { outputPath }),
148          },
149        },
150        now,
151      )
152    }, held),
153  )
154}
155
156export function monitors(on: On): void {
157  on('tool.call', { tool: 'Monitor' }, async ($, e, next) => {
158    const answer = await next(e)
159    try {
160      const result = answer.result as
161        | { taskId?: string; timeoutMs?: number; persistent?: boolean }
162        | undefined
163      const taskId = result?.taskId
164      if (e.tool === 'Monitor' && taskId) {
165        const now = await $.clock.now()
166        await write($, held => {
167          const outputPath = taskOutput(held, taskId)
168
169          return addItem(
170            held,
171            {
172              id: itemId('monitor', taskId),
173              kind: 'monitor',
174              title: e.description,
175              status: 'running',
176              startedAt: now,
177              ...(e.agentId && { parentId: itemId('agent', e.agentId) }),
178              detail: {
179                command: e.command ?? e.ws?.url ?? '',
180                description: e.description,
181                taskId,
182                timeoutMs: result.timeoutMs ?? e.timeout_ms,
183                isPersistent: result.persistent === true,
184                ...(outputPath && { outputPath }),
185              },
186            },
187            now,
188          )
189        })
190      }
191    } catch {}
192
193    return answer
194  })
195
196  on('prompt.submit', { origin: { kind: 'task-notification' } }, async ($, e, next) => {
197    try {
198      await onNotifications($, e.text)
199    } catch {}
200
201    return next(e)
202  })
203
204  on('classic.Stop', ANY_STOP, async ($, e, next) => {
205    try {
206      if (e.background_tasks) await correct($, e.background_tasks)
207    } catch {}
208
209    return next(e)
210  })
211
212  on('session.start', ANY_START, async ($, e, next) => {
213    $.clock.every(POLL_MS, () => void poll($))
214
215    return next(e)
216  })
217}
218
hooks/sources/shells.ts 249 lines
1import type { EngineInterface, On } from 'claude-code'
2
3import { itemId, type RabeItem } from '../model'
4import { addItem, type Change, commit, endItem, prune, updateItem } from '../registry'
5import {
6  appendLines,
7  endStatus,
8  guessPort,
9  outputPathOf,
10  parseNotifications,
11  parseOutput,
12  taskOutput,
13} from '../tasks'
14
15const POLL_MS = 2000
16const MAX_READ = 4 * 1024 * 1024
17const TAIL_BYTES = '1048576'
18
19// Each event takes one hook without a matcher per plugin; these match every event.
20const ANY_START = { isInteractive: [true, false] }
21const ANY_STOP = { stop_hook_active: [true, false] }
22
23type BackgroundTask = { id: string; type: string; command?: string }
24
25async function write($: Pick<EngineInterface, 'state'>, change: Change): Promise<void> {
26  for (;;) {
27    const { value, version } = await $.state.get({ plugin: 'rabe', key: 'items' })
28    const next = commit(value, change)
29    if (next === undefined) return
30    const { isSet } = await $.state.set({ plugin: 'rabe', key: 'items' }, next.items, {
31      ifVersion: version,
32    })
33    if (isSet) return forget($, next.dropped)
34  }
35}
36
37// Drops the lines and turns of the items a write dropped.
38async function forget($: Pick<EngineInterface, 'state'>, dropped: string[]): Promise<void> {
39  if (dropped.length === 0) return
40  for (;;) {
41    const { value = {}, version } = await $.state.get({ plugin: 'rabe', key: 'lines' })
42    const { value: items = [] } = await $.state.get({ plugin: 'rabe', key: 'items' })
43    const next = prune(value, dropped, items)
44    if (next === value) break
45    const lines = await $.state.set({ plugin: 'rabe', key: 'lines' }, next, { ifVersion: version })
46    if (lines.isSet) break
47  }
48  for (;;) {
49    const { value = {}, version } = await $.state.get({ plugin: 'rabe', key: 'turns' })
50    const { value: items = [] } = await $.state.get({ plugin: 'rabe', key: 'items' })
51    const next = prune(value, dropped, items)
52    if (next === value) return
53    const turns = await $.state.set({ plugin: 'rabe', key: 'turns' }, next, { ifVersion: version })
54    if (turns.isSet) return
55  }
56}
57
58async function readOutput($: EngineInterface, path: string): Promise<string | undefined> {
59  return (await readWhole($, path)).text
60}
61
62// `isWhole` is false when only the file's tail was read.
63async function readWhole(
64  $: EngineInterface,
65  path: string,
66): Promise<{ text?: string; isWhole: boolean }> {
67  try {
68    const { size } = await $.fs.stat(path)
69    if (size <= MAX_READ) return { text: String(await $.fs.read(path)), isWhole: true }
70    const { stdout } = await $.process.run(['tail', '-c', TAIL_BYTES, path])
71
72    return { text: stdout, isWhole: false }
73  } catch {
74    return { isWhole: false }
75  }
76}
77
78// ponytail: lines are counted from the file's start, so a shell whose output
79// passed 4 MiB keeps the lines it had; the pane shows those.
80async function keepLines($: EngineInterface, id: string, lines: string[]): Promise<void> {
81  const now = await $.clock.now()
82  for (;;) {
83    const { value = {}, version } = await $.state.get({ plugin: 'rabe', key: 'lines' })
84    const next = appendLines(value[id], lines, now)
85    if (!next) return
86    const { isSet } = await $.state.set(
87      { plugin: 'rabe', key: 'lines' },
88      { ...value, [id]: next },
89      { ifVersion: version },
90    )
91    if (isSet) return
92  }
93}
94
95function runningShells(items: RabeItem[]) {
96  return items.flatMap(item => (item.kind === 'shell' && item.status === 'running' ? [item] : []))
97}
98
99async function poll($: EngineInterface): Promise<void> {
100  try {
101    const { value: items = [] } = await $.state.get({ plugin: 'rabe', key: 'items' })
102    for (const item of runningShells(items)) {
103      const { text, isWhole } = item.detail.outputPath
104        ? await readWhole($, item.detail.outputPath)
105        : { isWhole: false }
106      if (!text) continue
107      const { exitCode, ended, lines } = parseOutput(text)
108      if (isWhole) await keepLines($, item.id, lines)
109      const port = item.detail.port ?? guessPort(text)
110      const now = await $.clock.now()
111      const portAt = item.detail.portAt ?? (port ? now : undefined)
112      await write($, held => {
113        const next = updateItem(held, item.id, {
114          detail: {
115            ...(port && { port, portAt }),
116            ...(exitCode !== undefined && { exitCode }),
117          },
118        })
119
120        return ended ? endItem(next, item.id, ended, now) : next
121      })
122    }
123  } catch {}
124}
125
126async function onNotifications($: EngineInterface, text: string): Promise<void> {
127  const notes = parseNotifications(text).filter(one => one.status)
128  const { value: items = [] } = await $.state.get({ plugin: 'rabe', key: 'items' })
129  for (const one of notes) {
130    const id = itemId('shell', one.taskId)
131    const item = items.find(held => held.id === id)
132    const path = one.outputFile ?? (item?.kind === 'shell' ? item.detail.outputPath : undefined)
133    const read = item && path ? await readWhole($, path) : undefined
134    if (read?.text && read.isWhole) await keepLines($, id, parseOutput(read.text).lines)
135  }
136  const now = await $.clock.now()
137  await write($, held =>
138    notes.reduce((items, one) => {
139      const id = itemId('shell', one.taskId)
140      const next = updateItem(items, id, {
141        detail: {
142          ...(one.exitCode !== undefined && { exitCode: one.exitCode }),
143          ...(one.outputFile && { outputPath: one.outputFile }),
144        },
145      })
146
147      return endItem(next, id, endStatus(one.status, one.exitCode), now)
148    }, held),
149  )
150}
151
152async function correct($: EngineInterface, tasks: readonly BackgroundTask[]): Promise<void> {
153  const { value: items = [] } = await $.state.get({ plugin: 'rabe', key: 'items' })
154  const inFlight = new Set(tasks.map(task => task.id))
155  const now = await $.clock.now()
156  for (const item of runningShells(items)) {
157    if (!item.detail.taskId || inFlight.has(item.detail.taskId)) continue
158    // A subagent's task may be missing from the main session's list; the poll
159    // ends it from the exit line of its output file.
160    if (item.parentId?.startsWith('agent:')) continue
161    const text = item.detail.outputPath && (await readOutput($, item.detail.outputPath))
162    const { exitCode, ended = 'stopped' } = parseOutput(text || '')
163    await write($, held =>
164      endItem(
165        updateItem(held, item.id, { detail: exitCode === undefined ? {} : { exitCode } }),
166        item.id,
167        ended,
168        now,
169      ),
170    )
171  }
172  await write($, held =>
173    tasks.reduce((next, task) => {
174      const id = itemId('shell', task.id)
175      // Claude Code lists a Monitor tool task as type 'shell' too.
176      const known = [id, itemId('monitor', task.id)]
177      if (task.type !== 'shell' || !task.command || next.some(item => known.includes(item.id))) {
178        return next
179      }
180      const outputPath = taskOutput(items, task.id)
181
182      return addItem(
183        next,
184        {
185          id,
186          kind: 'shell',
187          title: task.command,
188          status: 'running',
189          detail: { command: task.command, taskId: task.id, ...(outputPath && { outputPath }) },
190        },
191        now,
192      )
193    }, held),
194  )
195}
196
197export function shells(on: On): void {
198  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
199    const answer = await next(e)
200    try {
201      const result = answer.result as { backgroundTaskId?: string } | undefined
202      const taskId = result?.backgroundTaskId
203      if (e.tool === 'Bash' && taskId) {
204        const now = await $.clock.now()
205        const outputPath = outputPathOf(answer.text)
206        await write($, held =>
207          addItem(
208            held,
209            {
210              id: itemId('shell', taskId),
211              kind: 'shell',
212              title: e.command,
213              status: 'running',
214              startedAt: now,
215              ...(e.agentId && { parentId: itemId('agent', e.agentId) }),
216              detail: { command: e.command, taskId, ...(outputPath && { outputPath }) },
217            },
218            now,
219          ),
220        )
221      }
222    } catch {}
223
224    return answer
225  })
226
227  on('prompt.submit', { origin: { kind: 'task-notification' } }, async ($, e, next) => {
228    try {
229      await onNotifications($, e.text)
230    } catch {}
231
232    return next(e)
233  })
234
235  on('classic.Stop', ANY_STOP, async ($, e, next) => {
236    try {
237      if (e.background_tasks) await correct($, e.background_tasks)
238    } catch {}
239
240    return next(e)
241  })
242
243  on('session.start', ANY_START, async ($, e, next) => {
244    $.clock.every(POLL_MS, () => void poll($))
245
246    return next(e)
247  })
248}
249
hooks/sources/workflows.ts 114 lines
1import type { BuiltinToolResults, EngineInterface, On } from 'claude-code'
2
3import { itemId } from '../model'
4import { addItem, type Change, commit, endItem, prune, updateItem } from '../registry'
5import { endStatus, parseNotifications } from '../tasks'
6
7type Launched = BuiltinToolResults['Workflow']
8
9export function phaseNames(script: string): string[] | undefined {
10  // ponytail: a `}` inside an object entry's string still ends that entry.
11  const list =
12    /\bphases\s*:\s*\[\s*((?:(?:'[^']*'|"[^"]*"|`[^`]*`|\{[^}]*\})\s*(?:,\s*)?)*)\]/.exec(
13      script,
14    )?.[1]
15  if (list === undefined) return undefined
16
17  return [...list.matchAll(/\{[^}]*\}|(['"`])(.*?)\1/g)].flatMap(([whole, quote, text]) => {
18    if (quote) return [text ?? '']
19    const title = /\btitle\s*:\s*(['"`])(.*?)\1/.exec(whole)?.[2]
20    return title === undefined ? [] : [title]
21  })
22}
23
24async function write($: Pick<EngineInterface, 'state'>, change: Change): Promise<void> {
25  for (;;) {
26    const { value, version } = await $.state.get({ plugin: 'rabe', key: 'items' })
27    const next = commit(value, change)
28    if (next === undefined) return
29    const { isSet } = await $.state.set({ plugin: 'rabe', key: 'items' }, next.items, {
30      ifVersion: version,
31    })
32    if (isSet) return forget($, next.dropped)
33  }
34}
35
36// Drops the lines and turns of the items a write dropped.
37async function forget($: Pick<EngineInterface, 'state'>, dropped: string[]): Promise<void> {
38  if (dropped.length === 0) return
39  for (;;) {
40    const { value = {}, version } = await $.state.get({ plugin: 'rabe', key: 'lines' })
41    const { value: items = [] } = await $.state.get({ plugin: 'rabe', key: 'items' })
42    const next = prune(value, dropped, items)
43    if (next === value) break
44    const lines = await $.state.set({ plugin: 'rabe', key: 'lines' }, next, { ifVersion: version })
45    if (lines.isSet) break
46  }
47  for (;;) {
48    const { value = {}, version } = await $.state.get({ plugin: 'rabe', key: 'turns' })
49    const { value: items = [] } = await $.state.get({ plugin: 'rabe', key: 'items' })
50    const next = prune(value, dropped, items)
51    if (next === value) return
52    const turns = await $.state.set({ plugin: 'rabe', key: 'turns' }, next, { ifVersion: version })
53    if (turns.isSet) return
54  }
55}
56
57async function launched($: EngineInterface, result: Launched, name?: string): Promise<void> {
58  const runId = result.runId
59  if (!runId) return
60  const id = itemId('workflow', runId)
61  const now = await $.clock.now()
62  await write($, items =>
63    addItem(
64      items,
65      {
66        id,
67        kind: 'workflow',
68        title: result.workflowName ?? name ?? 'workflow',
69        status: 'running',
70        startedAt: now,
71        endedAt: undefined,
72        detail: {
73          runId,
74          taskId: result.taskId,
75          scriptPath: result.scriptPath,
76          transcriptDir: result.transcriptDir,
77        },
78      },
79      now,
80    ),
81  )
82  if (!result.scriptPath) return
83  const script = await $.fs.read(result.scriptPath).catch(() => undefined)
84  const phases = typeof script === 'string' ? phaseNames(script) : undefined
85  if (phases) await write($, items => updateItem(items, id, { detail: { phases } }))
86}
87
88async function notified($: EngineInterface, text: string): Promise<void> {
89  const notes = parseNotifications(text).filter(note => note.status)
90  if (notes.length === 0) return
91  const now = await $.clock.now()
92  await write($, items =>
93    notes.reduce((list, note) => {
94      const run = list.find(item => item.kind === 'workflow' && item.detail.taskId === note.taskId)
95      return run ? endItem(list, run.id, endStatus(note.status), now) : list
96    }, items),
97  )
98}
99
100export function workflows(on: On): void {
101  on('tool.call', { tool: 'Workflow' }, async ($, e, next) => {
102    const answer = await next(e)
103    if (answer.result && !answer.isError) await launched($, answer.result as Launched, e.name)
104
105    return answer
106  }).catch((_$, e, next) => next(e))
107
108  on('prompt.submit', { origin: { kind: 'task-notification' } }, async ($, e, next) => {
109    await notified($, e.text)
110
111    return next(e)
112  }).catch((_$, e, next) => next(e))
113}
114
hooks/ui/render.tsx 255 lines
1import type {
2  BoxProps,
3  ButtonProps,
4  ElementConstructor,
5  InputProps,
6  RasterProps,
7  RenderSurface,
8  TextProps,
9} from 'claude-code'
10
11import { clamp, encode, lines } from './cells/grid'
12import { C, paint } from './cells/palette'
13import {
14  type Action,
15  type Drawn,
16  isPress,
17  type Line,
18  layout,
19  NONE,
20  type Part,
21  type Piece,
22  type ViewButton,
23} from './view'
24
25export type Ui = {
26  Box: ElementConstructor<BoxProps>
27  Text: ElementConstructor<TextProps>
28  Button: ElementConstructor<ButtonProps>
29  Input?: ElementConstructor<InputProps>
30  Raster?: ElementConstructor<RasterProps>
31}
32
33// Where a press or an Input event came from: its surface and the element's key.
34export type At = { surface: RenderSurface; element: string }
35
36export type Act = (action: Action, at: At) => void
37
38// A focusable element drawn since the pane opened: its key, its latest label,
39// and whether it is a control (an Input too) rather than a row Button.
40export type Held = { key: string; label: string; isControl?: true }
41
42const keysOf = (piece: Piece): Held[] =>
43  'spans' in piece
44    ? piece.spans.filter(isPress).map(one => ({ key: one.key, label: one.label }))
45    : 'button' in piece
46      ? [{ key: piece.button.key, label: piece.button.label, isControl: true }]
47      : 'input' in piece
48        ? [{ key: piece.input.key, label: piece.input.label, isControl: true }]
49        : []
50
51// What stands in for an element that is gone: its key and old label, dim,
52// and a press does nothing. A row says `gone` first, which a cut keeps.
53function slot(one: Held): Piece {
54  if (one.isControl) return { button: { key: one.key, label: one.label, action: NONE, dim: true } }
55
56  return {
57    spans: [[' gone ', { fg: C.dim }], { key: one.key, label: one.label, action: NONE, dim: true }],
58  }
59}
60
61// The focus ring stays on an index, so while the pane is open the focusable
62// elements it draws only grow at the end: the `before` keys come first, in
63// their order, each in place or as a `slot` where it is gone, then the keys
64// that are new, in document order. A piece moves only when it must: a held
65// one comes forward to its turn, and a new one drawn above a held one goes
66// right after the last held one. A slot follows the element held before it,
67// so it never stands under the heading of the next. A line with several
68// Buttons (the tab row) moves as one. Returns the pieces to draw and the
69// keys to hold next.
70export function hold(list: Piece[], before?: readonly Held[]): { list: Piece[]; held: Held[] } {
71  const drawn = list.flatMap(keysOf)
72  if (!before) return { list, held: drawn }
73  const rank = new Map(before.map((one, i) => [one.key, i]))
74  const where = new Map(list.flatMap((piece, i) => keysOf(piece).map(one => [one.key, i] as const)))
75  const last = Math.max(-1, ...before.map(one => where.get(one.key) ?? -1))
76  const out: Piece[] = []
77  const done = new Set<number>()
78  const moved: Piece[] = []
79  let next = 0
80  let isClosed = false
81  const upTo = (end: number) => {
82    for (; next < before.length; next++) {
83      const one = before[next] as Held
84      const at = where.get(one.key)
85      if (next >= end && at !== undefined && !done.has(at)) break
86      if (at === undefined) out.push(slot(one))
87      else if (!done.has(at)) {
88        done.add(at)
89        out.push(list[at] as Piece)
90      }
91    }
92  }
93  const close = () => {
94    upTo(before.length)
95    out.push(...moved)
96    isClosed = true
97  }
98  list.forEach((piece, i) => {
99    if (i > last && !isClosed) close()
100    if (done.has(i)) return
101    const keys = keysOf(piece)
102    const first = Math.min(...keys.map(one => rank.get(one.key) ?? Infinity))
103    if (keys.length === 0 || isClosed) out.push(piece)
104    else if (first < Infinity) upTo(first + 1)
105    else moved.push(piece)
106  })
107  if (!isClosed) close()
108  const now = new Map(drawn.map(one => [one.key, one]))
109
110  return {
111    list: out,
112    held: [
113      ...before.map(one => now.get(one.key) ?? one),
114      ...drawn.filter(one => !rank.has(one.key)),
115    ],
116  }
117}
118
119// The one renderer, the same tree on every surface: each line a row Box of
120// Text parts and plain Buttons, each chart a Raster on the terminal (the
121// desktop draws a Raster as an empty Box) and text elsewhere, the controls in
122// a row and the Inputs where they stand. Colors go out as `paint` names them
123// per surface. A press of an action `none` does nothing.
124export function render(
125  ui: Ui,
126  surface: RenderSurface,
127  drawn: Drawn | Piece[],
128  act: Act,
129  key = 'cells',
130) {
131  const { Box, Button, Input, Raster, Text } = ui
132  const color = (rgb: number | undefined) => paint(rgb, surface)
133  const run =
134    (action: Action) =>
135    (press: At): void => {
136      if (action.type !== 'none') act(action, press)
137    }
138
139  const part = (one: Part, hasScope: boolean) =>
140    isPress(one) ? (
141      <Button
142        key={one.key}
143        plain
144        label={one.label}
145        hotkey={one.hotkey}
146        autoFocus={one.autoFocus}
147        dimColor={one.dim}
148        hover={hasScope ? { bold: true } : undefined}
149        onPress={run(one.action)}
150      />
151    ) : (
152      <Text color={color(one[1]?.fg)} backgroundColor={color(one[1]?.bg)} wrap="truncate">
153        {one[0]}
154      </Text>
155    )
156
157  // A Button takes no color, so each run of parts on one background sits in
158  // a Box of that color. A row with one Button is keyed after it: the hover
159  // scope of its label.
160  const row = (line: Line) => {
161    const presses = line.spans.filter(isPress)
162    const scope = presses.length === 1 ? presses[0]?.key : undefined
163    const runs: { bg?: number; parts: Part[] }[] = []
164    for (const one of line.spans) {
165      if (!(isPress(one) ? one.label : one[0])) continue
166      const bg = isPress(one) ? one.bg : one[1]?.bg
167      const last = runs.at(-1)
168      if (last && last.bg === bg) last.parts.push(one)
169      else runs.push({ bg, parts: [one] })
170    }
171    const draw = (parts: Part[]) => parts.map(one => part(one, scope !== undefined))
172
173    return (
174      <Box
175        {...(scope && { key: `line:${scope}` })}
176        flexDirection="row"
177        backgroundColor={color(line.bg ?? (runs.length === 1 ? runs[0]?.bg : undefined))}
178      >
179        {runs.length === 0 && <Text> </Text>}
180        {runs.length === 1 && draw(runs[0]?.parts ?? [])}
181        {runs.length > 1 &&
182          runs.map(run => (
183            <Box flexDirection="row" backgroundColor={color(run.bg)}>
184              {draw(run.parts)}
185            </Box>
186          ))}
187      </Box>
188    )
189  }
190
191  const control = (one: ViewButton) => (
192    <Button
193      key={one.key}
194      label={one.label}
195      hotkey={one.hotkey}
196      autoFocus={one.autoFocus}
197      dimColor={one.dim}
198      onPress={run(one.action)}
199    />
200  )
201
202  // Controls side by side wrap as one row.
203  const list = Array.isArray(drawn) ? drawn : layout(drawn)
204  const runs: (Piece | ViewButton[])[] = []
205  for (const piece of list) {
206    const last = runs.at(-1)
207    if (!('button' in piece)) runs.push(piece)
208    else if (Array.isArray(last)) last.push(piece.button)
209    else runs.push([piece.button])
210  }
211  let rows = 0
212
213  return (
214    <Box flexDirection="column">
215      {runs.flatMap(one => {
216        if (Array.isArray(one)) {
217          rows += 1
218          return [
219            <Box
220              key={rows > 1 ? `controls-${rows}` : 'controls'}
221              flexDirection="row"
222              flexWrap="wrap"
223              columnGap={1}
224            >
225              {one.map(control)}
226            </Box>,
227          ]
228        }
229        if ('input' in one) {
230          const { input } = one
231          return Input
232            ? [
233                <Input
234                  key={input.key}
235                  label={input.label}
236                  placeholder={input.placeholder}
237                  value={input.value}
238                  submitLabel={input.submitLabel}
239                  onInput={input.isLive ? (text, at) => act(input.action(text), at) : undefined}
240                  onSubmit={(text, at) => act(input.action(text), at)}
241                />,
242              ]
243            : []
244        }
245        if ('spans' in one) return [row(one)]
246        if (!('chart' in one)) return []
247        const cut = clamp(one.chart)
248        return surface === 'terminal' && Raster
249          ? [<Raster key={key} columns={cut.columns} rows={cut.rows} cells={encode(cut)} />]
250          : lines(one.chart).map(text => <Text wrap="truncate">{text || ' '}</Text>)
251      })}
252    </Box>
253  )
254}
255