SLOPSHOPPER

clawd-bar

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…

newbandguardcommandtoastprocess
v0.7.0MITupdated 2026-10-07KaiC5504/clawd-bar
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · clawd-bar
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /clawd ⎿ clawd-bar: Clawd tucked away. /clawd brings him back. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

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

Install

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

  • A recent Claude Code. clawd-bar is built and tested on 2.1.289; if /clawd doesn't show up after installing, run claude update first.
  • For CI races: the GitHub CLI, logged in with gh auth login.
  • Tested on Windows 11. Desktop notifications use PowerShell on Windows, 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

What you see

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:

  1. What he's doing right now (the file being edited, the command running, the search) and how long the turn has taken. Amber when he needs you, red when something broke.
  2. The task list's progress, or this turn's tools and changed files. After a turn: Done in 2m 14s · 23 tools · 5 files.
  3. The repo and branch.

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:

  • An edit: a little editor headed by the file type (.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.
  • A new file: the same editor filling up, headed by its line count (+120), then a tick.
  • A command: a terminal headed by the program ($ git), the seconds counting up in the corner, and a ✓ or ✗ when it ends.
  • A test run: a light sweeps ten dots while it runs; when it ends they settle to the real pass/fail share and the counts come up (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 ✗.
  • An install (npm, pnpm, uv, pip, …): parcels piling up under the package manager's name.
  • An MCP tool: he plugs a cable into a socket marked with the server's initial in its own colour, so a Gmail call looks unlike a Linear one.

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

CI races

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:

  • Claude runs gh workflow run to start a GitHub Actions workflow
  • you type /ci, which picks up the latest run for this repo, live or just finished
  • Claude runs gh 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:

  • a toast appears in Claude Code, and a desktop notification pops up
  • your phone gets a push, if you've set an ntfy topic (see Settings)
  • After a failure, Claude gets a new turn. clawd-bar sends it a prompt carrying the failure log and asking Claude to diagnose and fix it. A pass stays quiet, since Claude is usually watching its own build already. The one exception is a 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.

Commands

CommandWhat it does
/clawdHide or show him. Remembered across sessions.
/clawd demoPlay every state on a loop, 6 s each. Run it again to stop.
/clawd doctorCheck the setup: what he's showing, the gh login, CI and bridge status.
/clawd deskSwitch Clawd on Desk over to clawd-bar.
/ciWatch the latest CI run for this repo.
/ci stopStop watching.

Settings

Open /config and find clawd-bar.

SettingDefaultWhat it does
Show Clawd above the promptonTurn the whole status area on or off.
Watch CI buildsonRace builds and send alerts when they finish.
Let Claude continue after buildsonSend Claude a new prompt when a build fails, or passes after clawd-bar took over its gh run watch.
ntfy topicemptyAn 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 DeskonFeed the Clawd on Desk app once /clawd desk has switched it over. Does nothing without the app.
Clawd on Desk app folderemptyFound by itself. Set it only if /clawd doctor says the app wasn't found.
Node.js executablenodeOnly used by the bridge.

Troubleshooting

  • Clawd isn't there. Run /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.
  • No race when Claude starts a build. /clawd doctor shows whether gh is logged in and what the CI watcher sees.
  • Clawd on Desk doesn't react. /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.
  • Clawd stutters in the desktop app's Code tab. Known: the desktop restarts his animation whenever the band redraws. The terminal is the smooth one for now.

Feed Clawd on Desk

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.

  1. In Clawd on Desk, open Settings → Agents and turn off Manage Claude hooks automatically. When it asks, choose Disable automatic management only. Otherwise the app puts its hooks straight back.
  2. Run /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).
  3. Restart Claude Code. /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.

Develop

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 canvas
  • hooks/pixels.ts: packs that canvas into terminal cells, four pixels to a cell in quadrant blocks
  • hooks/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 showpieces
  • hooks/arcade.ts: the cabinet's other games: snake, breakout and the runner
  • hooks/pet-state.ts: turns session events into what he acts out
  • hooks/band.tsx and hooks/bars.ts: lay out the gauges and lines, and move the bars
  • hooks/svg.ts: Clawd as an animated SVG, for the desktop app and this README
  • hooks/ci/: watches the builds

He 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).

Credits

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.

Disclaimer

Unofficial fan project. Clawd is Anthropic's character. This plugin is free, non-commercial, and not affiliated with Anthropic or with Clawd on Desk.

License

MIT for the code. The Clawd character belongs to Anthropic and isn't covered by it; this is an unofficial, non-commercial fan project.

Source 26 files
hooks/register.tsx 868 lines
1import { 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}
868
hooks/activity.ts 161 lines
1import 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}
161
hooks/band.tsx 283 lines
1import 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)
283
hooks/bars.ts 105 lines
1import { 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}
105
hooks/demo.ts 151 lines
1import 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}
151
hooks/desk.ts 261 lines
1// 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}
261
hooks/ci/actions.ts 106 lines
1import 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}
106
hooks/ci/codemagic.ts 111 lines
1import 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}
111
hooks/ci/detect.ts 37 lines
1export 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]
37
hooks/ci/host.ts 24 lines
1import 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}
24
hooks/ci/repo.ts 20 lines
1import 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}
20
hooks/ci/watcher.ts 213 lines
1import 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