Clawd, Claude Code's crab, lives in a band above your prompt: he acts out the session, shows what's running, your context and usage limits, and races your CI…

<img src="docs/readme/wordmark.svg" width="525" alt="clawd-bar">
A Claude Code plugin that puts Clawd, Claude Code's pixel crab, in a three-row band above your prompt. He acts out what your session is doing, gauges beside him show how much context and usage you have left, and the lines after them show what's running and when Claude needs you. Start a CI build and the band turns into a race against your best time.
<img src="docs/readme/session.svg" alt="The band above the Claude Code prompt through one session: Clawd idles while a prompt is typed, thinks under a flickering bulb, edits code in a little editor whose header shows the real diff (-2+2), unrolls a scroll to read, throws paper planes for subagents, rings a bell when Claude needs an answer, plays a falling-block game during a slow build (landing a T-spin), runs the tests and comes up with the real count (48 ✓), celebrates with confetti when the turn ends, then dozes and falls asleep. Beside him: context and usage gauges that fill as they rise and turn yellow past 80 %, then what's running with the turn clock, task progress, and the repo and branch.">
claude plugin marketplace add KaiC5504/clawd-bar
claude plugin install clawd-bar@clawd-bar
Then start a new Claude Code session, or run /reload-plugins in the one you have open. Clawd shows up above the prompt in the terminal and in the desktop app's Code tab.
Requirements
/clawd doesn't show up after installing, run claude update first.gh auth login.osascript on macOS and notify-send on Linux; the macOS and Linux ones haven't been tried on real machines yet, so reports are welcome.Update
claude plugin marketplace update clawd-bar
claude plugin update clawd-bar@clawd-bar
then restart Claude Code. What changed in each version is in the changelog.
Uninstall
claude plugin uninstall clawd-bar@clawd-bar
Gauges, one per meter: how full your context is (ctx), then each usage limit (5h, wk) with the time left until it resets. Green, yellow from 80 %, red from 90 %. They move only when something happens: a new value fills in a box at a time, crossing 80 or 90 sweeps the new colour across and flashes the percent, a limit that resets drains away and refills, and from 90 % the last box beats like a heart.
Three lines after the gauges:
Done in 2m 14s · 23 tools · 5 files.In a narrow terminal the bars shrink first, then only the percents stay, so the lines keep their room.
And Clawd himself. Every state is a little scene with its own props: a bulb that won't quite switch on while he thinks, a scroll he unrolls to read, paper planes for subagents, a bell when Claude needs you.
What he does acts out the very call that's running, with real text where pixels would only hint:
.ts), which swaps to the real diff (-2+2) while the old lines go red, get wiped and the new ones type in green. A one-line fix only bites the end off a line. When it lands he glances at you.+120), then a tick.$ git), the seconds counting up in the corner, and a ✓ or ✗ when it ends.41 ✓ over 7 ✗). He cheers a pass and turns to you on a failure. Counts come from vitest, jest, pytest, cargo, node, bun and mocha output; other runners settle on just the ✓ or ✗.npm, pnpm, uv, pip, …): parcels piling up under the package manager's name.The game cabinet. A command that runs past 20 s bores him: he glances at you from 15 s, then pulls out a game, and goes back to the command's result when it ends. The cabinet takes turns between a falling-block game (every game ends on a perfect clear, with T-spin showpieces between), snake, breakout and a runner jumping cacti. Each opens on its name, plays to its end and closes on GG!, and the next slow command gets the next game. Tools with no scene of their own play it too.
<img src="docs/readme/sprite-sheet.svg" alt="Every state Clawd acts out, each playing live, with what sets it off. Session: idle (nothing running), thinking (you send a prompt), editing (the real diff), writing (a new file), running (a command), ran (it ends, with ✓ or ✗ and its time), testing, tested (its real counts), installing (npm, pnpm, uv, pip), linking (MCP tools), games (a command past 20 s: the game cabinet), reading, searching, browsing, delegating (subagents run), needs you, compacting, done, error, interrupted, sweating (context past 90 %), dozing (1 minute idle), asleep (10 minutes idle), waking, exhausted (a usage limit runs out). CI race: prep, fetch, sign, build, publish, passed, failed.">
Start a build and the band becomes a race: the build's steps against your personal best, a ghost of your fastest run, and Clawd hauling packages, stamping the signature, hammering out the build and launching the release.
<img src="docs/readme/ci-race.svg" alt="A CI race in the band: Clawd carries a box, stamps a signature, hammers on an anvil and launches a rocket as the build moves through its steps, the 'you' track races a dimmer 'ghost' of the best run with the time and the gap beside them, and the build finishes with 'passed', NEW PB and a waving finish flag.">
A race starts when:
gh workflow run to start a GitHub Actions workflow/ci, which picks up the latest run for this repo, live or just finishedgh run watch <id>. In the background, Claude keeps its own watch and clawd-bar races alongside it. In the foreground, clawd-bar takes that run over and watches it in the background instead, so Claude's turn isn't stuck waiting on it (only while Let Claude continue after builds is on, since Claude gets its new turn from that)When the build finishes:
gh run watch clawd-bar took over: Claude was told to end its turn and wait, so a pass there wakes it to pick up where it left off. This only happens in a session open in the build's repo; anywhere else you just get the alerts. The log is marked as untrusted CI output, since anyone who can open a pull request writes some of it. Turn off Let Claude continue after builds to keep the race and the alerts without the new turn.Codemagic builds race too: set CODEMAGIC_API_TOKEN (or put the token in ~/.codemagic-token) and use /ci.
| Command | What it does |
|---|---|
/clawd | Hide or show him. Remembered across sessions. |
/clawd demo | Play every state on a loop, 6 s each. Run it again to stop. |
/clawd doctor | Check the setup: what he's showing, the gh login, CI and bridge status. |
/clawd desk | Switch Clawd on Desk over to clawd-bar. |
/ci | Watch the latest CI run for this repo. |
/ci stop | Stop watching. |
Open /config and find clawd-bar.
| Setting | Default | What it does |
|---|---|---|
| Show Clawd above the prompt | on | Turn the whole status area on or off. |
| Watch CI builds | on | Race builds and send alerts when they finish. |
| Let Claude continue after builds | on | Send Claude a new prompt when a build fails, or passes after clawd-bar took over its gh run watch. |
| ntfy topic | empty | An ntfy.sh topic name for phone pushes. Install the ntfy app, subscribe to the same topic, and you'll get a push when a build ends. Pick a long random name: anyone who knows it can read the pushes, which name your repo, branch and build. |
| Feed Clawd on Desk | on | Feed the Clawd on Desk app once /clawd desk has switched it over. Does nothing without the app. |
| Clawd on Desk app folder | empty | Found by itself. Set it only if /clawd doctor says the app wasn't found. |
| Node.js executable | node | Only used by the bridge. |
/clawd doctor. If /clawd isn't a command at all, the plugin didn't load: run /reload-plugins or restart Claude Code, and claude update if that doesn't help./clawd shows the doctor instead of hiding him. Show Clawd above the prompt is off in /config./ci doesn't exist. Another plugin already has /ci. Builds Claude starts still race./clawd doctor shows whether gh is logged in and what the CI watcher sees./clawd doctor says what's missing: the app's own hooks still installed (run /clawd desk), a restart still to do, or the app not found.If you also run the Clawd on Desk desktop pet, clawd-bar can send it this session's events in place of the app's clawd-hook.js command hooks, which start a new node process for every event. clawd-bar runs one small sidecar (tools/clawd-bridge.js) that loads the hook code from your installed app, so the desktop pet gets exactly what the hooks would have sent.
/clawd desk. It finds the app, backs up ~/.claude/settings.json, removes the app's per-event hooks and keeps everything else, including the permission hook and auto-start (it adds them back if they're missing)./clawd doctor should show the sidecar running and events being forwarded.Until step 2, clawd-bar sends nothing, so the desktop pet never hears an event twice. To go back, turn Manage Claude hooks automatically on again in Clawd on Desk; it reinstalls its own hooks, and clawd-bar steps aside at the next session.
claude plugin validate .
claude plugin test .
It's a Claude Code plugin made of function hooks, so it runs inside Claude Code itself. Both commands also run on GitHub Actions for every push and pull request.
hooks/scenes.ts: every scene, drawn fresh each frame on a 30 × 6 pixel canvashooks/pixels.ts: packs that canvas into terminal cells, four pixels to a cell in quadrant blockshooks/work.ts: reads a tool call (the diff, the program, the MCP server) and its result (test counts)hooks/well.ts: the falling-block game and its T-spin showpieceshooks/arcade.ts: the cabinet's other games: snake, breakout and the runnerhooks/pet-state.ts: turns session events into what he acts outhooks/band.tsx and hooks/bars.ts: lay out the gauges and lines, and move the barshooks/svg.ts: Clawd as an animated SVG, for the desktop app and this READMEhooks/ci/: watches the buildsHe is the Claude Code logo, and his outline never changes: everything he does is carried by his eyes, a flick of an arm, and the props beside him. Frames cut, never tween: at three terminal rows a half-pixel move can only twitch, so he moves the way old game sprites do.
node tools/preview/live.mjs plays every scene in your terminal. The README's art is drawn by the same code: after changing a scene or the band, run npm ci && npm run build in tools/readme-art (needs Node 23.5+, and the Cascadia Mono and Segoe UI Symbol fonts that ship with Windows).
Clawd on Desk by @rullerzhou-afk had the idea first: a Clawd who reacts to what your coding agent is doing, as a desktop pet. clawd-bar takes that idea into the terminal, and can feed Clawd on Desk if you run both.
Unofficial fan project. Clawd is Anthropic's character. This plugin is free, non-commercial, and not affiliated with Anthropic or with Clawd on Desk.
MIT for the code. The Clawd character belongs to Anthropic and isn't covered by it; this is an unofficial, non-commercial fan project.
hooks/register.tsx 868 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PluginOptions, Register } from 'claude-code'
3
4import type { ClawdAct, Place, Run, Work } from '../types'
5import { NO_ACTIVITY, applyActivity, withTaskCreated, withTaskUpdated, withTodos } from './activity'
6import { ciLines, ciShown, divider, fitBand, gaugeColumns, sessionLines } from './band'
7import { barMotion } from './bars'
8import type { SessionShown } from './band'
9import { DEMO_SCENES, DEMO_SCENE_MS, demoScene } from './demo'
10import { CAME_BACK, canFeed, inspectDesk, setupDesk, slashes } from './desk'
11import type { Desk, DeskHost, DeskPaths } from './desk'
12import { fetchGhRun, ghAuthLine, ghToRun, latestGhRunId } from './ci/actions'
13import { cmToRun, cmToken, fetchBuild, findApp, latestBuildId } from './ci/codemagic'
14import { classify, parseCodemagicStarted, watchedRun } from './ci/detect'
15import type { Host } from './ci/host'
16import { originRemote, repoSlug } from './ci/repo'
17import { addWatch, loadView, pollOnce, promiseWatches, showReplay, stopAll } from './ci/watcher'
18import { applyEvent, initialPet, settleWork, visualFor, withUsage } from './pet-state'
19import type { HookPayload } from './pet-state'
20import { encode } from './pixels'
21import { CABINET_GAMES, COLS as CLAWD_COLS, ROWS as CLAWD_ROWS, frameAt, isOnce, svgLoopMs } from './scenes'
22import type { Extras } from './scenes'
23import { svgFor } from './svg'
24import { meters } from './usage'
25import type { Meter } from './usage'
26
27const RASTER_KEY = 'clawd'
28const TICK_MS = 1000
29// Scenes are drawn fresh each frame; a frame that matches the last one isn't sent.
30const FRAME_MS = 100
31// The bars' smallest step is a 35 ms drain; 40 ms catches each box.
32const BAR_MS = 40
33// How long Clawd may look busy while Claude Code says no turn is running.
34const STALE_BUSY_MS = 5000
35const CI_POLL_MS = 30_000
36const GAP = 2
37// 4 px to a pixel of his: crisp at 100%, 125% and 150% scaling alike.
38const SVG_HEIGHT = 48
39// A frame left without a width takes the browser's 300 px.
40const SVG_ASPECT = (CLAWD_COLS * 2) / (CLAWD_ROWS * 4)
41// The desktop counts its width in columns of about 9 px: Clawd's 120 px take 14 of
42// them, and one ▊ draws half again as wide as a column in its font.
43const DESKTOP_CLAWD_COLS = 14
44const DESKTOP_BOX_COLS = 1.5
45// Desktop Svgs kept built: every edit or command result can be its own.
46const SVG_CACHE = 24
47// The scenes that act out one call: a new call starts its scene over.
48const CALL_ACTS: ReadonlySet<ClawdAct> = new Set(['editing', 'writing', 'running', 'ran', 'testing', 'tested', 'installing', 'installed', 'linking'])
49
50const pet = atom({ plugin: 'clawd-bar', key: 'pet' } as const, initialPet(0))
51const visual = atom({ plugin: 'clawd-bar', key: 'visual' } as const, { act: 'idle', label: 'Idle' })
52const isHidden = atom({ plugin: 'clawd-bar', key: 'isHidden' } as const, false)
53const activity = atom({ plugin: 'clawd-bar', key: 'activity' } as const, NO_ACTIVITY)
54const place = atom({ plugin: 'clawd-bar', key: 'place' } as const, null)
55const ciView = atom({ plugin: 'clawd-bar', key: 'view' } as const, null)
56// Bumped once a second while a clock is on the band, so it redraws.
57const second = atom({ plugin: 'clawd-bar', key: 'second' } as const, 0)
58// Bumped when the demo moves to its next scene: the desktop redraws on this, not every second.
59const demoStep = atom({ plugin: 'clawd-bar', key: 'demoStep' } as const, 0)
60// Bumped when a moving usage bar changes how it looks.
61const barFrame = atom({ plugin: 'clawd-bar', key: 'barFrame' } as const, 0)
62
63const DENY = "clawd-bar is already watching this run in the background. End your turn; you'll get a new turn when it finishes."
64const NO_TOKEN = "clawd-bar can't watch Codemagic without a token: set CODEMAGIC_API_TOKEN or write it to ~/.codemagic-token."
65
66// Module state: a hot reload starts it over, while the atoms live on in $.state.
67let settings = { installDir: '', nodePath: 'node', isPetOn: true, isBridgeOn: true, isCiOn: true, isContinueOn: true, ntfyTopic: '' }
68let isReady = false
69let dataDir = ''
70let transcriptPath = ''
71let selfRepo: string | null | undefined
72let animation: { key: string; act: ClawdAct; startedAt: number; requestId: string; extras: Extras; painted: string } | null = null
73let frameTimer: { cancel: () => void } | null = null
74const bars = barMotion()
75// What the terminal's bars last drew from, so the bar ticker can tell when a redraw is due.
76let barsShown: { meters: Meter[]; boxes: number; look: string } | null = null
77let barTimer: { cancel: () => void } | null = null
78let hasClock = false
79let demoStartedAt: number | null = null
80let demoIndex = -1
81// The desktop rebuilds an Svg's frame on every redraw; this is where its loop started.
82let desktopPlay: { key: string; startedAt: number } | null = null
83let isEngineWorking: boolean | null = null
84const svgs = new Map<string, string>()
85// Each spell at the cabinet starts on the next game.
86let cabinetSpell = -1
87let lastAct: ClawdAct | null = null
88
89// Resolves to `fallback` instead of hanging a queue when `promise` never settles.
90function within<T>($: EngineInterface, ms: number, promise: Promise<T>, fallback: T): Promise<T> {
91 return new Promise(resolve => {
92 const timer = $.clock.after(ms, () => resolve(fallback))
93 promise.then(
94 value => {
95 timer.cancel()
96 resolve(value)
97 },
98 () => {
99 timer.cancel()
100 resolve(fallback)
101 },
102 )
103 })
104}
105
106type BridgeConfig = {
107 nodePath: string
108 script: string
109 installDir: string
110 stateFile: string
111}
112
113type BridgeStatus = {
114 port: number | null
115 claudePid: number | null
116 forwarded: number
117 failed: number
118 lastError: string | null
119}
120
121type Endpoint = { port: number; claudePid: number | null }
122
123const RE_ENSURE_AFTER_MS = 10000
124const ENSURE_TIMEOUT_MS = 12000
125const POST_TIMEOUT_MS = 2000
126
127// Forwards classic hook payloads to tools/clawd-bridge.js, which runs the
128// installed Clawd on Desk app's own hook code. Fire-and-forget, in arrival order.
129let bridgeConfig: BridgeConfig | null = null
130let endpoint: Promise<Endpoint | null> | null = null
131let lastEnsureAt = 0
132let chain: Promise<void> = Promise.resolve()
133// Events wait on this, so none is dropped while the session looks for the app.
134let deskReady: Promise<void> = Promise.resolve()
135let desk: Desk | null = null
136let deskPaths: DeskPaths | null = null
137const DESK_RECHECK_MS = 8000
138
139const bridgeStatus: BridgeStatus = { port: null, claudePid: null, forwarded: 0, failed: 0, lastError: null }
140
141function configureBridge(next: BridgeConfig | null): void {
142 bridgeConfig = next
143 endpoint = null
144}
145
146async function ensureBridge($: EngineInterface): Promise<Endpoint | null> {
147 if (!bridgeConfig) return null
148 lastEnsureAt = await $.clock.now()
149 try {
150 const ran = await $.process.run(
151 [bridgeConfig.nodePath, bridgeConfig.script, 'ensure', '--install-dir', bridgeConfig.installDir, '--state-file', bridgeConfig.stateFile],
152 { timeoutMs: 10000 },
153 )
154 if (ran.exitCode !== 0) throw new Error(ran.stderr.trim() || `ensure exited ${ran.exitCode}`)
155 const info = JSON.parse(ran.stdout.trim().split('\n').at(-1) ?? '') as { port: number; claudePid?: number }
156 bridgeStatus.port = info.port
157 bridgeStatus.claudePid = info.claudePid ?? null
158 return { port: info.port, claudePid: info.claudePid ?? null }
159 } catch (error) {
160 bridgeStatus.lastError = String(error instanceof Error ? error.message : error)
161 return null
162 }
163}
164
165async function postEvent($: EngineInterface, target: Endpoint, body: string): Promise<boolean> {
166 const sent = $.http
167 .fetch(`http://127.0.0.1:${target.port}/event`, {
168 method: 'POST',
169 headers: { 'content-type': 'application/json' },
170 body,
171 })
172 .then(response => response.ok)
173 return within($, POST_TIMEOUT_MS, sent, false)
174}
175
176async function sendEvent($: EngineInterface, event: string, payload: HookPayload, eventAt: number): Promise<void> {
177 endpoint ??= ensureBridge($)
178 let target = await within($, ENSURE_TIMEOUT_MS, endpoint, null)
179 const body = (t: Endpoint) => JSON.stringify({ event, payload, claudePid: t.claudePid, eventAt })
180 if (target && (await postEvent($, target, body(target)))) {
181 bridgeStatus.forwarded += 1
182 return
183 }
184 // The sidecar exits on its own (idle, app update): start it again, rate-limited.
185 if ((await $.clock.now()) - lastEnsureAt >= RE_ENSURE_AFTER_MS) {
186 endpoint = ensureBridge($)
187 target = await within($, ENSURE_TIMEOUT_MS, endpoint, null)
188 if (target && (await postEvent($, target, body(target)))) {
189 bridgeStatus.forwarded += 1
190 return
191 }
192 }
193 bridgeStatus.failed += 1
194}
195
196function forwardEvent($: EngineInterface, event: string, payload: HookPayload, eventAt: number): void {
197 if (!settings.isBridgeOn) return
198 chain = chain
199 .then(() => deskReady)
200 .then(() => (bridgeConfig ? sendEvent($, event, payload, eventAt) : undefined))
201 .catch(() => undefined)
202}
203
204function deskHost($: EngineInterface): DeskHost {
205 return {
206 readFile: path => $.fs.read(path),
207 writeFile: (path, text) => $.fs.write(path, text),
208 exists: path => $.fs.exists(path),
209 run: (argv, timeoutMs = 15_000) => $.process.run(argv, { timeoutMs }),
210 }
211}
212
213const bridgeScript = ($: EngineInterface) => `${slashes($.plugin.root)}/tools/clawd-bridge.js`
214
215async function lookAtDesk($: EngineInterface, paths: DeskPaths): Promise<Desk> {
216 const remembered = await $.store.get('deskInstallDir')
217 const found = await inspectDesk(deskHost($), paths, {
218 override: settings.installDir,
219 remembered: typeof remembered === 'string' ? remembered : null,
220 })
221 if (found.installDir && found.installDir !== remembered) await $.store.set('deskInstallDir', found.installDir)
222 return found
223}
224
225// The feed starts only once the app's own hooks are gone, and only at session start:
226// this session's hooks were read from settings.json when it started.
227async function prepareDesk($: EngineInterface, paths: DeskPaths): Promise<void> {
228 desk = await lookAtDesk($, paths).catch(() => null)
229 configureBridge(
230 desk?.installDir && canFeed(desk)
231 ? { nodePath: settings.nodePath, script: bridgeScript($), installDir: desk.installDir, stateFile: `${dataDir}/bridge.json` }
232 : null,
233 )
234}
235
236function readSettings(options: PluginOptions): typeof settings {
237 return {
238 installDir: slashes(String(options.clawdInstallDir ?? '')),
239 nodePath: String(options.nodePath || 'node'),
240 isPetOn: options.pet !== false,
241 isBridgeOn: options.bridge !== false,
242 isCiOn: options.ci !== false,
243 isContinueOn: options.continueAfterBuilds !== false,
244 ntfyTopic: String(options.ntfyTopic ?? ''),
245 }
246}
247
248// Everything the CI watcher needs from the engine, as closures over `$`.
249function hostFrom($: EngineInterface): Host {
250 return {
251 now: () => $.clock.now(),
252 self: async () => {
253 if (selfRepo === undefined) {
254 const r = await $.process.run(['git', 'remote', 'get-url', 'origin'])
255 selfRepo = r.exitCode === 0 ? repoSlug(r.stdout.trim()) : null
256 }
257 return { id: await $.session.id(), repo: selfRepo ?? undefined }
258 },
259 // The engine wants literal names here so it can list what the mod reads.
260 env: async name => {
261 if (name === 'CODEMAGIC_API_TOKEN') return $.env.get('CODEMAGIC_API_TOKEN')
262 if (name === 'USERPROFILE') return $.env.get('USERPROFILE')
263 if (name === 'HOME') return $.env.get('HOME')
264 if (name === 'OS') return $.env.get('OS')
265 return undefined
266 },
267 readFile: path => $.fs.read(path),
268 fetch: (url, init) => $.http.fetch(url, init),
269 run: (argv, timeoutMs = 30_000) => $.process.run(argv, { timeoutMs }),
270 load: key => $.store.get(key),
271 save: (key, value) => $.store.set(key, value),
272 toast: text => $.ui.toast(text),
273 submit: async text => {
274 await $.prompt.submit({ text })
275 },
276 publish: async view => {
277 await update($, ciView, () => view)
278 },
279 }
280}
281
282async function refreshVisual($: EngineInterface): Promise<void> {
283 const next = visualFor(await read($, pet), await $.clock.now())
284 const shown = await read($, visual)
285 if (next.act !== shown.act || next.label !== shown.label) {
286 await update($, visual, () => next)
287 }
288}
289
290// The repo's name and branch for the footer; outside git, the folder's name.
291async function refreshPlace($: EngineInterface): Promise<void> {
292 const [remote, branch] = await Promise.all([
293 $.process.run(['git', 'remote', 'get-url', 'origin'], { timeoutMs: 5000 }).catch(() => null),
294 $.process.run(['git', 'branch', '--show-current'], { timeoutMs: 5000 }).catch(() => null),
295 ])
296 const folder = slashes(await $.session.cwd().catch(() => '')).split('/').pop() ?? ''
297 const slug = remote?.exitCode === 0 ? repoSlug(remote.stdout.trim()) : ''
298 const next: Place = {
299 repo: slug.split('/').pop() || folder,
300 branch: branch?.exitCode === 0 ? branch.stdout.trim() || null : null,
301 }
302 const shown = await read($, place)
303 if (shown?.repo !== next.repo || shown?.branch !== next.branch) await update($, place, () => next)
304}
305
306async function observe($: EngineInterface, event: string, payload: HookPayload): Promise<void> {
307 await ensureReady($)
308 const now = await $.clock.now()
309 if (typeof payload.transcript_path === 'string' && payload.transcript_path) {
310 transcriptPath = payload.transcript_path
311 }
312 // TurnEnded is this mod's own; Clawd on Desk only knows the hook events.
313 if (event !== 'TurnEnded') forwardEvent($, event, payload, now)
314 if (!settings.isPetOn) return
315 await update($, pet, p => applyEvent(p, event, payload, now))
316 await update($, activity, a => applyActivity(a, event, payload, now))
317 await refreshVisual($)
318 // A shell command may have switched branches.
319 if (event === 'PostToolUse' && (payload.tool_name === 'Bash' || payload.tool_name === 'PowerShell')) void refreshPlace($).catch(() => undefined)
320}
321
322const barLook = (meters: Meter[], boxes: number, now: number) => JSON.stringify(meters.map(m => bars.paint(m, boxes, now)))
323
324async function barTick($: EngineInterface): Promise<void> {
325 const now = await $.clock.now()
326 const shown = barsShown
327 if (!shown || !bars.isMoving(shown.meters, shown.boxes, now)) {
328 barTimer?.cancel()
329 barTimer = null
330 }
331 if (shown && barLook(shown.meters, shown.boxes, now) !== shown.look) await update($, barFrame, n => n + 1)
332}
333
334function stopAnimation(): void {
335 frameTimer?.cancel()
336 frameTimer = null
337 animation = null
338}
339
340const cellsAt = (playing: NonNullable<typeof animation>, now: number) => encode(frameAt(playing.act, now - playing.startedAt, playing.extras))
341
342async function paintFrame($: EngineInterface): Promise<void> {
343 const playing = animation
344 if (!playing) return
345 const cells = cellsAt(playing, await $.clock.now())
346 if (cells === playing.painted) return
347 playing.painted = cells
348 const blitted = await $.ui.blit({ requestId: playing.requestId, key: RASTER_KEY, cells })
349 // Not mounted (band hidden or collapsed): rest until the next draw.
350 if (blitted.deny !== undefined && animation === playing) stopAnimation()
351}
352
353async function tick($: EngineInterface): Promise<void> {
354 // The backstop for an ending no event reported: Claude Code says no turn runs.
355 const current = await read($, pet)
356 if (isEngineWorking === false && current.state !== 'idle' && (await $.clock.now()) - current.lastActivityAt > STALE_BUSY_MS) {
357 await observe($, 'TurnEnded', { reason: 'stale' })
358 }
359 const usage = await $.session.usage().then(u => u, () => null)
360 if (usage) {
361 const now = await $.clock.now()
362 const latest = await read($, pet)
363 const next = withUsage(latest, usage, now)
364 if (next !== latest) await update($, pet, () => next)
365 }
366 await refreshVisual($)
367 if (hasClock) await update($, second, n => n + 1)
368 if (demoStartedAt !== null) {
369 const index = Math.floor(((await $.clock.now()) - demoStartedAt) / DEMO_SCENE_MS)
370 if (index !== demoIndex) {
371 demoIndex = index
372 await update($, demoStep, n => n + 1)
373 }
374 }
375}
376
377const pollOpts = () => ({ ntfyTopic: settings.ntfyTopic, canContinue: settings.isContinueOn })
378
379async function deskLines($: EngineInterface): Promise<string[]> {
380 if (!settings.isBridgeOn) return ['· Clawd on Desk feed off in /config']
381 await deskReady
382 const now = deskPaths ? await lookAtDesk($, deskPaths).catch(() => desk) : desk
383 if (!now?.installDir) return ['· Clawd on Desk not found; if it\'s installed, set "Clawd on Desk app folder" in /config']
384 if (!now.hooks) return ["✗ couldn't read Claude's settings.json, so clawd-bar isn't feeding Clawd on Desk"]
385 const n = now.hooks.stateHooks
386 if (n > 0) return [`· Clawd on Desk runs its own ${n} per-event hook${n === 1 ? '' : 's'}, so clawd-bar isn't feeding it: /clawd desk switches it over`]
387 if (!bridgeConfig) return ['· Clawd on Desk is switched over; restart Claude Code to start feeding it']
388 const s = bridgeStatus
389 const lines = [
390 `${s.port ? '✓' : '✗'} bridge sidecar ${s.port ? `on port ${s.port}` : 'not running'}, Claude pid ${s.claudePid ?? '?'}`,
391 ` app folder ${bridgeConfig.installDir}`,
392 ` forwarded ${s.forwarded}, failed ${s.failed}${s.lastError ? `, last error: ${s.lastError}` : ''}`,
393 ]
394 if (s.port) {
395 const health = await within($, 1500, $.http.fetch(`http://127.0.0.1:${s.port}/health`).then(r => r.text), '')
396 if (health) lines.push(` sidecar says ${health}`)
397 }
398 if (!now.hooks.hasPermission) lines.push("✗ Clawd on Desk's permission hook is missing, so permission bubbles won't show: /clawd desk adds it back")
399 return lines
400}
401
402async function runDesk($: EngineInterface): Promise<string> {
403 await deskReady
404 if (!deskPaths) return "clawd-bar couldn't find your home folder."
405 const paths = deskPaths
406 const found = await lookAtDesk($, paths)
407 const done = await setupDesk(deskHost($), paths, {
408 installDir: found.installDir,
409 isBridgeOn: settings.isBridgeOn,
410 nodePath: settings.nodePath,
411 bridgeScript: bridgeScript($),
412 now: await $.clock.now(),
413 })
414 if (done.didWrite) {
415 // The app may still be watching settings.json and put its hooks back.
416 $.clock.after(DESK_RECHECK_MS, () => {
417 void lookAtDesk($, paths)
418 .then(later => {
419 if ((later.hooks?.stateHooks ?? 0) > 0) $.ui.toast(CAME_BACK)
420 })
421 .catch(() => undefined)
422 })
423 }
424 return done.text
425}
426
427async function doctor($: EngineInterface): Promise<string> {
428 const shown = await read($, visual)
429 const lines = [`clawd-bar · ${settings.isPetOn ? `showing "${shown.label}" (${shown.act})` : 'pet off in /config'}`]
430 if (settings.isCiOn) {
431 const view = await loadView(hostFrom($))
432 const token = await cmToken(hostFrom($))
433 lines.push(`· CI: watching ${view.watches.length} run(s); Codemagic token ${token ? 'found' : 'not set'}; phone pushes ${settings.ntfyTopic ? 'on' : 'off'}`)
434 lines.push(` ${ghAuthLine(await hostFrom($).run(['gh', 'auth', 'status'], 5000).catch(() => null))}`)
435 } else {
436 lines.push('· CI watching off in /config')
437 }
438 lines.push(...(await deskLines($)))
439 if (transcriptPath) lines.push(`· transcript ${transcriptPath}`)
440 return lines.join('\n')
441}
442
443// Idempotent, and retried from later events: a session can start before a
444// test has its fakes in place.
445async function ensureReady($: EngineInterface): Promise<void> {
446 if (isReady) return
447 const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? ''
448 if (!home) return
449 isReady = true
450 dataDir = `${slashes(home)}/.claude/clawd-bar`
451 const [appData, localAppData, configDir] = await Promise.all([$.env.get('APPDATA'), $.env.get('LOCALAPPDATA'), $.env.get('CLAUDE_CONFIG_DIR')])
452 deskPaths = {
453 home: slashes(home),
454 claudeDir: slashes(configDir || `${home}/.claude`),
455 ...(appData ? { appData: slashes(appData) } : {}),
456 ...(localAppData ? { localAppData: slashes(localAppData) } : {}),
457 }
458 configureBridge(null)
459 if (settings.isBridgeOn) deskReady = prepareDesk($, deskPaths)
460 if (settings.isCiOn) {
461 const h = hostFrom($)
462 $.clock.every(CI_POLL_MS, () => void pollOnce(h, pollOpts()).catch(() => undefined))
463 void pollOnce(h, pollOpts()).catch(() => undefined)
464 }
465 if (!settings.isPetOn) return
466 const now = await $.clock.now()
467 if ((await read($, pet)).lastActivityAt === 0) await update($, pet, () => initialPet(now))
468 await refreshVisual($)
469 if ((await $.store.get('isHidden')) === true) await update($, isHidden, () => true)
470 $.clock.every(TICK_MS, () => void tick($))
471 void refreshPlace($).catch(() => undefined)
472}
473
474async function startSession($: EngineInterface): Promise<void> {
475 await $.command.register({
476 name: 'clawd',
477 description: 'Show or hide Clawd above the prompt · /clawd doctor checks the setup · /clawd demo plays every state · /clawd desk feeds Clawd on Desk',
478 argumentHint: '[doctor|demo|desk]',
479 })
480 if (settings.isCiOn) {
481 // Another plugin may own /ci already; the band and the build watching don't need it.
482 await $.command
483 .register({ name: 'ci', description: 'Watch the latest CI run for this repo above the prompt (/ci stop to stop watching)' })
484 .catch(() => undefined)
485 }
486 await ensureReady($)
487}
488
489async function runCommand($: EngineInterface, args: string): Promise<string> {
490 await ensureReady($)
491 if (args.trim() === 'desk') return runDesk($)
492 if (args.trim() === 'doctor' || !settings.isPetOn) return doctor($)
493 if (args.trim() === 'demo') {
494 if (demoStartedAt !== null) {
495 demoStartedAt = null
496 await update($, second, n => n + 1)
497 await update($, demoStep, n => n + 1)
498 return 'Demo stopped.'
499 }
500 if (await read($, isHidden)) return 'Clawd is tucked away: /clawd to bring him back, then /clawd demo.'
501 demoStartedAt = await $.clock.now()
502 demoIndex = 0
503 await update($, second, n => n + 1)
504 await update($, demoStep, n => n + 1)
505 return `Playing all ${DEMO_SCENES} states above the prompt, ${DEMO_SCENE_MS / 1000} s each, on a loop. /clawd demo stops it.`
506 }
507 const hide = !(await read($, isHidden))
508 await update($, isHidden, () => hide)
509 await $.store.set('isHidden', hide)
510 return hide ? 'Clawd tucked away. /clawd brings him back.' : 'Clawd is back above the prompt.'
511}
512
513async function repoHere(h: Host) {
514 const remote = await originRemote(h)
515 return remote ? { remote, slug: repoSlug(remote) } : undefined
516}
517
518const isLive = (run: Run) => run.state === 'running' || run.state === 'queued'
519
520async function runCi($: EngineInterface, args: string) {
521 const h = hostFrom($)
522 if (args.trim() === 'stop') {
523 const n = await stopAll(h)
524 return { text: n ? `Stopped watching ${n} run${n > 1 ? 's' : ''}.` : 'Nothing was being watched.' }
525 }
526 const here = await repoHere(h)
527 if (!here) return { text: 'This folder has no git origin, so there is no CI to watch.' }
528
529 // Look at both providers: a running Actions check beats yesterday's finished Codemagic build.
530 const found: Run[] = []
531 const token = await cmToken(h)
532 const app = token ? await findApp(h, token, here.remote).catch(() => undefined) : undefined
533 if (token && app) {
534 const id = await latestBuildId(h, token, app.id).catch(() => undefined)
535 if (id) found.push(cmToRun(await fetchBuild(h, token, id), here.slug))
536 }
537 let ghError: string | undefined
538 try {
539 const id = await latestGhRunId(h, here.slug)
540 if (id) found.push(ghToRun(await fetchGhRun(h, here.slug, id), here.slug))
541 } catch (err) {
542 ghError = (err as Error).message
543 }
544
545 const live = found.find(isLive)
546 if (live) {
547 await addWatch(h, { provider: live.provider, id: live.id, repo: here.slug })
548 await pollOnce(h, pollOpts())
549 return { text: `Watching ${live.label}.` }
550 }
551 const newest = found.sort((a, b) => (b.finishedAt ?? b.startedAt ?? 0) - (a.finishedAt ?? a.startedAt ?? 0))[0]
552 if (newest) {
553 await showReplay(h, newest)
554 return { text: `${newest.label} already finished (${newest.state}). It's above the prompt; nothing to watch.` }
555 }
556 if (ghError) {
557 return { text: /auth login/.test(ghError) ? "gh isn't logged in, so Actions can't be watched. Run `gh auth login`." : `Couldn't read Actions runs: ${ghError}` }
558 }
559 return { text: token ? `No Codemagic app or Actions run found for ${here.slug}.` : `No Actions run found for ${here.slug}, and no Codemagic token is set (CODEMAGIC_API_TOKEN or ~/.codemagic-token).` }
560}
561
562async function raceWatchedRun($: EngineInterface, command: string, isPromised: boolean) {
563 const h = hostFrom($)
564 const target = watchedRun(command)
565 const repo = target?.repo ?? (await repoHere(h))?.slug
566 if (!target || !repo) return false
567 await addWatch(h, { provider: 'actions', id: target.id, repo, isPromised })
568 $.clock.after(5_000, () => void pollOnce(h, pollOpts()).catch(() => undefined))
569 return true
570}
571
572// A foreground watch is only refused when clawd-bar really is watching that run, so the
573// reason it gives is true. Anything it can't take over runs as asked.
574async function takeOverWatch($: EngineInterface, command: string, provider: 'codemagic' | 'actions') {
575 if (provider === 'actions') return raceWatchedRun($, command, true)
576 const h = hostFrom($)
577 const here = await repoHere(h)
578 if (!here) return false
579 return promiseWatches(h, w => w.provider === 'codemagic' && w.repo.toLowerCase() === here.slug.toLowerCase())
580}
581
582type Shell = { command: string; run_in_background?: boolean; tool_use_id?: string }
583type ShellResult = { deny?: string; isError?: boolean; text?: string; result?: { stdout?: string } | null }
584
585async function onShell($: EngineInterface, e: Shell, next: (e: Shell) => Promise<ShellResult>) {
586 const ran = await watchShell($, e, next)
587 await settleShell($, e, ran)
588 return ran
589}
590
591// The command's result, straight from the call, for its result scene; PostToolUse brings the same.
592async function settleShell($: EngineInterface, e: Shell, ran: ShellResult): Promise<void> {
593 if (!settings.isPetOn || ran.deny !== undefined) return
594 const now = await $.clock.now()
595 const payload = ran.isError ? { tool_use_id: e.tool_use_id, error: ran.text ?? '' } : { tool_use_id: e.tool_use_id, tool_response: ran.result }
596 await update($, pet, p => settleWork(p, payload, ran.isError === true, now))
597 await refreshVisual($)
598}
599
600async function watchShell($: EngineInterface, e: Shell, next: (e: Shell) => Promise<ShellResult>) {
601 if (!settings.isCiOn) return next(e)
602 const d = classify(e.command)
603 // The refusal promises Claude a new turn, so it only happens when one will come.
604 if (d.kind === 'foreground-watch' && settings.isContinueOn && !e.run_in_background && (await takeOverWatch($, e.command, d.provider))) {
605 return { deny: DENY }
606 }
607 // Claude keeps its own watch, usually in the background, so this one races without promising a turn.
608 if (d.kind === 'foreground-watch' && d.provider === 'actions') await raceWatchedRun($, e.command, false)
609 const ran = await next(e)
610 if (ran.deny !== undefined || ran.isError) return ran
611 const h = hostFrom($)
612 if (d.kind === 'codemagic-start') {
613 const id = parseCodemagicStarted(String(ran.result?.stdout ?? ''))
614 const here = await repoHere(h)
615 if (id && here) {
616 if (!(await cmToken(h))) {
617 $.ui.toast(NO_TOKEN)
618 return ran
619 }
620 await addWatch(h, { provider: 'codemagic', id, repo: here.slug })
621 $.clock.after(5_000, () => void pollOnce(h, pollOpts()).catch(() => undefined))
622 }
623 } else if (d.kind === 'actions-start') {
624 const repo = d.repo ?? (await repoHere(h))?.slug
625 if (repo) {
626 await addWatch(h, { provider: 'actions', id: '', repo, pending: { workflow: d.workflow, ref: d.ref, since: await h.now() } })
627 $.clock.after(8_000, () => void pollOnce(h, pollOpts()).catch(() => undefined))
628 }
629 }
630 return ran
631}
632
633type ToolRan = { deny?: string; isError?: boolean; result?: unknown }
634
635// The session's task list, followed through the tools that change it.
636async function onTasks($: EngineInterface, tool: string, input: Record<string, unknown>, ran: ToolRan): Promise<void> {
637 if (ran.deny !== undefined || ran.isError) return
638 if (tool === 'TodoWrite') {
639 await update($, activity, a => withTodos(a, input.todos))
640 } else if (tool === 'TaskCreate') {
641 const task = (ran.result as { task?: { id?: unknown } } | null)?.task
642 const id = typeof task?.id === 'string' ? task.id : ''
643 await update($, activity, a => withTaskCreated(a, id, String(input.subject ?? ''), typeof input.activeForm === 'string' ? input.activeForm : undefined))
644 } else if (tool === 'TaskUpdate') {
645 await update($, activity, a => withTaskUpdated(a, String(input.taskId ?? ''), input))
646 }
647}
648
649type BandEvent = Parameters<EngineInterface['ui']['resolve']>[0] & {
650 requestId: string
651 props: { hasSurvey: boolean; isWorking: boolean; bodyColumns: number }
652}
653
654async function drawBand($: EngineInterface, e: BandEvent, next: (e: BandEvent) => unknown) {
655 if (e.props.hasSurvey || !settings.isPetOn || (await read($, isHidden))) {
656 stopAnimation()
657 return next(e)
658 }
659 isEngineWorking = e.props.isWorking
660 const now = await $.clock.now()
661 const isTerminal = e.surface === 'terminal'
662 if (isTerminal) await read($, second)
663 else await read($, demoStep)
664 if (isTerminal) await read($, barFrame)
665 const scene = demoStartedAt === null ? null : demoScene(now - demoStartedAt, now)
666 const view = scene ? (scene.ci ?? null) : settings.isCiOn ? ((await read($, ciView)) ?? (await loadView(hostFrom($)))) : null
667 const ci = scene || settings.isCiOn ? ciShown(view, now) : null
668 const shownVisual = await read($, visual)
669 const shownPet = await read($, pet)
670 const session: SessionShown = scene?.session ?? {
671 ...shownVisual,
672 activity: await read($, activity),
673 subagents: shownPet.subagents.length,
674 place: await read($, place),
675 }
676 const act = ci?.act ?? session.act
677 if (act === 'working' && lastAct !== 'working') cabinetSpell++
678 lastAct = act
679 const work: Work | null = (scene ? scene.work : shownPet.work) ?? null
680 const extras: Extras = ci
681 ? {}
682 : {
683 sweat: (scene ? scene.sweat : shownVisual.isSweating) === true,
684 planes: session.subagents,
685 ...(work ? { work } : {}),
686 ...(act === 'working' ? { game: Math.max(0, cabinetSpell) % CABINET_GAMES } : {}),
687 }
688 const playKey = CALL_ACTS.has(act) && work ? `${act}:${work.id}` : act
689 const usage = scene ? null : await $.session.usage().then(u => u, () => null)
690 const meterList: Meter[] = scene ? scene.meters : usage ? meters(usage, now) : []
691 hasClock = scene !== null || ci !== null || session.activity.turnStartedAt !== null
692 // A live race's lanes move every second; on the desktop that's the one redraw a second worth its rebuild.
693 if (!isTerminal && ci?.run) await read($, second)
694
695 const columns = e.props.bodyColumns
696 const clawdCols = isTerminal ? CLAWD_COLS : DESKTOP_CLAWD_COLS
697 const boxCols = isTerminal ? 1 : DESKTOP_BOX_COLS
698 const fit = fitBand(columns, clawdCols, meterList, 10, boxCols, !isTerminal)
699 const textWidth = Math.max(10, columns - clawdCols - GAP - (fit.gauges ? fit.width + 2 : 0))
700 const els = $.ui.resolve(e) as never as { Box: any; Text: any; Client?: any }
701 const { Box, Client } = els
702 const turnStartedAt = session.activity.turnStartedAt
703 const turnClock =
704 !isTerminal && Client && turnStartedAt !== null ? (
705 <Client key="turn-clock" module="./ticker.tsx" props={{ elapsedMs: now - turnStartedAt }} />
706 ) : undefined
707 const lines = ci ? ciLines(els, ci, view, now, textWidth) : sessionLines(els, session, now, turnClock)
708 // The terminal's bars move when their values change; the desktop's stand still, since a redraw there costs Clawd his frame.
709 if (isTerminal) {
710 bars.see(meterList, now)
711 barsShown = { meters: meterList, boxes: fit.boxes, look: barLook(meterList, fit.boxes, now) }
712 if (!barTimer && bars.isMoving(meterList, fit.boxes, now)) barTimer = $.clock.every(BAR_MS, () => void barTick($))
713 }
714 const paint = isTerminal ? (m: Meter, boxes: number) => bars.paint(m, boxes, now) : undefined
715 const right = [
716 <Box key="gauges" flexDirection="row" marginLeft={GAP} flexShrink={0}>
717 {gaugeColumns(els, meterList, fit, paint)}
718 </Box>,
719 fit.gauges ? divider(els) : null,
720 <Box key="lines" flexDirection="column" marginLeft={1} flexGrow={1} flexShrink={1} minWidth={0}>
721 {lines}
722 </Box>,
723 ]
724
725 if (e.surface === 'terminal') {
726 const { Raster } = $.ui.resolve(e as never) as never as { Raster: any }
727 // A new label over the same act keeps its place in the loop; a new call starts its scene over.
728 if (!animation || animation.key !== playKey || animation.requestId !== e.requestId) {
729 stopAnimation()
730 animation = { key: playKey, act, startedAt: now, requestId: e.requestId, extras, painted: '' }
731 frameTimer = $.clock.every(FRAME_MS, () => void paintFrame($))
732 }
733 animation.extras = work ? { ...extras, lead: animation.startedAt - work.startedAt } : extras
734 animation.painted = cellsAt(animation, now)
735 return (
736 <Box flexDirection="row">
737 <Raster key={RASTER_KEY} columns={CLAWD_COLS} rows={CLAWD_ROWS} cells={animation.painted} />
738 {right}
739 </Box>
740 )
741 }
742
743 const { Svg } = $.ui.resolve(e as never) as never as { Svg: any }
744 const drawn = CALL_ACTS.has(act) && work ? JSON.stringify({ ...work, id: undefined, startedAt: undefined }) : `${extras.game ?? ''}`
745 const svgKey = `${act}:${extras.sweat ? 1 : 0}:${extras.planes ?? 0}:${drawn}`
746 const svg = svgs.get(svgKey) ?? svgFor(act, extras)
747 // Most recently used last, so the oldest goes first.
748 svgs.delete(svgKey)
749 svgs.set(svgKey, svg)
750 if (svgs.size > SVG_CACHE) svgs.delete(svgs.keys().next().value!)
751 if (desktopPlay?.key !== `${svgKey}:${playKey}`) desktopPlay = { key: `${svgKey}:${playKey}`, startedAt: now }
752 // Start the rebuilt frame where the loop already is, so a redraw doesn't send him back to the start.
753 const loop = svgLoopMs(act, extras)
754 const into = isOnce(act) ? Math.min(now - desktopPlay.startedAt, loop) : (now - desktopPlay.startedAt) % loop
755 const source = svg.replace('<style>', `<style>*{animation-delay:-${into}ms!important}`)
756 return (
757 <Box flexDirection="row" alignItems="center">
758 <Svg source={source} alt={session.label} width={SVG_HEIGHT * SVG_ASPECT} height={SVG_HEIGHT} isInteractive />
759 {right}
760 </Box>
761 )
762}
763
764export const register: Register = (on, options) => {
765 settings = readSettings(options)
766
767 on('session.start', async ($, e, next) => {
768 await startSession($)
769 return next(e)
770 })
771
772 on('command.run', { command: 'clawd' }, async ($, e) => ({ text: await runCommand($, e.args) }))
773 on('command.run', { command: 'ci' }, ($, e) => runCi($, e.args))
774
775 on('ui.render', { component: 'AbovePrompt' }, ($, e, next) => drawBand($, e as never, next as never) as never)
776
777 on('tool.call', { tool: 'Bash' }, ($, e, next) => onShell($, e, next as never) as never)
778 on('tool.call', { tool: 'PowerShell' }, ($, e, next) => onShell($, e, next as never) as never)
779 on('tool.call', { tool: 'TodoWrite' }, async ($, e, next) => {
780 const ran = await next(e)
781 await onTasks($, 'TodoWrite', e as never, ran as never)
782 return ran
783 })
784 on('tool.call', { tool: 'TaskCreate' }, async ($, e, next) => {
785 const ran = await next(e)
786 await onTasks($, 'TaskCreate', e as never, ran as never)
787 return ran
788 })
789 on('tool.call', { tool: 'TaskUpdate' }, async ($, e, next) => {
790 const ran = await next(e)
791 await onTasks($, 'TaskUpdate', e as never, ran as never)
792 return ran
793 })
794
795 on('turn.complete', async ($, e, next) => {
796 const result = await next(e)
797 if (e.agentId === undefined) await observe($, 'TurnEnded', { reason: e.reason })
798 return result
799 })
800
801 on('classic.SessionStart', async ($, e, next) => {
802 await observe($, 'SessionStart', e)
803 return next(e)
804 })
805 on('classic.SessionEnd', async ($, e, next) => {
806 await observe($, 'SessionEnd', e)
807 return next(e)
808 })
809 on('classic.UserPromptSubmit', async ($, e, next) => {
810 await observe($, 'UserPromptSubmit', e)
811 return next(e)
812 })
813 on('classic.PreToolUse', async ($, e, next) => {
814 // classic.PreToolUse carries the tool call envelope, not the hook's stdin JSON.
815 const { tool, tool_use_id: toolUseId, consent: _consent, ...input } = e as unknown as Record<string, unknown>
816 await observe($, 'PreToolUse', {
817 hook_event_name: 'PreToolUse',
818 session_id: await $.session.id(),
819 transcript_path: transcriptPath,
820 cwd: await $.session.cwd(),
821 tool_name: tool,
822 tool_input: input,
823 tool_use_id: toolUseId,
824 })
825 return next(e)
826 })
827 on('classic.PostToolUse', async ($, e, next) => {
828 await observe($, 'PostToolUse', e)
829 return next(e)
830 })
831 on('classic.PostToolUseFailure', async ($, e, next) => {
832 await observe($, 'PostToolUseFailure', e)
833 return next(e)
834 })
835 on('classic.Stop', async ($, e, next) => {
836 await observe($, 'Stop', e)
837 return next(e)
838 })
839 on('classic.StopFailure', async ($, e, next) => {
840 await observe($, 'StopFailure', e)
841 return next(e)
842 })
843 on('classic.SubagentStart', async ($, e, next) => {
844 await observe($, 'SubagentStart', e)
845 return next(e)
846 })
847 on('classic.SubagentStop', async ($, e, next) => {
848 await observe($, 'SubagentStop', e)
849 return next(e)
850 })
851 on('classic.PreCompact', async ($, e, next) => {
852 await observe($, 'PreCompact', e)
853 return next(e)
854 })
855 on('classic.PostCompact', async ($, e, next) => {
856 await observe($, 'PostCompact', e)
857 return next(e)
858 })
859 on('classic.Notification', async ($, e, next) => {
860 await observe($, 'Notification', e)
861 return next(e)
862 })
863 on('classic.Elicitation', async ($, e, next) => {
864 await observe($, 'Elicitation', e)
865 return next(e)
866 })
867}
868hooks/activity.ts 161 lines1import type { Activity, TaskItem } from '../types'
2import type { HookPayload } from './pet-state'
3
4export const NO_ACTIVITY: Activity = { doing: null, turnStartedAt: null, tools: 0, files: [], lastTurn: null, tasks: [] }
5
6const EDIT_TOOLS = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit'])
7const PLAN_TOOLS = new Set(['TodoWrite', 'TaskCreate', 'TaskUpdate', 'TaskList', 'TaskGet'])
8
9const str = (value: unknown) => (typeof value === 'string' ? value : '')
10const base = (path: string) => path.replace(/\\/g, '/').split('/').filter(Boolean).pop() ?? path
11const clip = (text: string, max: number) => (text.length > max ? `${text.slice(0, max - 1)}…` : text)
12const firstLine = (text: string) => text.trim().split('\n')[0] ?? ''
13
14function host(url: string): string {
15 return /^[a-z]+:\/\/([^/]+)/i.exec(url)?.[1] ?? url
16}
17
18// "Editing sprites.ts", "Running npm test": what a tool call is doing, in a few words.
19export function describeTool(tool: string, input: Record<string, unknown>): string {
20 const path = str(input.file_path) || str(input.notebook_path)
21 switch (tool) {
22 case 'Read':
23 return `Reading ${base(path)}`
24 case 'Edit':
25 case 'MultiEdit':
26 case 'NotebookEdit':
27 return `Editing ${base(path)}`
28 case 'Write':
29 return `Writing ${base(path)}`
30 case 'Bash':
31 case 'PowerShell': {
32 const description = firstLine(str(input.description))
33 return description ? clip(description, 60) : `Running ${clip(firstLine(str(input.command)), 48)}`
34 }
35 case 'Grep':
36 return `Searching "${clip(str(input.pattern), 40)}"`
37 case 'Glob':
38 return `Finding ${clip(str(input.pattern), 40)}`
39 case 'WebFetch':
40 return `Reading ${host(str(input.url))}`
41 case 'WebSearch':
42 return `Searching the web for "${clip(str(input.query), 40)}"`
43 case 'Agent':
44 case 'Task':
45 return `Handing off: ${clip(str(input.description) || 'a subagent', 50)}`
46 case 'Skill':
47 return `Using the ${str(input.skill) || 'a'} skill`
48 }
49 if (PLAN_TOOLS.has(tool)) return 'Planning the tasks'
50 const mcp = /^mcp__(.+?)__(.+)$/.exec(tool)
51 if (mcp) return `${mcp[2]!.replace(/_/g, ' ')} (${mcp[1]!.replace(/^plugin_[^_]+_/, '').replace(/_/g, ' ')})`
52 return `Using ${tool}`
53}
54
55export function applyActivity(a: Activity, event: string, payload: HookPayload, now: number): Activity {
56 switch (event) {
57 case 'SessionStart':
58 return { ...NO_ACTIVITY }
59 case 'UserPromptSubmit':
60 return { ...a, doing: null, turnStartedAt: now, tools: 0, files: [] }
61 case 'PreToolUse': {
62 const tool = str(payload.tool_name)
63 const input = (payload.tool_input ?? {}) as Record<string, unknown>
64 const path = str(input.file_path) || str(input.notebook_path)
65 const files = EDIT_TOOLS.has(tool) && path && !a.files.includes(path) ? [...a.files, path] : a.files
66 return { ...a, doing: describeTool(tool, input), tools: a.tools + 1, files, turnStartedAt: a.turnStartedAt ?? now }
67 }
68 case 'Stop':
69 case 'StopFailure':
70 case 'TurnEnded':
71 return {
72 ...a,
73 doing: null,
74 turnStartedAt: null,
75 lastTurn: a.turnStartedAt === null ? a.lastTurn : { ms: now - a.turnStartedAt, tools: a.tools, files: a.files.length },
76 }
77 default:
78 return a
79 }
80}
81
82type Todo = { content?: unknown; status?: unknown; activeForm?: unknown }
83
84const status = (value: unknown): TaskItem['status'] =>
85 value === 'completed' || value === 'in_progress' ? value : 'pending'
86
87// TodoWrite hands over the whole list every time.
88export function withTodos(a: Activity, todos: unknown): Activity {
89 if (!Array.isArray(todos)) return a
90 const tasks = (todos as Todo[]).map((todo, i): TaskItem => ({
91 id: `todo-${i}`,
92 subject: str(todo.content),
93 activeForm: str(todo.activeForm) || undefined,
94 status: status(todo.status),
95 }))
96 return { ...a, tasks }
97}
98
99export function withTaskCreated(a: Activity, id: string, subject: string, activeForm?: string): Activity {
100 if (!id || a.tasks.some(task => task.id === id)) return a
101 return { ...a, tasks: [...a.tasks, { id, subject, activeForm, status: 'pending' }] }
102}
103
104export function withTaskUpdated(a: Activity, id: string, patch: { status?: unknown; subject?: unknown; activeForm?: unknown }): Activity {
105 if (patch.status === 'deleted') return { ...a, tasks: a.tasks.filter(task => task.id !== id) }
106 return {
107 ...a,
108 tasks: a.tasks.map(task =>
109 task.id !== id
110 ? task
111 : {
112 ...task,
113 status: patch.status === undefined ? task.status : status(patch.status),
114 subject: str(patch.subject) || task.subject,
115 activeForm: str(patch.activeForm) || task.activeForm,
116 },
117 ),
118 }
119}
120
121export function taskLine(tasks: TaskItem[]): string | null {
122 if (tasks.length === 0 || tasks.every(task => task.status === 'completed')) return null
123 const done = tasks.filter(task => task.status === 'completed').length
124 const current = tasks.find(task => task.status === 'in_progress') ?? tasks.find(task => task.status === 'pending')
125 const what = current ? (current.status === 'in_progress' ? current.activeForm || current.subject : current.subject) : ''
126 return `Tasks ${done}/${tasks.length}${what ? ` ▶ ${what}` : ''}`
127}
128
129export function clock(ms: number): string {
130 const s = Math.max(0, Math.floor(ms / 1000))
131 const h = Math.floor(s / 3600)
132 const m = Math.floor((s % 3600) / 60)
133 const sec = String(s % 60).padStart(2, '0')
134 return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${sec}` : `${m}:${sec}`
135}
136
137export function took(ms: number): string {
138 const s = Math.max(0, Math.round(ms / 1000))
139 if (s < 60) return `${s}s`
140 const m = Math.floor(s / 60)
141 return m < 60 ? `${m}m ${s % 60}s` : `${Math.floor(m / 60)}h ${m % 60}m`
142}
143
144const plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`
145
146export function turnLine(a: Activity, subagents: number): string | null {
147 if (a.turnStartedAt !== null) {
148 if (a.tools === 0 && subagents === 0) return null
149 const parts = [plural(a.tools, 'tool')]
150 if (a.files.length) parts.push(`${plural(a.files.length, 'file')} changed`)
151 if (subagents) parts.push(plural(subagents, 'subagent'))
152 return parts.join(' · ')
153 }
154 if (a.lastTurn) {
155 const parts = [`Done in ${took(a.lastTurn.ms)}`, plural(a.lastTurn.tools, 'tool')]
156 if (a.lastTurn.files) parts.push(plural(a.lastTurn.files, 'file'))
157 return parts.join(' · ')
158 }
159 return null
160}
161hooks/band.tsx 283 lines1import type { Activity, CiPose, ClawdAct, LastResult, Place, Run, View } from '../types'
2import { clock, taskLine, turnLine } from './activity'
3import { RESULT_MS, fmt, fmtDelta } from './ci/messages'
4import { poseForStep } from './ci/poses'
5import { elapsed, lanes, liveDelta, recordKey } from './ci/splits'
6import { AMBER, BOX, GREEN, RED } from './usage'
7import type { Meter } from './usage'
8import { stillBar } from './bars'
9import type { BarPaint, Paint } from './bars'
10
11// The surface's element table, as $.ui.resolve(e) hands it over.
12type Els = { Box: any; Text: any }
13
14const BLUE = 'suggestion'
15const STEP_W = 24
16// Long enough to read the race; any longer only stretches the dots across a wide terminal.
17const LANE_W = 30
18const BRANCH = '⎇'
19
20const CI_ACTS: Record<CiPose, ClawdAct> = {
21 idle: 'ciPrep',
22 prep: 'ciPrep',
23 fetch: 'ciFetch',
24 sign: 'ciSign',
25 build: 'ciBuild',
26 publish: 'ciPublish',
27 win: 'ciWin',
28 fail: 'ciFail',
29}
30
31const clip = (s: string, w: number) => (s.length > w ? `${s.slice(0, w - 1)}…` : s)
32const repoName = (slug: string) => slug.split('/').pop() ?? slug
33
34export function placeText(place: Place | null): string {
35 if (!place) return ''
36 return place.branch ? `${place.repo} · ${BRANCH} ${place.branch}` : place.repo
37}
38
39// --- CI race --------------------------------------------------------------
40
41export type CiShown = { pose: CiPose; act: ClawdAct; run?: Run; last?: LastResult }
42
43const freshResult = (view: View | null, now: number) =>
44 view?.last && now - view.last.at < RESULT_MS ? view.last : undefined
45
46// What the band shows for CI right now, or nothing when no build is live or just done.
47export function ciShown(view: View | null, now: number): CiShown | null {
48 const run = view?.watches.find(w => w.run)?.run
49 if (run) {
50 const pose = poseForStep(run.steps.find(s => s.state === 'running')?.name ?? 'prepar')
51 return { pose, act: CI_ACTS[pose], run }
52 }
53 const last = freshResult(view, now)
54 if (!last) return null
55 const pose: CiPose = last.isReplay ? 'idle' : last.run.state === 'passed' ? 'win' : last.run.state === 'failed' ? 'fail' : 'idle'
56 return { pose, act: CI_ACTS[pose], last }
57}
58
59export function ciLines(els: Els, shown: CiShown, view: View | null, now: number, width: number) {
60 return shown.run ? live(els, shown.run, view, now, width) : result(els, shown.last!)
61}
62
63function live({ Box, Text }: Els, run: Run, view: View | null, now: number, width: number) {
64 const rec = view?.records[recordKey(run)]
65 const lane = lanes(run, rec, now)
66 const delta = liveDelta(run, rec, now)
67 const laneW = Math.min(LANE_W, Math.max(8, width - 6 - 2 - STEP_W - 1))
68 const track = (p: number, ch: string) => `${'━'.repeat(Math.round(p * laneW))}${ch}`.padEnd(laneW + 1, '·')
69 const more = (view?.watches.length ?? 1) - 1
70 const cur = run.steps.find(s => s.state === 'running')
71 const idx = cur ? run.steps.indexOf(cur) + 1 : run.steps.filter(s => s.state === 'passed').length
72 const step = cur ? `${clip(cur.name, STEP_W - 8)} ${idx}/${run.steps.length}` : run.state === 'queued' ? 'queued' : 'starting'
73
74 return [
75 // As wide as the lanes, so the clock sits over the end of the race rather than the terminal's edge.
76 <Box key="title" flexDirection="row" justifyContent="space-between" width={Math.min(width, 6 + laneW + 1 + 2 + STEP_W)}>
77 <Text bold wrap="truncate-end">
78 {run.label}
79 <Text dimColor>
80 {' '}· {repoName(run.repo)} · {BRANCH} {run.branch}
81 {more > 0 ? ` · +${more} more` : ''}
82 </Text>
83 </Text>
84 <Text>
85 <Text bold>{fmt(elapsed(run, now))}</Text>
86 {delta !== undefined ? <Text color={delta <= 0 ? GREEN : RED}> {fmtDelta(delta)}</Text> : ''}
87 </Text>
88 </Box>,
89 <Text key="you" wrap="truncate-end">
90 <Text color={AMBER}>you </Text>
91 {track(lane.you, '▶')} <Text color={BLUE}>● </Text>
92 {step}
93 </Text>,
94 <Text key="ghost" dimColor wrap="truncate-end">
95 {lane.ghost !== undefined ? `ghost ${track(lane.ghost, '▷')}` : 'first run · setting the PB'}
96 </Text>,
97 ]
98}
99
100function result({ Text }: Els, last: LastResult) {
101 const r = last.run
102 const color = r.state === 'passed' ? GREEN : r.state === 'failed' ? RED : undefined
103 const mark = r.state === 'passed' ? '✓' : r.state === 'failed' ? '✗' : '–'
104
105 return [
106 <Text key="title" bold wrap="truncate-end">
107 <Text color={color}>
108 {mark} {r.label} {r.state}
109 </Text>
110 <Text dimColor> · {fmt(elapsed(r, r.finishedAt ?? 0))}</Text>
111 {last.isNewPB ? <Text color={AMBER}> NEW PB</Text> : ''}
112 </Text>,
113 <Text key="where" dimColor wrap="truncate-end">
114 {repoName(r.repo)} · {BRANCH} {r.branch}
115 </Text>,
116 <Text key="blank"> </Text>,
117 ]
118}
119
120// --- Session --------------------------------------------------------------
121
122export type SessionShown = {
123 act: ClawdAct
124 label: string
125 activity: Activity
126 subagents: number
127 place: Place | null
128 note?: string
129}
130
131const ACT_COLOR: Partial<Record<ClawdAct, string>> = {
132 calling: AMBER,
133 error: RED,
134 interrupted: AMBER,
135 done: GREEN,
136 compacting: BLUE,
137}
138
139const WORKING: ReadonlySet<ClawdAct> = new Set([
140 'working',
141 'reading',
142 'searching',
143 'browsing',
144 'editing',
145 'writing',
146 'running',
147 'ran',
148 'testing',
149 'tested',
150 'installing',
151 'installed',
152 'linking',
153])
154
155function headline(shown: SessionShown): string {
156 const { act, label, activity } = shown
157 if (WORKING.has(act)) return activity.doing ?? label
158 if (act === 'idle' && activity.lastTurn) return 'Idle'
159 return label
160}
161
162// `turnClock` stands in for the clock text where the surface ticks it itself.
163export function sessionLines({ Box, Text }: Els, shown: SessionShown, now: number, turnClock?: unknown) {
164 const { activity } = shown
165 const running = activity.turnStartedAt !== null
166 const second = taskLine(activity.tasks) ?? turnLine(activity, shown.subagents)
167 const isAsleep = shown.act === 'dozing' || shown.act === 'sleeping'
168
169 return [
170 <Box key="title" flexDirection="row" justifyContent="space-between">
171 <Text bold color={ACT_COLOR[shown.act]} dimColor={isAsleep} wrap="truncate-end">
172 {headline(shown)}
173 </Text>
174 {running ? (turnClock ?? <Text bold>{clock(now - activity.turnStartedAt!)}</Text>) : <Text bold>{''}</Text>}
175 </Box>,
176 <Text key="turn" dimColor={!running} wrap="truncate-end">
177 {second ?? ' '}
178 </Text>,
179 <Text key="place" dimColor wrap="truncate-end">
180 {[placeText(shown.place), shown.note].filter(Boolean).join(' · ') || ' '}
181 </Text>,
182 ]
183}
184
185// --- Usage gauges -------------------------------------------------------
186
187// How the band fits a width: boxes per bar (0 drops the bars and keeps the
188// percents), whether the gauges show at all, whether they stack one meter to a
189// row, and the columns they take.
190export type Fit = { boxes: number; gauges: boolean; isStacked: boolean; width: number }
191
192const GAP = 2
193const MIN_TEXT = 28
194const BOX_STEPS = [10, 8, 6, 4]
195
196// `boxCols` is how many columns one ▊ takes: 1 in a terminal, wider in the desktop's font.
197const barWidth = (boxes: number, boxCols: number) => (boxes > 0 ? Math.ceil(boxes * boxCols) + 1 : 0)
198const reset = (m: Meter) => (m.resetIn ? `↻ ${m.resetIn}` : '')
199
200// Side by side: a column per meter, three rows each.
201function columnsWidth(meters: Meter[], boxes: number, boxCols: number): number {
202 const one = (m: Meter) => Math.max(m.name.length, barWidth(boxes, boxCols) + `${m.percent}%`.length, reset(m).length)
203 return meters.reduce((sum, m) => sum + one(m) + GAP, 0)
204}
205
206// Stacked: a row per meter, its name, bar, percent and countdown in columns.
207function stackWidth(meters: Meter[], boxes: number, boxCols: number): number {
208 const widest = (f: (m: Meter) => string) => Math.max(...meters.map(m => f(m).length))
209 const resets = widest(reset)
210 return widest(m => m.name) + 1 + barWidth(boxes, boxCols) + widest(m => `${m.percent}%`) + (resets ? 2 + resets : 0) + GAP
211}
212
213// Shrink the bars before the text; drop the bars, then the gauges, before Clawd.
214// Stacked suits the desktop, whose text runs wide and has no smaller size.
215export function fitBand(columns: number, clawd: number, meters: Meter[], maxBoxes = 10, boxCols = 1, isStacked = false): Fit {
216 const none = { boxes: 0, gauges: false, isStacked, width: 0 }
217 if (meters.length === 0) return none
218 for (const boxes of [...BOX_STEPS.filter(n => n <= maxBoxes), 0]) {
219 const width = isStacked ? stackWidth(meters, boxes, boxCols) : columnsWidth(meters, boxes, boxCols)
220 if (clawd + GAP + width + 2 + MIN_TEXT <= columns) return { boxes, gauges: true, isStacked, width }
221 }
222 return none
223}
224
225// The bar as runs of boxes painted alike, a Text each.
226function bar({ Text }: Els, paint: BarPaint) {
227 const runs: { box: Paint; n: number }[] = []
228 for (const box of paint.boxes) {
229 const last = runs.at(-1)
230 if (last && last.box.color === box.color && last.box.isDim === box.isDim) last.n++
231 else runs.push({ box, n: 1 })
232 }
233 return runs.map(({ box, n }, i) => (
234 <Text key={i} color={box.color} dimColor={box.isDim}>
235 {BOX.repeat(n)}
236 </Text>
237 ))
238}
239
240const percent = ({ Text }: Els, m: Meter, isFlashing = false) => (
241 <Text bold inverse={isFlashing} color={m.percent >= 80 ? m.color : undefined}>
242 {m.percent}%
243 </Text>
244)
245
246// Side by side: `ctx` over `▊▊▊▊▊▊▊▊▊▊ 34%` over `↻ 2h 0m`. Stacked: `ctx ▊▊▊▊▊ 34% ↻ 2h 0m` a row each.
247// `paint` draws a moving bar; left out, each bar stands still at its value.
248export function gaugeColumns(els: Els, meters: Meter[], fit: Fit, paint: (m: Meter, boxes: number) => BarPaint = stillBar) {
249 const { Box, Text } = els
250 if (!fit.gauges) return []
251 const paints = meters.map(m => paint(m, fit.boxes))
252 if (fit.isStacked) {
253 const column = (key: string, cell: (m: Meter, i: number) => unknown, marginRight = 1) => (
254 <Box key={key} flexDirection="column" marginRight={marginRight} flexShrink={0}>
255 {meters.map(cell)}
256 </Box>
257 )
258 return [
259 column('names', (m, i) => <Text key={i} dimColor>{m.name}</Text>),
260 fit.boxes > 0 ? column('bars', (_, i) => <Text key={i}>{bar(els, paints[i]!)}</Text>) : null,
261 column('percents', (m, i) => <Box key={i} justifyContent="flex-end">{percent(els, m, paints[i]!.isFlashing)}</Box>, meters.some(m => m.resetIn) ? 2 : GAP),
262 meters.some(m => m.resetIn) ? column('resets', (m, i) => <Text key={i} dimColor>{reset(m) || ' '}</Text>, GAP) : null,
263 ]
264 }
265 return meters.map((m, i) => (
266 <Box key={`g${i}`} flexDirection="column" marginRight={GAP} flexShrink={0}>
267 <Text dimColor>{m.name}</Text>
268 <Text>
269 {fit.boxes > 0 ? bar(els, paints[i]!) : ''}
270 {fit.boxes > 0 ? ' ' : ''}
271 {percent(els, m, paints[i]!.isFlashing)}
272 </Text>
273 <Text dimColor>{reset(m) || ' '}</Text>
274 </Box>
275 ))
276}
277
278export const divider = ({ Text }: Els) => (
279 <Text key="divider" dimColor>
280 {'│\n│\n│'}
281 </Text>
282)
283hooks/bars.ts 105 lines1import { filledBoxes, levelColor } from './usage'
2import type { Meter } from './usage'
3
4// The usage bars move only when something happened: a new value fills in or
5// drains out a box at a time, crossing 80 or 90 sweeps the new colour across and
6// flashes the percent, a window that resets drains away and refills, and from 90
7// up the last box beats like a heart.
8
9// One box: a theme colour, dimmed or not; `undefined` colour is the text colour (the glint).
10export type Paint = { color: string | undefined; isDim: boolean }
11export type BarPaint = { boxes: Paint[]; isFlashing: boolean }
12
13const STEP_MS = 110
14const DRAIN_MS = 35
15const WAVE_MS = 45
16const FLASH_MS = 600
17const BEAT_MS = 1600
18
19const GLINT: Paint = { color: undefined, isDim: false }
20const EMPTY: Paint = { color: undefined, isDim: true }
21const RANK: Record<string, number> = { [levelColor(0)]: 0, [levelColor(80)]: 1, [levelColor(90)]: 2 }
22
23// A bar at rest at its value.
24export function stillBar(meter: Meter, boxes: number): BarPaint {
25 const n = filledBoxes(meter.percent, boxes)
26 return { boxes: Array.from({ length: boxes }, (_, i) => (i < n ? { color: meter.color, isDim: false } : EMPTY)), isFlashing: false }
27}
28
29type Motion = { from: number; to: number; at: number; isReset: boolean }
30
31export function barMotion() {
32 const seen = new Map<string, number>()
33 const motions = new Map<string, Motion>()
34
35 // How long a motion runs at this many boxes, and the moments its phases change.
36 const plan = (m: Motion, boxes: number) => {
37 const a = filledBoxes(m.from, boxes)
38 const b = filledBoxes(m.to, boxes)
39 const drain = m.isReset ? a * DRAIN_MS : 0
40 const fill = (m.isReset ? b : Math.abs(b - a)) * STEP_MS
41 const isRising = RANK[levelColor(m.to)]! > RANK[levelColor(m.from)]!
42 const wave = isRising ? Math.max(b * WAVE_MS + WAVE_MS, FLASH_MS) : 0
43 return { a, b, drain, fill, isRising, end: drain + fill + wave }
44 }
45
46 return {
47 // Notes new values; a change starts a motion from whatever the bar shows now.
48 see(meters: Meter[], now: number): void {
49 for (const m of meters) {
50 const last = seen.get(m.name)
51 seen.set(m.name, m.percent)
52 if (last === undefined || last === m.percent) continue
53 // A window's limit only falls when the window resets.
54 const isReset = m.resetIn !== undefined && m.percent < last - 20
55 motions.set(m.name, { from: last, to: m.percent, at: now, isReset })
56 }
57 },
58
59 paint(meter: Meter, boxes: number, now: number): BarPaint {
60 const color = levelColor(meter.percent)
61 const motion = motions.get(meter.name)
62 const steady = (n: number, c: string): Paint[] =>
63 Array.from({ length: boxes }, (_, i) => (i < n ? { color: c, isDim: false } : EMPTY))
64
65 if (motion) {
66 const p = plan(motion, boxes)
67 const k = now - motion.at
68 if (k < p.end) {
69 const old = levelColor(motion.from)
70 if (k < p.drain) return { boxes: steady(p.a - Math.floor(k / DRAIN_MS), old), isFlashing: false }
71 const f = k - p.drain
72 if (f < p.fill) {
73 const start = motion.isReset ? 0 : p.a
74 const moved = Math.floor(f / STEP_MS) + 1
75 const n = p.b >= start ? Math.min(p.b, start + moved) : Math.max(p.b, start - moved)
76 const shown = steady(n, motion.isReset || !p.isRising ? color : old)
77 if (n > start && n - 1 < boxes) shown[n - 1] = GLINT
78 return { boxes: shown, isFlashing: false }
79 }
80 const w = k - p.drain - p.fill
81 const front = w / WAVE_MS + 1
82 const shown = steady(p.b, old).map((box, i) => (i >= p.b ? box : i < front - 1 ? { color, isDim: false } : i < front ? GLINT : box))
83 return { boxes: shown, isFlashing: w < FLASH_MS && w % 200 < 100 }
84 }
85 motions.delete(meter.name)
86 }
87
88 const shown = steady(filledBoxes(meter.percent, boxes), color)
89 const last = filledBoxes(meter.percent, boxes) - 1
90 if (meter.percent >= 90 && last >= 0) {
91 const k = now % BEAT_MS
92 const isBeat = k < 120 || (k >= 240 && k < 360)
93 shown[last] = { color, isDim: !isBeat }
94 }
95 return { boxes: shown, isFlashing: false }
96 },
97
98 // Whether any bar is moving at this many boxes, so the band knows to keep redrawing.
99 isMoving(meters: Meter[], boxes: number, now: number): boolean {
100 for (const [name, m] of motions) if (meters.some(x => x.name === name) && now - m.at < plan(m, boxes).end) return true
101 return boxes > 0 && meters.some(m => m.percent >= 90)
102 },
103 }
104}
105hooks/demo.ts 151 lines1import type { Activity, ClawdAct, Run, SplitRecord, View, Work } from '../types'
2import { NO_ACTIVITY } from './activity'
3import type { SessionShown } from './band'
4import type { Meter } from './usage'
5import { levelColor } from './usage'
6
7// `/clawd demo`: every state the band can show, one after another, with
8// made-up numbers, so the whole look can be checked in a real terminal.
9export const DEMO_SCENE_MS = 6000
10
11export type DemoScene = {
12 meters: Meter[]
13 session?: SessionShown
14 sweat?: boolean
15 work?: Work
16 ci?: View
17}
18
19const meter = (name: string, percent: number, resetIn?: string): Meter => ({ name, percent, color: levelColor(percent), ...(resetIn ? { resetIn } : {}) })
20
21const METERS: Meter[] = [meter('ctx', 61), meter('5h', 38, '1h 2m'), meter('wk', 22, '3d 12h')]
22const FULL: Meter[] = [meter('ctx', 92), meter('5h', 84, '18m'), meter('wk', 22, '3d 12h')]
23const SPENT: Meter[] = [meter('ctx', 34), meter('5h', 100, '18m'), meter('wk', 41, '3d 12h')]
24const PLACE = { repo: 'clawd-bar', branch: 'main' }
25
26type SessionSpec = {
27 act: ClawdAct
28 label: string
29 activity?: Partial<Activity>
30 subagents?: number
31 meters?: Meter[]
32 sweat?: boolean
33 work?: Omit<Work, 'id' | 'startedAt'>
34}
35
36const TASKS: Activity['tasks'] = [
37 { id: '1', subject: 'Read ci-watch', status: 'completed' },
38 { id: '2', subject: 'Port the watcher', status: 'completed' },
39 { id: '3', subject: 'Draw the CI poses', status: 'completed' },
40 { id: '4', subject: 'Write the tests', activeForm: 'Writing the tests', status: 'in_progress' },
41 { id: '5', subject: 'Update the README', status: 'pending' },
42 { id: '6', subject: 'Commit', status: 'pending' },
43 { id: '7', subject: 'Ship it', status: 'pending' },
44]
45
46const STEPS = ['Preparing runner', 'Fetching sources', 'Install packages', 'Sign the release', 'Build and test', 'Publish release']
47
48const RECORD: SplitRecord = {
49 label: 'Actions release',
50 total: 430,
51 steps: [26, 44, 98, 121, 380, 430].map((at, i) => ({ name: STEPS[i]!, at })),
52}
53
54function race(now: number, running: number): Run {
55 const startedAt = now - [20, 50, 110, 140, 260, 410][running]! * 1000
56 return {
57 provider: 'actions',
58 id: 'demo',
59 repo: 'KaiC5504/clawd-bar',
60 workflow: 'release',
61 label: 'Actions release',
62 branch: 'main',
63 url: '',
64 state: 'running',
65 startedAt,
66 steps: STEPS.map((name, i) => ({
67 name,
68 state: i < running ? 'passed' : i === running ? 'running' : 'pending',
69 startedAt: i <= running ? startedAt + (RECORD.steps[i - 1]?.at ?? 0) * 1000 : undefined,
70 finishedAt: i < running ? startedAt + RECORD.steps[i]!.at * 1000 : undefined,
71 })),
72 }
73}
74
75function ciView(now: number, running: number | 'passed' | 'failed'): View {
76 const base = { records: { 'KaiC5504/clawd-bar#release': RECORD }, notice: null }
77 if (typeof running === 'number') {
78 return { ...base, watches: [{ provider: 'actions', id: 'demo', repo: 'KaiC5504/clawd-bar', addedAt: now, errors: 0, run: race(now, running) }], last: null }
79 }
80 const run: Run = {
81 ...race(now, 5),
82 state: running,
83 finishedAt: now - 2000,
84 steps: STEPS.map((name, i) => ({ name, state: running === 'failed' && i === 4 ? 'failed' : running === 'failed' && i > 4 ? 'pending' : 'passed' })),
85 }
86 return { ...base, watches: [], last: { run, isNewPB: running === 'passed', at: now - 1000, isReplay: false } }
87}
88
89function session(spec: SessionSpec, now: number, index: number, total: number, sceneAt: number): DemoScene {
90 const activity: Activity = { ...NO_ACTIVITY, ...spec.activity }
91 if (spec.activity?.turnStartedAt !== undefined && spec.activity.turnStartedAt !== null) activity.turnStartedAt = now - spec.activity.turnStartedAt
92 return {
93 meters: spec.meters ?? METERS,
94 ...(spec.sweat ? { sweat: true } : {}),
95 // Each scene is a call of its own, begun when the scene came up.
96 ...(spec.work ? { work: { ...spec.work, id: `demo${index}`, startedAt: sceneAt } } : {}),
97 session: {
98 act: spec.act,
99 label: spec.label,
100 activity,
101 subagents: spec.subagents ?? 0,
102 place: PLACE,
103 note: `demo ${index + 1}/${total} · /clawd demo to stop`,
104 },
105 }
106}
107
108const RUNNING = { turnStartedAt: 102_000 }
109
110// turnStartedAt in a spec is how long ago the turn began.
111const SESSION_SCENES: SessionSpec[] = [
112 { act: 'idle', label: 'Idle' },
113 { act: 'thinking', label: 'Thinking…', activity: { turnStartedAt: 4000 } },
114 { act: 'editing', label: 'Working · Edit', work: { kind: 'edit', ext: 'ts', removed: 2, added: 2 }, activity: { ...RUNNING, doing: 'Editing scenes.ts', tools: 14, files: ['a', 'b', 'c'], tasks: TASKS } },
115 { act: 'writing', label: 'Working · Write', work: { kind: 'write', ext: 'md', lines: 120 }, activity: { ...RUNNING, doing: 'Writing CHANGELOG.md', tools: 15, files: ['a', 'b', 'c', 'd'] } },
116 { act: 'running', label: 'Working · Bash', work: { kind: 'shell', cmd: 'git' }, activity: { ...RUNNING, doing: 'Running git status', tools: 16, files: ['a', 'b', 'c', 'd'] } },
117 { act: 'ran', label: 'Working · Bash', work: { kind: 'shell', cmd: 'tsc', result: { ok: false, ms: 12_000 } }, activity: { ...RUNNING, doing: 'Running tsc --noEmit', tools: 17, files: ['a', 'b', 'c', 'd'] } },
118 { act: 'testing', label: 'Working · Bash', work: { kind: 'tests', cmd: 'vitest' }, activity: { ...RUNNING, doing: 'Running npm test', tools: 18, files: ['a', 'b', 'c', 'd'] } },
119 { act: 'tested', label: 'Working · Bash', work: { kind: 'tests', cmd: 'vitest', result: { ok: false, ms: 9000, passed: 41, failed: 7 } }, activity: { ...RUNNING, doing: 'Running npm test', tools: 18, files: ['a', 'b', 'c', 'd'] } },
120 { act: 'tested', label: 'Working · Bash', work: { kind: 'tests', cmd: 'vitest', result: { ok: true, ms: 8000, passed: 48, failed: 0 } }, activity: { ...RUNNING, doing: 'Running npm test', tools: 20, files: ['a', 'b', 'c', 'd'] } },
121 { act: 'installing', label: 'Working · Bash', work: { kind: 'install', cmd: 'pnpm' }, activity: { ...RUNNING, doing: 'Running pnpm add zod', tools: 21, files: ['a', 'b', 'c', 'd'] } },
122 { act: 'linking', label: 'Working · mcp__linear__list_issues', work: { kind: 'mcp', server: 'linear' }, activity: { ...RUNNING, doing: 'list issues (linear)', tools: 22, files: ['a', 'b', 'c', 'd'] } },
123 { act: 'working', label: 'Working · Bash', activity: { ...RUNNING, doing: 'Running npm run build', tools: 23, files: ['a', 'b', 'c', 'd'] } },
124 { act: 'reading', label: 'Working · Read', activity: { ...RUNNING, doing: 'Reading band.tsx', tools: 15, files: ['a', 'b', 'c'] } },
125 { act: 'searching', label: 'Working · Grep', activity: { ...RUNNING, doing: 'Searching for blit', tools: 16, files: ['a', 'b', 'c'] } },
126 { act: 'browsing', label: 'Working · WebFetch', activity: { ...RUNNING, doing: 'Fetching the changelog', tools: 17, files: ['a', 'b', 'c'] } },
127 { act: 'delegating', label: 'Juggling 2 subagents', subagents: 2, activity: { ...RUNNING, doing: 'Handing off: Explore the repo', tools: 4 } },
128 { act: 'calling', label: 'Needs you', activity: { ...RUNNING, tools: 3 } },
129 { act: 'done', label: 'Done!', activity: { lastTurn: { ms: 134_000, tools: 23, files: 5 } } },
130 { act: 'error', label: 'Something broke', activity: { ...RUNNING, tools: 6 } },
131 { act: 'interrupted', label: 'Interrupted', activity: { lastTurn: { ms: 41_000, tools: 7, files: 1 } } },
132 { act: 'working', label: 'Context almost full', meters: FULL, sweat: true, activity: { ...RUNNING, tools: 38, files: ['a', 'b', 'c', 'd'] } },
133 { act: 'compacting', label: 'Compacting', meters: FULL, activity: { ...RUNNING, tools: 41, files: ['a', 'b', 'c', 'd'] } },
134 { act: 'dozing', label: 'Dozing', activity: { lastTurn: { ms: 134_000, tools: 23, files: 5 } } },
135 { act: 'sleeping', label: 'Zzz', activity: { lastTurn: { ms: 134_000, tools: 23, files: 5 } } },
136 { act: 'exhausted', label: 'Out of usage · resets in 18m', meters: SPENT, activity: { lastTurn: { ms: 134_000, tools: 23, files: 5 } } },
137 { act: 'waking', label: 'Waking up', activity: { turnStartedAt: 1000 } },
138]
139
140// A build at each pose, by the step running (prep, fetch, sign, build, publish), then both endings.
141const CI: (number | 'passed' | 'failed')[] = [0, 2, 3, 4, 5, 'passed', 'failed']
142
143export const DEMO_SCENES = SESSION_SCENES.length + CI.length
144
145export function demoScene(elapsedMs: number, now: number): DemoScene {
146 const index = Math.floor(elapsedMs / DEMO_SCENE_MS) % DEMO_SCENES
147 const spec = SESSION_SCENES[index]
148 if (spec) return session(spec, now, index, DEMO_SCENES, now - (elapsedMs % DEMO_SCENE_MS))
149 return { meters: METERS, ci: ciView(now, CI[index - SESSION_SCENES.length]!) }
150}
151hooks/desk.ts 261 lines1// Clawd on Desk installs one `clawd-hook.js` command hook per Claude Code event in
2// settings.json, each starting a node process. The bridge replaces those, so this file
3// finds the app, reads what it has installed, and swaps its hooks for the bridge.
4
5export type HookSpec = { type?: string; command?: string; url?: string; [key: string]: unknown }
6export type HookGroup = { matcher?: string; hooks: HookSpec[]; [key: string]: unknown }
7export type ClaudeSettings = { hooks?: Record<string, HookGroup[]>; [key: string]: unknown }
8
9export type DeskHooks = {
10 stateHooks: number
11 hasPermission: boolean
12 hasAutoStart: boolean
13 // Where the app's hook commands point, when any still do.
14 installDir: string | null
15}
16
17export type DeskPrefs = {
18 // null when the prefs file can't be read: an older app, or not installed.
19 isManagingHooks: boolean | null
20 isAutoStartOn: boolean
21}
22
23// What register.tsx hands in, built from `$`.
24export type DeskHost = {
25 readFile: (path: string) => Promise<string>
26 writeFile: (path: string, text: string) => Promise<void>
27 exists: (path: string) => Promise<boolean>
28 run: (argv: string[], timeoutMs?: number) => Promise<{ exitCode: number; stdout: string; stderr: string }>
29}
30
31export type DeskPaths = {
32 home: string
33 claudeDir: string
34 appData?: string
35 localAppData?: string
36}
37
38export const slashes = (path: string): string => path.replace(/\\/g, '/').replace(/\/+$/, '')
39
40const hooksOf = (s: ClaudeSettings): HookSpec[] => Object.values(s.hooks ?? {}).flatMap(groups => groups.flatMap(g => g.hooks ?? []))
41
42export const isStateHook = (h: HookSpec): boolean => h.type === 'command' && /clawd-hook\.js/.test(h.command ?? '')
43const isAutoStartHook = (h: HookSpec): boolean => h.type === 'command' && /auto-start\.js/.test(h.command ?? '')
44const isPermissionHook = (h: HookSpec): boolean => h.type === 'http' && /^http:\/\/127\.0\.0\.1:\d+\/permission/.test(h.url ?? '')
45
46const HOOK_SCRIPT = /(?:"([^"]+)|([^\s"]+))[\\/]hooks[\\/](?:clawd-hook|auto-start)\.js/
47
48export function readDeskHooks(s: ClaudeSettings): DeskHooks {
49 const hooks = hooksOf(s)
50 let installDir: string | null = null
51 for (const h of hooks) {
52 const m = HOOK_SCRIPT.exec(h.type === 'command' ? (h.command ?? '') : '')
53 if (m) {
54 installDir = slashes(m[1] ?? m[2] ?? '')
55 break
56 }
57 }
58 return {
59 stateHooks: hooks.filter(isStateHook).length,
60 hasPermission: hooks.some(isPermissionHook),
61 hasAutoStart: hooks.some(isAutoStartHook),
62 installDir,
63 }
64}
65
66export type Rendered = { permission: HookGroup | null; autoStart: HookGroup | null }
67
68// Drops the per-event hooks and puts back the permission and auto-start hooks when they
69// are missing, as the app's own installer rendered them. Everything else stays as it was.
70export function switchToBridge(s: ClaudeSettings, rendered: Rendered): { settings: ClaudeSettings; removed: number; added: string[] } {
71 const next = JSON.parse(JSON.stringify(s)) as ClaudeSettings
72 const before = readDeskHooks(next)
73 const hooks: Record<string, HookGroup[]> = next.hooks ?? {}
74 let removed = 0
75 for (const [event, groups] of Object.entries(hooks)) {
76 const kept = groups
77 .map(g => {
78 const left = (g.hooks ?? []).filter(h => !isStateHook(h))
79 removed += (g.hooks ?? []).length - left.length
80 return { ...g, hooks: left }
81 })
82 .filter(g => g.hooks.length > 0)
83 if (kept.length > 0) hooks[event] = kept
84 else delete hooks[event]
85 }
86 const added: string[] = []
87 if (!before.hasPermission && rendered.permission) {
88 hooks.PermissionRequest = [rendered.permission, ...(hooks.PermissionRequest ?? [])]
89 added.push('the permission hook')
90 }
91 if (!before.hasAutoStart && rendered.autoStart) {
92 hooks.SessionStart = [rendered.autoStart, ...(hooks.SessionStart ?? [])]
93 added.push('the auto-start hook')
94 }
95 next.hooks = hooks
96 return { settings: next, removed, added }
97}
98
99export const settingsPath = (p: DeskPaths): string => `${p.claudeDir}/settings.json`
100
101export async function readClaudeSettings(host: DeskHost, p: DeskPaths): Promise<ClaudeSettings | null> {
102 if (!(await host.exists(settingsPath(p)))) return {}
103 try {
104 const parsed: unknown = JSON.parse(await host.readFile(settingsPath(p)))
105 return parsed && typeof parsed === 'object' ? (parsed as ClaudeSettings) : null
106 } catch {
107 return null
108 }
109}
110
111function installCandidates(p: DeskPaths): string[] {
112 const unpacked = 'resources/app.asar.unpacked'
113 const out: string[] = []
114 if (p.localAppData) {
115 out.push(`${p.localAppData}/Programs/Clawd on Desk/${unpacked}`, `${p.localAppData}/Programs/clawd-on-desk/${unpacked}`)
116 }
117 if (p.appData) out.push(`C:/Program Files/Clawd on Desk/${unpacked}`)
118 else out.push('/Applications/Clawd on Desk.app/Contents/Resources/app.asar.unpacked')
119 return out.map(slashes)
120}
121
122const isInstallDir = (host: DeskHost, dir: string) => host.exists(`${dir}/hooks/clawd-hook.js`).catch(() => false)
123
124// The /config override wins; then where its hooks point, the folder found last time,
125// and the usual install folders.
126export async function findInstallDir(host: DeskHost, p: DeskPaths, o: { override: string; fromHooks: string | null; remembered: string | null }): Promise<string | null> {
127 if (o.override) return slashes(o.override)
128 for (const dir of [o.fromHooks, o.remembered, ...installCandidates(p)]) {
129 if (dir && (await isInstallDir(host, dir))) return slashes(dir)
130 }
131 if (!p.appData) return null
132 for (const dir of await registryInstallDirs(host)) {
133 if (await isInstallDir(host, dir)) return dir
134 }
135 return null
136}
137
138// The Windows installer records where it put the app, wherever the person chose.
139async function registryInstallDirs(host: DeskHost): Promise<string[]> {
140 const out: string[] = []
141 for (const hive of ['HKCU', 'HKLM']) {
142 const ran = await host
143 .run(['reg', 'query', `${hive}\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall`, '/s', '/f', 'Clawd on Desk', '/d'], 5000)
144 .catch(() => null)
145 for (const m of ran?.stdout.matchAll(/"([^"]+)[\\/]Uninstall Clawd on Desk\.exe"/gi) ?? []) {
146 if (m[1]) out.push(`${slashes(m[1])}/resources/app.asar.unpacked`)
147 }
148 }
149 return [...new Set(out)]
150}
151
152function prefsCandidates(p: DeskPaths): string[] {
153 const file = 'clawd-on-desk/clawd-prefs.json'
154 if (p.appData) return [`${p.appData}/${file}`]
155 return [`${p.home}/Library/Application Support/${file}`, `${p.home}/.config/${file}`]
156}
157
158export async function readDeskPrefs(host: DeskHost, p: DeskPaths): Promise<DeskPrefs> {
159 for (const path of prefsCandidates(p)) {
160 if (!(await host.exists(path).catch(() => false))) continue
161 try {
162 const prefs = JSON.parse(await host.readFile(path)) as Record<string, unknown>
163 return {
164 isManagingHooks: typeof prefs.manageClaudeHooksAutomatically === 'boolean' ? prefs.manageClaudeHooksAutomatically : null,
165 isAutoStartOn: prefs.autoStartWithClaude !== false,
166 }
167 } catch {
168 break
169 }
170 }
171 return { isManagingHooks: null, isAutoStartOn: true }
172}
173
174export type Desk = { installDir: string | null; hooks: DeskHooks | null; prefs: DeskPrefs }
175
176export async function inspectDesk(host: DeskHost, p: DeskPaths, o: { override: string; remembered: string | null }): Promise<Desk> {
177 const s = await readClaudeSettings(host, p)
178 const hooks = s ? readDeskHooks(s) : null
179 const installDir = await findInstallDir(host, p, { ...o, fromHooks: hooks?.installDir ?? null })
180 return { installDir, hooks, prefs: await readDeskPrefs(host, p) }
181}
182
183// Feeding the app while its own hooks are installed would show it every event twice.
184export const canFeed = (d: Desk): boolean => d.installDir !== null && d.hooks !== null && d.hooks.stateHooks === 0
185
186const MANAGED =
187 'Clawd on Desk is still managing its Claude hooks, so it would put them straight back. In Clawd on Desk, open Settings → Agents, turn off "Manage Claude hooks automatically" and choose "Disable automatic management only". Then run /clawd desk again.'
188
189export const CAME_BACK =
190 'Clawd on Desk put its per-event hooks back. In Clawd on Desk, open Settings → Agents, turn off "Manage Claude hooks automatically" and choose "Disable automatic management only". Then run /clawd desk again.'
191
192function stamp(now: number): string {
193 const d = new Date(now)
194 const two = (n: number) => String(n).padStart(2, '0')
195 return `${d.getFullYear()}${two(d.getMonth() + 1)}${two(d.getDate())}-${two(d.getHours())}${two(d.getMinutes())}${two(d.getSeconds())}`
196}
197
198export type SetupResult = { text: string; didWrite: boolean }
199
200// /clawd desk: the whole switch-over in one go, with a backup first.
201export async function setupDesk(
202 host: DeskHost,
203 p: DeskPaths,
204 o: { installDir: string | null; isBridgeOn: boolean; nodePath: string; bridgeScript: string; now: number },
205): Promise<SetupResult> {
206 if (!o.isBridgeOn) return { text: '"Feed Clawd on Desk" is off in /config. Turn it on, then run /clawd desk again.', didWrite: false }
207 if (!o.installDir) {
208 return {
209 text: 'Couldn\'t find Clawd on Desk. If it\'s installed, set "Clawd on Desk app folder" in /config to its resources/app.asar.unpacked folder, then run /clawd desk again.',
210 didWrite: false,
211 }
212 }
213 const current = await readClaudeSettings(host, p)
214 if (!current) return { text: `Couldn't read ${settingsPath(p)} as JSON, so nothing was changed.`, didWrite: false }
215 const hooks = readDeskHooks(current)
216 // Read again: the person may have just turned it off.
217 const prefs = await readDeskPrefs(host, p)
218 if (prefs.isManagingHooks === true) return { text: MANAGED, didWrite: false }
219
220 let rendered: Rendered = { permission: null, autoStart: null }
221 let renderError = ''
222 const wantsAutoStart = !hooks.hasAutoStart && prefs.isAutoStartOn
223 if (!hooks.hasPermission || wantsAutoStart) {
224 const argv = [o.nodePath, o.bridgeScript, 'render-hooks', '--install-dir', o.installDir, ...(wantsAutoStart ? ['--auto-start'] : [])]
225 try {
226 const ran = await host.run(argv, 15_000)
227 if (ran.exitCode !== 0) throw new Error(ran.stderr.trim() || `render-hooks exited ${ran.exitCode}`)
228 rendered = JSON.parse(ran.stdout.trim().split('\n').at(-1) ?? '') as Rendered
229 } catch (error) {
230 renderError = error instanceof Error ? error.message : String(error)
231 }
232 }
233
234 const { settings, removed, added } = switchToBridge(current, rendered)
235 const missing = [
236 !hooks.hasPermission && !added.includes('the permission hook') ? 'the permission hook (permission bubbles)' : '',
237 wantsAutoStart && !added.includes('the auto-start hook') ? 'the auto-start hook' : '',
238 ].filter(Boolean)
239 const warn = missing.length ? `\nCouldn't add back ${missing.join(' or ')}${renderError ? `: ${renderError}` : ''}.` : ''
240
241 if (removed === 0 && added.length === 0) {
242 return { text: `Already set up: Clawd on Desk has no per-event hooks left, and clawd-bar feeds it.${warn}`, didWrite: false }
243 }
244 const backup = `${settingsPath(p)}.clawd-bar-${stamp(o.now)}.bak`
245 await host.writeFile(backup, JSON.stringify(current, null, 2) + '\n')
246 await host.writeFile(settingsPath(p), JSON.stringify(settings, null, 2) + '\n')
247
248 const did = [removed ? `removed its ${removed} per-event hook${removed === 1 ? '' : 's'}` : '', added.length ? `added back ${added.join(' and ')}` : '']
249 .filter(Boolean)
250 .join(', ')
251 return {
252 text: [
253 `Switched Clawd on Desk over to clawd-bar: ${did}.${warn}`,
254 `Backup: ${backup}`,
255 'Restart Claude Code to finish. After that, /clawd doctor shows the events being forwarded.',
256 'To undo, turn "Manage Claude hooks automatically" back on in Clawd on Desk; it reinstalls its own hooks.',
257 ].join('\n'),
258 didWrite: true,
259 }
260}
261hooks/ci/actions.ts 106 lines1import type { Run, RunState, StepState } from '../../types'
2import type { Host, RunReply } from './host'
3
4type GhStep = { name: string; status: string; conclusion: string; startedAt: string | null; completedAt: string | null }
5type GhJob = { name: string; status: string; conclusion: string; steps?: GhStep[] }
6export type GhRun = {
7 databaseId: number
8 number: number
9 status: string
10 conclusion: string
11 workflowName: string
12 headBranch: string
13 url: string
14 createdAt: string
15 startedAt?: string
16 updatedAt: string
17 jobs?: GhJob[]
18}
19
20const FIELDS = 'databaseId,number,status,conclusion,workflowName,headBranch,url,createdAt,startedAt,updatedAt,jobs'
21
22async function gh(h: Host, args: string[]) {
23 const r = await h.run(['gh', ...args])
24 if (r.exitCode !== 0) throw new Error(`gh ${args[0]} ${args[1]} failed: ${r.stderr.split('\n')[0] ?? ''}`)
25 return r.stdout
26}
27
28const ms = (s: string | null | undefined) => (s ? Date.parse(s) : undefined)
29
30function runState(r: GhRun): RunState {
31 if (r.status !== 'completed') return ['queued', 'waiting', 'pending', 'requested'].includes(r.status) ? 'queued' : 'running'
32 if (r.conclusion === 'success') return 'passed'
33 if (r.conclusion === 'cancelled' || r.conclusion === 'skipped') return 'canceled'
34 return 'failed'
35}
36
37function stepState(s: GhStep): StepState {
38 if (s.status === 'completed') return s.conclusion === 'success' ? 'passed' : s.conclusion === 'skipped' ? 'skipped' : 'failed'
39 return s.status === 'in_progress' ? 'running' : 'pending'
40}
41
42export function ghToRun(raw: GhRun, repo: string): Run {
43 const jobs = raw.jobs ?? []
44 const steps = jobs.flatMap(j => (j.steps ?? [])
45 .filter(s => !/^(Post |Complete job)/.test(s.name))
46 .map(s => ({
47 name: jobs.length > 1 ? `${j.name}: ${s.name}` : s.name,
48 state: stepState(s),
49 startedAt: ms(s.startedAt),
50 finishedAt: ms(s.completedAt),
51 })))
52 const state = runState(raw)
53 return {
54 provider: 'actions',
55 id: String(raw.databaseId),
56 repo,
57 workflow: raw.workflowName,
58 label: `Actions ${raw.workflowName}`,
59 branch: raw.headBranch,
60 url: raw.url,
61 state,
62 startedAt: ms(raw.startedAt) ?? ms(raw.createdAt),
63 finishedAt: state === 'running' || state === 'queued' ? undefined : ms(raw.updatedAt),
64 steps,
65 }
66}
67
68export async function fetchGhRun(h: Host, repo: string, id: string) {
69 return JSON.parse(await gh(h, ['run', 'view', id, '-R', repo, '--json', FIELDS])) as GhRun
70}
71
72export async function findGhRun(h: Host, repo: string, p: { workflow: string; ref?: string; since: number }) {
73 const args = ['run', 'list', '-R', repo, '--workflow', p.workflow, '--limit', '5', '--json', 'databaseId,createdAt']
74 if (p.ref) args.push('--branch', p.ref)
75 const runs = JSON.parse(await gh(h, args)) as { databaseId: number; createdAt: string }[]
76 // `gh workflow run` returns before the run exists; allow a little clock skew.
77 const fresh = runs.filter(r => Date.parse(r.createdAt) >= p.since - 10_000)
78 return fresh.length ? String(fresh[0]!.databaseId) : undefined
79}
80
81export async function latestGhRunId(h: Host, repo: string) {
82 const runs = JSON.parse(await gh(h, ['run', 'list', '-R', repo, '--limit', '1', '--json', 'databaseId'])) as { databaseId: number }[]
83 return runs[0] ? String(runs[0].databaseId) : undefined
84}
85
86export async function ghFailureLog(h: Host, repo: string, id: string) {
87 try {
88 const out = await gh(h, ['run', 'view', id, '-R', repo, '--log-failed'])
89 const lines = out.split('\n').filter(l => l.trim())
90 return `Last ${Math.min(60, lines.length)} log lines:\n${lines.slice(-60).join('\n')}`
91 } catch (err) {
92 return `Could not read the failed log (${(err as Error).message}).`
93 }
94}
95
96// One doctor line from `gh auth status`; `null` when gh couldn't be started at all.
97export function ghAuthLine(ran: RunReply | null): string {
98 if (!ran) return '✗ gh not found: install the GitHub CLI to race Actions builds'
99 const account = /Logged in to \S+ (?:account|as) (\S+)/.exec(ran.stdout + ran.stderr)?.[1]
100 if (ran.exitCode === 0) return `✓ gh logged in${account ? ` as ${account}` : ''}`
101 if (/not logged in/i.test(ran.stderr + ran.stdout)) return '✗ gh not logged in: run gh auth login'
102 const lines = (ran.stderr || ran.stdout).split('\n').map(l => l.trim()).filter(Boolean)
103 const why = lines.find(l => /fail|invalid|expired/i.test(l)) ?? lines[0]
104 return `✗ gh auth status failed${why ? `: ${why}` : ''}`
105}
106hooks/ci/codemagic.ts 111 lines1import type { Run, RunState, StepState } from '../../types'
2import type { Host } from './host'
3import { normalizeRemote } from './repo'
4
5const API = 'https://api.codemagic.io'
6
7export type CmAction = { name: string; status: string | null; startedAt: string | null; finishedAt: string | null; logUrl?: string }
8export type CmBuild = {
9 _id: string
10 appId: string
11 index: number
12 status: string
13 branch: string
14 startedAt: string | null
15 finishedAt: string | null
16 fileWorkflowId?: string | null
17 workflowId?: string | null
18 config?: { name?: string }
19 message?: string
20 buildActions?: CmAction[]
21}
22
23export async function cmToken(h: Host) {
24 const env = await h.env('CODEMAGIC_API_TOKEN')
25 if (env) return env.trim()
26 const home = (await h.env('USERPROFILE')) ?? (await h.env('HOME'))
27 if (!home) return undefined
28 try {
29 return (await h.readFile(`${home}/.codemagic-token`)).trim() || undefined
30 } catch {
31 return undefined
32 }
33}
34
35async function get<T>(h: Host, token: string, path: string): Promise<T> {
36 const res = await h.fetch(`${API}${path}`, { headers: { 'x-auth-token': token } })
37 // The message names the status only: the token must never reach a toast or a log.
38 if (!res.ok) throw new Error(`Codemagic answered ${res.status} for ${path.split('?')[0]}`)
39 return JSON.parse(res.text) as T
40}
41
42type CmApp = { _id: string; appName: string; repository?: { htmlUrl?: string } }
43
44export async function findApp(h: Host, token: string, remote: string) {
45 const { applications = [] } = await get<{ applications?: CmApp[] }>(h, token, '/apps')
46 const want = normalizeRemote(remote)
47 const app = applications.find(a => a.repository?.htmlUrl && normalizeRemote(a.repository.htmlUrl) === want)
48 return app ? { id: app._id, name: app.appName } : undefined
49}
50
51export async function fetchBuild(h: Host, token: string, buildId: string) {
52 return (await get<{ build: CmBuild }>(h, token, `/builds/${buildId}`)).build
53}
54
55export async function latestBuildId(h: Host, token: string, appId: string) {
56 const { builds = [] } = await get<{ builds?: CmBuild[] }>(h, token, `/builds?appId=${appId}`)
57 return builds[0]?._id
58}
59
60const ms = (s: string | null | undefined) => (s ? Date.parse(s) : undefined)
61
62function runState(status: string): RunState {
63 if (status === 'finished') return 'passed'
64 if (status === 'failed' || status === 'timeout') return 'failed'
65 if (status === 'canceled' || status === 'skipped') return 'canceled'
66 if (status === 'queued') return 'queued'
67 return 'running'
68}
69
70function stepState(a: CmAction): StepState {
71 if (a.status === 'success') return 'passed'
72 if (a.status === 'failed') return 'failed'
73 if (a.status === 'skipped' || a.status === 'canceled') return 'skipped'
74 if (a.startedAt && !a.finishedAt) return 'running'
75 return a.status ? 'running' : 'pending'
76}
77
78export function cmToRun(b: CmBuild, repo: string): Run {
79 return {
80 provider: 'codemagic',
81 id: b._id,
82 repo,
83 workflow: b.fileWorkflowId ?? b.workflowId ?? b.config?.name ?? 'default',
84 label: `Codemagic #${b.index}`,
85 branch: b.branch,
86 url: `https://codemagic.io/app/${b.appId}/build/${b._id}`,
87 state: runState(b.status),
88 startedAt: ms(b.startedAt),
89 finishedAt: ms(b.finishedAt),
90 steps: (b.buildActions ?? []).map(a => ({ name: a.name, state: stepState(a), startedAt: ms(a.startedAt), finishedAt: ms(a.finishedAt) })),
91 }
92}
93
94export async function cmFailureLog(h: Host, token: string, b: CmBuild) {
95 const url = `https://codemagic.io/app/${b.appId}/build/${b._id}`
96 const failed = b.buildActions?.find(a => a.status === 'failed')
97 const head = `${failed ? `Failed step: ${failed.name}\n` : ''}${b.message?.trim() ?? ''}\nBuild: ${url}`
98 // Codemagic only gives built-in steps a logUrl; script steps fall back to the message.
99 if (!failed?.logUrl) return head
100 try {
101 // The token only ever goes to Codemagic's own API host.
102 const isOwnHost = failed.logUrl.startsWith(`${API}/`)
103 const res = await h.fetch(failed.logUrl, isOwnHost ? { headers: { 'x-auth-token': token } } : undefined)
104 if (!res.ok) return head
105 const lines = res.text.replace(/<\/?span[^>]*>/g, '').split('\n').filter(l => l.trim())
106 return `${head}\n\nLast ${Math.min(60, lines.length)} log lines:\n${lines.slice(-60).join('\n')}`
107 } catch {
108 return head
109 }
110}
111hooks/ci/detect.ts 37 lines1export type Detected =
2 | { kind: 'codemagic-start' }
3 | { kind: 'actions-start'; workflow: string; ref?: string; repo?: string }
4 | { kind: 'foreground-watch'; provider: 'codemagic' | 'actions' }
5 | { kind: 'none' }
6
7const unquote = (s: string | undefined) => s?.replace(/^["']|["']$/g, '')
8// A shell variable (`--repo $R`) can't be read from here; callers fall back to this repo.
9const repoFlag = (command: string) => {
10 const repo = unquote(command.match(/(?:\s-R|--repo)[ =](\S+)/)?.[1])
11 return repo?.includes('$') ? undefined : repo
12}
13
14export function classify(command: string): Detected {
15 // A start wins over a watch in the same command: blocking it would also block the start.
16 if (/codemagic\.py["']?\s+start\b/.test(command)) return { kind: 'codemagic-start' }
17 const wf = command.match(/\bgh\s+workflow\s+run\s+(\S+)/)
18 if (wf) {
19 return {
20 kind: 'actions-start',
21 workflow: unquote(wf[1])!,
22 ref: unquote(command.match(/--ref[ =](\S+)/)?.[1]),
23 repo: repoFlag(command),
24 }
25 }
26 if (/\bgh\s+run\s+watch\b/.test(command)) return { kind: 'foreground-watch', provider: 'actions' }
27 if (/codemagic\.py["']?\s+watch\b/.test(command)) return { kind: 'foreground-watch', provider: 'codemagic' }
28 return { kind: 'none' }
29}
30
31export function watchedRun(command: string) {
32 const id = command.match(/\bgh\s+run\s+watch\s+(\d+)/)?.[1]
33 return id ? { id, repo: repoFlag(command) } : undefined
34}
35
36export const parseCodemagicStarted = (stdout: string) => stdout.match(/started\s+(\S+)\s+on\s+/)?.[1]
37hooks/ci/host.ts 24 lines1import type { View } from '../../types'
2
3export type HttpReply = { status: number; ok: boolean; text: string }
4export type RunReply = { exitCode: number; stdout: string; stderr: string }
5export type HttpOptions = { method?: string; headers?: Record<string, string>; body?: string }
6
7// Everything the watcher and the API clients need from the engine. register.tsx builds it
8// from `$` (the engine only lets `$` into functions in the same file), and tests hand in
9// an in-memory fake.
10export type Host = {
11 now: () => Promise<number>
12 // This session: its id, and the owner/name of the repo it runs in.
13 self: () => Promise<{ id: string; repo?: string }>
14 env: (name: string) => Promise<string | undefined>
15 readFile: (path: string) => Promise<string>
16 fetch: (url: string, init?: HttpOptions) => Promise<HttpReply>
17 run: (argv: string[], timeoutMs?: number) => Promise<RunReply>
18 load: (key: string) => Promise<unknown>
19 save: (key: string, value: unknown) => Promise<void>
20 toast: (text: string) => void
21 submit: (text: string) => Promise<void>
22 publish: (view: View) => Promise<void>
23}
24hooks/ci/repo.ts 20 lines1import type { Host } from './host'
2
3export function normalizeRemote(url: string) {
4 return url.trim()
5 .replace(/^git@([^:]+):/, 'https://$1/')
6 .replace(/\/+$/, '')
7 .replace(/\.git$/, '')
8 .toLowerCase()
9}
10
11export function repoSlug(url: string) {
12 const m = url.trim().replace(/\/+$/, '').replace(/\.git$/, '').match(/[:/]([^/:]+)\/([^/]+)$/)
13 return m ? `${m[1]}/${m[2]}` : url
14}
15
16export async function originRemote(h: Host) {
17 const r = await h.run(['git', 'remote', 'get-url', 'origin'])
18 return r.exitCode === 0 ? r.stdout.trim() : undefined
19}
20hooks/ci/watcher.ts 213 lines1import type { LastResult, Notice, PendingLookup, Provider, Run, SplitRecord, View, Watch } from '../../types'
2import { fetchGhRun, findGhRun, ghFailureLog, ghToRun } from './actions'
3import { cmFailureLog, cmToken, cmToRun, fetchBuild } from './codemagic'
4import type { Host } from './host'
5import { alertText, continuePrompt } from './messages'
6import { desktopToast, ntfy } from './notify'
7import { isNewPB, recordKey, toRecord } from './splits'
8
9const MAX_ERRORS = 5
10const LOOKUP_WINDOW = 2 * 60_000
11// The mod runs in every open session and they share one store, so each watch has an
12// owner that renews its lease on every poll. Another session adopts it only once the
13// owner has been quiet this long (closed, or restarted with a new id).
14const LEASE = 90_000
15
16// One store write at a time within this process. Other processes are handled by the
17// lease and by re-reading the list just before saving it.
18let chain: Promise<unknown> = Promise.resolve()
19function serial<T>(fn: () => Promise<T>): Promise<T> {
20 const next = chain.then(fn, fn)
21 chain = next.catch(() => undefined)
22 return next
23}
24
25const isDone = (run: Run) => run.state === 'passed' || run.state === 'failed' || run.state === 'canceled'
26const sameRepo = (a: string | undefined, b: string) => a !== undefined && a.toLowerCase() === b.toLowerCase()
27const keyOf = (w: Watch) => `${w.provider}:${w.id || `pending:${w.pending?.workflow}:${w.pending?.since}`}`
28const loadWatches = async (h: Host) => ((await h.load('watches')) as Watch[] | undefined) ?? []
29
30export async function loadView(h: Host): Promise<View> {
31 return {
32 watches: await loadWatches(h),
33 records: ((await h.load('records')) as Record<string, SplitRecord> | undefined) ?? {},
34 last: ((await h.load('last')) as LastResult | undefined) ?? null,
35 notice: ((await h.load('notice')) as Notice | undefined) ?? null,
36 }
37}
38
39async function publish(h: Host) {
40 await h.publish(await loadView(h))
41}
42
43export function addWatch(h: Host, w: { provider: Provider; id: string; repo: string; pending?: PendingLookup; isPromised?: boolean }) {
44 return serial(async () => {
45 const watches = await loadWatches(h)
46 const same = w.id ? watches.find(x => x.provider === w.provider && x.id === w.id) : undefined
47 if (same) {
48 if (w.isPromised && !same.isPromised) {
49 same.isPromised = true
50 await h.save('watches', watches)
51 }
52 return
53 }
54 const now = await h.now()
55 watches.push({ ...w, addedAt: now, errors: 0, owner: (await h.self()).id, beat: now })
56 await h.save('watches', watches)
57 await publish(h)
58 })
59}
60
61// Marks the matching watches promised; false when there are none.
62export function promiseWatches(h: Host, match: (w: Watch) => boolean) {
63 return serial(async () => {
64 const watches = await loadWatches(h)
65 const hits = watches.filter(match)
66 for (const w of hits) w.isPromised = true
67 if (hits.length) await h.save('watches', watches)
68 return hits.length > 0
69 })
70}
71
72export function stopAll(h: Host) {
73 return serial(async () => {
74 const n = (await loadWatches(h)).length
75 await h.save('watches', [])
76 await publish(h)
77 return n
78 })
79}
80
81export function showReplay(h: Host, run: Run) {
82 return serial(async () => {
83 const last: LastResult = { run, isNewPB: false, at: await h.now(), isReplay: true }
84 await h.save('last', last)
85 await publish(h)
86 })
87}
88
89async function refresh(h: Host, w: Watch, now: number): Promise<{ run?: Run; log?: () => Promise<string> }> {
90 if (w.provider === 'codemagic') {
91 const token = await cmToken(h)
92 if (!token) throw new Error('no Codemagic token (set CODEMAGIC_API_TOKEN or ~/.codemagic-token)')
93 const raw = await fetchBuild(h, token, w.id)
94 return { run: cmToRun(raw, w.repo), log: () => cmFailureLog(h, token, raw) }
95 }
96 if (!w.id && w.pending) {
97 const id = await findGhRun(h, w.repo, w.pending)
98 if (!id) {
99 if (now - w.pending.since > LOOKUP_WINDOW) throw new Error('the run never appeared')
100 return {}
101 }
102 w.id = id
103 }
104 const run = ghToRun(await fetchGhRun(h, w.repo, w.id), w.repo)
105 return { run, log: () => ghFailureLog(h, w.repo, w.id) }
106}
107
108// Returns the prompt to queue, if any; the caller submits it after the store chain is
109// released, because a submit only resolves once the current turn ends.
110async function finish(h: Host, run: Run, log: (() => Promise<string>) | undefined, opts: PollOpts, canPrompt: boolean, isPromised: boolean) {
111 const records = ((await h.load('records')) as Record<string, SplitRecord> | undefined) ?? {}
112 let last: LastResult = { run, isNewPB: false, at: await h.now(), isReplay: false }
113
114 if (run.state === 'passed' || run.state === 'failed') {
115 const key = recordKey(run)
116 const pb = isNewPB(records[key], run)
117 if (pb) {
118 records[key] = toRecord(run)
119 await h.save('records', records)
120 }
121 last = { ...last, isNewPB: pb }
122 }
123 await h.save('last', last)
124
125 const { title, body } = alertText(run)
126 h.toast(`${title} · ${body}`)
127 await desktopToast(h, title, body).catch(() => undefined)
128 if (opts.ntfyTopic) await ntfy(h, opts.ntfyTopic, title, body, run.url).catch(() => undefined)
129
130 if (opts.canContinue === false) return undefined
131 // Claude is usually watching its own build already; a pass only needs saying if it was told not to.
132 if (run.state === 'passed' && !isPromised) return undefined
133 const text = continuePrompt(run, run.state === 'failed' && log ? await log() : undefined)
134 if (!text) return undefined
135 if (canPrompt) return text
136 h.toast(`Open Claude Code in ${run.repo} to let Claude continue.`)
137 return undefined
138}
139
140// canContinue off keeps the race and the alerts but never hands Claude a turn.
141export type PollOpts = { ntfyTopic: string; canContinue?: boolean }
142
143export async function pollOnce(h: Host, opts: PollOpts) {
144 const prompts = await serial(async () => {
145 const me = await h.self()
146 const now = await h.now()
147 const watches = await loadWatches(h)
148 const handled = new Map<string, Watch | null>()
149 // A run's first watch, or null when another session holds it.
150 const seen = new Map<string, Watch | null>()
151 const done: { watch: Watch; run: Run; log?: () => Promise<string> }[] = []
152
153 for (const w of watches) {
154 const key = keyOf(w)
155 if (w.owner && w.owner !== me.id && now - (w.beat ?? 0) < LEASE) {
156 if (w.id) seen.set(`${w.provider}:${w.id}`, null)
157 continue
158 }
159 w.owner = me.id
160 w.beat = now
161 try {
162 const { run, log } = await refresh(h, w, now)
163 w.errors = 0
164 // Two dispatches, or /ci plus a dispatch, can land on the same run: keep the first.
165 const runKey = `${w.provider}:${w.id}`
166 if (w.id && seen.has(runKey)) {
167 const kept = seen.get(runKey)
168 if (kept && w.isPromised) kept.isPromised = true
169 handled.set(key, null)
170 continue
171 }
172 if (w.id) seen.set(runKey, w)
173 if (run) w.run = run
174 if (run && isDone(run)) {
175 done.push({ watch: w, run, log })
176 handled.set(key, null)
177 } else {
178 handled.set(key, w)
179 }
180 } catch (err) {
181 w.errors++
182 if (w.errors >= MAX_ERRORS) {
183 const text = `stopped watching ${w.run?.label ?? w.repo}: ${(err as Error).message}`
184 h.toast(`clawd-bar ${text}`)
185 await h.save('notice', { text, at: now })
186 handled.set(key, null)
187 } else {
188 handled.set(key, w)
189 }
190 }
191 }
192
193 // Re-read before saving: another session may have added or stopped watches meanwhile.
194 // Their entries pass through untouched; a watch that vanished was stopped, so it stays gone.
195 const next: Watch[] = []
196 for (const f of await loadWatches(h)) {
197 const mine = handled.get(keyOf(f))
198 if (mine === undefined) next.push(f)
199 else if (mine) next.push(mine)
200 }
201 await h.save('watches', next)
202
203 const texts: string[] = []
204 for (const d of done) {
205 const text = await finish(h, d.run, d.log, opts, sameRepo(me.repo, d.run.repo), d.watch.isPromised === true)
206 if (text) texts.push(text)
207 }
208 await publish(h)
209 return texts
210 })
211 for (const text of prompts) void h.submit(text).catch(() => undefined)
212}
213