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

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

Status: early development. Version 0.1.0.
| Kind | In the band | In the pane |
|---|---|---|
| Claude subagents | Name and run time | Tokens, share of the session, the prompt it got, each turn with its tool calls |
| Workflows | Current phase and agent count | Phases 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 time | Model, effort and sandbox, tokens, the prompt, each message and command with its exit code |
| Background shells | Command and the port it serves | Output lines, exit code, the guessed port |
| Monitors | Name | Each line with the time Rabe received it |
Cron jobs and /loop wakeups | Prompt and the time of the next run (due for a wakeup that waits) | The next five runs |
| Cost | Session cost and tokens of the background work | Cost 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:
/cost totals it, and a bar of tokens per agent and Codex job. Agents that look slow or stuck show under "Load".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).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.
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.
| Key | What it does |
|---|---|
| ↑ / ↓ | Move to the previous / next row; the detail beside the list follows |
| Enter | Open the row that has the focus, also after a click selected another row |
| Click on a row | Select the row; click it again to open it. A click on the row that has the focus opens it at once |
| Tab / Shift+Tab | Move the focus through rows, tabs and buttons |
b | Go back to the list |
1 to 4 | Switch tab |
s | Search |
x | Stop the selected item |
g | Stop the group the selected row is in (the rows the search shows), or the workflow run of the selected run or workflow agent |
m | Send a message to an agent |
c | Copy the command, the prompt or the ssh -L line |
d | Delete a cron job |
| Mouse wheel | Scroll the pane |
| Esc | Close 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.
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:
CronList and the files tell.What Rabe can see lists each source and each limit.
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:
~/.claude/projects/./tmp/claude-<uid>/.~/.claude/plugins/data/codex-openai-codex/, and ~/.claude/plugins/installed_plugins.json.~/.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.
n/a where a file changed.codex@openai-codex) for Codex jobs, and the gh CLI for the report commands.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.
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.
hooks/register.tsx 14 lines1import 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}
14hooks/sources/index.ts 20 lines1import 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}
20hooks/ui/band.tsx 24 lines1import 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}
24hooks/ui/builtin.tsx 57 lines1import 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}
57hooks/ui/pane.tsx 442 lines1import 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}
442hooks/sources/agents.ts 380 lines1import 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}
380hooks/sources/codex.ts 417 lines1import 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}
417hooks/sources/crons.ts 238 lines1import 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}
238hooks/sources/monitors.ts 218 lines1import 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}
218hooks/sources/shells.ts 249 lines1import 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}
249hooks/sources/workflows.ts 114 lines1import 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}
114hooks/ui/render.tsx 255 lines1import 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