Every open Claude Code session on one live board: a progress bar per session, a rough time left, click a row to switch to that session, x to hide it (/pulse)

Every open Claude Code session on one live board, above the prompt:
● Edit the opening animation ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 72% ~30m ×
✓ Fix the login bug ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 100% 3m ago ×
● Write the newsletter ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 45% ~12m ×
● Review the payments PR ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 60% needs you ×
~30m, <1m).claude://code/continue?session=local_…). A terminal session can't be focused from here, so its cd … && claude --resume … command is copied./pulse opens the whole board in a pane: idle, hidden and silent sessions too, each with what it is doing (Editing register.tsx, plan 3/5, running 4m).| Command | Does |
|---|---|
/pulse | Open or close the board pane |
/pulse compact · full · off | One summary line, a row per session, or no band |
/pulse alerts on · off | Toasts when another session finishes or needs you |
/pulse show | Bring hidden rows back |
/pulse name <title> | Rename this session on the board (no title: back to automatic) |
/pulse demo · demo off | Five made-up sessions for three minutes, to see every state |
If another plugin owns /pulse, the command is /session-pulse.
It's an estimate, marked with ~. It never moves backwards during a turn.
~/.claude/mods-data/session-pulse/s-<session>.json: each session's heartbeat (state, title, timing, plan), rewritten at least every 15 s. A session silent for 75 s shows as not responding; files older than 12 h are removed.~/.claude/mods-data/session-pulse/hide-<session>.json: rows you hid.~/.claude/mods-data/recording.json), rows show as Session 1, Session 2 with no activity or folder.No network calls, no model requests, nothing added to Claude's prompt.
hooks/register.tsx 1416 lines1// Session Pulse: every open Claude Code session on one live board.
2// - Each session writes a heartbeat, ~/.claude/mods-data/session-pulse/s-<id>.json:
3// its state (working, waiting on you, done, stopped, error, idle), when its turn
4// started, how long that turn should take, its task plan when it keeps one
5// (TodoWrite, the Task tools, savvy-progress, goal-meter) and what it is doing.
6// - The band above the prompt draws a row per live session: a colored dot, the
7// title, a bar, the percent and a rough time left. Click a row to switch to that
8// session (the desktop app opens it; for a terminal session its resume command
9// is copied). x hides a row until that session starts its next turn.
10// - /pulse opens the whole board in a pane: idle, hidden and silent sessions too,
11// with what each one is doing. /pulse compact|full|off, /pulse alerts on|off,
12// /pulse show (bring hidden rows back), /pulse name <title>.
13// - The percent comes from the plan when there is one, else from how long turns
14// usually take, learned per session and across sessions. The bar warms from
15// orange to yellow-green as it fills; blue means the session needs you.
16// - It sends no model requests and adds nothing to Claude's prompt.
17
18import { atom, read, update } from 'claude-code'
19import type { CommandRunResult, Elements, EngineInterface, Register, RenderElement, RenderSurface, ToolCallResult } from 'claude-code'
20
21import type { Beat, Plan, Prefs, PulseState, Row } from '../types'
22import {
23 COLOR,
24 DEFAULT_TURN_MS,
25 asDigitFont,
26 HOST_ID,
27 HOUR,
28 MIN,
29 SEC,
30 SVG_HEIGHT,
31 activityOf,
32 askNeed,
33 agoText,
34 asBeat,
35 barCells,
36 barSvg,
37 basename,
38 cells,
39 clamp,
40 clip,
41 colorOf,
42 demoTiming,
43 durText,
44 estimate,
45 etaText,
46 expectedMs,
47 fit,
48 goalPlan,
49 iconOf,
50 permissionNeed,
51 savvyPlan,
52 tasksPlan,
53 titleFromPrompt,
54 todosPlan,
55 withChange,
56 workingColor,
57} from './estimate'
58import type { DigitFont, TaskEntry } from './estimate'
59
60const rowsAtom = atom({ plugin: 'session-pulse', key: 'rows' } as const, [])
61const nowAtom = atom({ plugin: 'session-pulse', key: 'now' } as const, 0)
62const prefsAtom = atom({ plugin: 'session-pulse', key: 'prefs' } as const, { mode: 'full', alerts: true })
63const maskedAtom = atom({ plugin: 'session-pulse', key: 'isMasked' } as const, false)
64
65const PANE = 'session-pulse'
66const DATA = '/.claude/mods-data/session-pulse'
67const DESKTOP_SESSIONS = '/Library/Application Support/Claude/claude-code-sessions'
68const SAVVY_PROGRESS = 'mcp__savvy-progress__progress'
69const SAVVY_STEP = 'mcp__savvy-progress__step'
70const GOAL_TOOL = 'mcp__goal-meter__tasks'
71const BEAT_MS = 15 * SEC // a live session rewrites its file at least this often
72const STALE_MS = 75 * SEC // a file older than this belongs to a session that stopped answering
73const FORGET_MS = 12 * HOUR // files older than this are removed
74const RECENT_MS = 60 * MIN // a finished session stays on the band this long
75const MISSED_TURN_GRACE_MS = 15 * SEC // the band may draw before the engine marks a new turn running
76const BAND_ROWS = 6
77// The status dot's column: a row's title and its detail line both start after it.
78const ICON_CELLS = 2
79const BEAT_FILE = /^s-([A-Za-z0-9-]{8,64})\.json$/
80const HIDE_FILE = /^hide-([A-Za-z0-9-]{8,64})\.json$/
81
82type Kit = Pick<Elements['terminal'], 'Box' | 'Text' | 'Button'>
83type SvgElement = Elements['desktop']['Svg']
84type Item = { row: Row; label: string }
85type View = { pct: number; pctText: string; color: string; tail: string; tailColor: string; tailHex: string; isLive: boolean; state: string; stateColor: string }
86type CachedFile = { mtimeMs: number; beat: Beat | null; hiddenAt: number }
87
88// This session
89let home = ''
90let dir = ''
91let hostId = ''
92let me: Beat | null = null
93let isTracked = false
94let isTicking = false
95let isBusy = false
96let isDirty = false
97let lastWrite = 0
98let titleFrom: 'name' | 'desktop' | 'prompt' | 'folder' = 'folder'
99let desktopRecord = ''
100let myTurns: number[] = []
101let pace: number[] = []
102let waitKind: '' | 'ask' | 'permission' = ''
103let commandName = 'pulse'
104const tasks = new Map<string, TaskEntry>()
105// The app's own digit outlines (scripts/digits-from-app-font.py), when extracted.
106let digitFont: DigitFont | null = null
107
108// What this session sees of the others
109const files = new Map<string, CachedFile>()
110const hidden = new Map<string, number>()
111const lastState = new Map<string, PulseState>()
112const highest = new Map<string, number>()
113// What each drawing site last showed per session, so the next drawing animates
114// from there: the bar grows, the changed digits roll.
115const lastShown = new Map<string, { pct: number; pctText: string }>()
116let hasScanned = false
117let ticks = 0
118let lastPrune = 0
119
120// /pulse demo: made-up sessions for a few minutes, never written to disk
121const DEMO_PREFIX = 'demo0000-'
122let demo: Beat[] = []
123let demoUntil = 0
124const demoHidden = new Set<string>()
125const isDemo = (id: string): boolean => id.startsWith(DEMO_PREFIX)
126
127const beatPath = (id: string): string => `${dir}/s-${id}.json`
128const hidePath = (id: string): string => `${dir}/hide-${id}.json`
129
130function freshBeat(id: string, surface: string, cwd: string, at: number): Beat {
131 return {
132 v: 1,
133 id,
134 hostId,
135 surface,
136 title: '',
137 project: basename(cwd),
138 cwd,
139 state: 'idle',
140 bornAt: at,
141 startedAt: 0,
142 endedAt: 0,
143 waitMs: 0,
144 waitingSince: 0,
145 tools: 0,
146 activity: '',
147 plan: null,
148 planAt: 0,
149 expectMs: DEFAULT_TURN_MS,
150 turns: 0,
151 lastMs: 0,
152 stopPct: 0,
153 agents: 0,
154 updatedAt: at,
155 }
156}
157
158function demoBeats(at: number): Beat[] {
159 const make = (i: number, title: string, project: string, over: Partial<Beat>): Beat => ({
160 ...freshBeat(`${DEMO_PREFIX}0000-0000-0000-00000000000${i}`, 'desktop', `/demo/${project}`, at - (10 - i) * MIN),
161 hostId: '',
162 title,
163 turns: 1,
164 ...over,
165 })
166 const working = (pct: number, etaMinutes: number, activity: string): Partial<Beat> => {
167 const timing = demoTiming(pct, etaMinutes * MIN)
168 return { state: 'working', startedAt: at - timing.workedMs, expectMs: timing.expectMs, activity }
169 }
170 return [
171 make(1, 'Edit the opening animation', 'promo-video', working(72, 30, 'Rendering frames')),
172 make(2, 'Fix the login bug', 'webapp', { state: 'done', startedAt: at - 9 * MIN, endedAt: at - 3 * MIN, lastMs: 6 * MIN, turns: 3 }),
173 make(3, 'Write the newsletter', 'newsletter', working(45, 12, 'Editing issue-42.md')),
174 make(4, 'Organize research notes', 'research', working(88, 5, 'Reading sources.md')),
175 make(5, 'Review the payments PR', 'payments', { state: 'waiting', startedAt: at - 4 * MIN, waitingSince: at - MIN, expectMs: 6 * MIN, activity: 'Waiting for your permission', need: 'allow bash', turns: 2 }),
176 ]
177}
178
179async function loadDigitFont($: EngineInterface): Promise<DigitFont | null> {
180 try {
181 const path = `${dir}/digits.json`
182 if (!(await $.fs.exists(path))) return null
183 return asDigitFont(JSON.parse(await $.fs.read(path)))
184 } catch {
185 return null
186 }
187}
188
189async function saveDemo($: EngineInterface, startedAt: number): Promise<void> {
190 try {
191 await $.store.set('demo', startedAt)
192 } catch {
193 // the demo ends with this load
194 }
195}
196
197// A demo started before a hot reload carries on where it was.
198async function restoreDemo($: EngineInterface, at: number): Promise<void> {
199 try {
200 const startedAt = await $.store.get('demo')
201 if (typeof startedAt === 'number' && startedAt > 0 && at < startedAt + 3 * MIN) {
202 demo = demoBeats(startedAt)
203 demoUntil = startedAt + 3 * MIN
204 }
205 } catch {
206 // no demo to restore
207 }
208}
209
210function beatOf(row: Row): Beat {
211 const { isSelf, isHidden, isStale, ...beat } = row
212 return beat
213}
214
215const isHiddenBeat = (b: Beat, hiddenAt: number): boolean => hiddenAt > 0 && b.startedAt <= hiddenAt
216
217const byBirth = (a: Row, b: Row): number => a.bornAt - b.bornAt || (a.id < b.id ? -1 : 1)
218
219function selfRow(): Row | null {
220 return me === null ? null : { ...me, isSelf: true, isHidden: isHiddenBeat(me, hidden.get(me.id) ?? 0), isStale: false }
221}
222
223function labelOf(row: Row, index: number, isMasked: boolean): string {
224 if (isMasked) return `session ${index + 1}`
225 // The session's name exactly as Claude Code shows it.
226 if (row.title) return row.title
227 return row.project ? `untitled · ${row.project}` : 'untitled session'
228}
229
230function isOnBand(r: Row, at: number): boolean {
231 if (r.isHidden || r.isStale || r.ended === true || r.state === 'idle') return false
232 if (r.state === 'done' || r.state === 'stopped' || r.state === 'error') return at - r.endedAt < RECENT_MS
233 return true
234}
235
236const isLiveState = (s: PulseState): boolean => s === 'working' || s === 'waiting'
237
238// ---------------------------------------------------------------------------
239// This session's own heartbeat
240
241async function startTracking($: EngineInterface, cwd: string, surface: string): Promise<void> {
242 if (isTracked || !dir) return
243 isTracked = true
244 const at = await $.clock.now()
245 const id = await $.session.id()
246 // A hot reload keeps $.state, so the row drawn before it carries on as it was.
247 const kept = (await read($, rowsAtom)).find(r => r.isSelf && r.id === id)
248 if (kept !== undefined) {
249 me = beatOf(kept)
250 } else {
251 // A resumed session: keep its title, count and age; its last turn is over.
252 const saved = await readBeat($, beatPath(id))
253 me = freshBeat(id, surface, cwd, at)
254 if (saved !== null && saved.ended !== true) {
255 me = { ...me, title: saved.title, turns: saved.turns, lastMs: saved.lastMs, bornAt: saved.bornAt || at, endedAt: saved.endedAt, state: saved.turns > 0 ? 'done' : 'idle' }
256 }
257 }
258 me = { ...me, hostId, surface: surface || me.surface, cwd, project: basename(cwd) }
259 if (me.title) titleFrom = 'prompt'
260 if (me.lastMs > 0) myTurns = [me.lastMs]
261 pace = await loadPace($)
262 digitFont = await loadDigitFont($)
263 await restoreDemo($, at)
264 await refreshTitle($)
265 await writeSelf($)
266 await scan($, at)
267 startTicking($)
268}
269
270function startTicking($: EngineInterface): void {
271 if (isTicking) return
272 isTicking = true
273 $.clock.every(SEC, () => {
274 void tick($)
275 })
276}
277
278async function tick($: EngineInterface): Promise<void> {
279 if (me === null || isBusy) return
280 isBusy = true
281 try {
282 ticks += 1
283 const at = await $.clock.now()
284 if (ticks % 5 === 0 || me.ended === true) await followClear($)
285 if (isDirty || at - lastWrite >= BEAT_MS) await writeSelf($)
286 if (ticks % 2 === 0) await scan($, at)
287 if (ticks % 10 === 1) await readSettings($)
288 // A rename in the app shows within seconds.
289 if (ticks % 5 === 0 && titleFrom !== 'prompt') await refreshTitle($)
290 else if (ticks % 120 === 0) await refreshTitle($)
291 const list = await read($, rowsAtom)
292 const isLive = list.some(r => isLiveState(r.state) && !r.isStale)
293 if (isLive || ticks % 30 === 0) await update($, nowAtom, () => at)
294 } catch {
295 // the next tick tries again
296 } finally {
297 isBusy = false
298 }
299}
300
301async function writeSelf($: EngineInterface): Promise<void> {
302 if (me === null || !dir) return
303 const at = await $.clock.now()
304 me.updatedAt = at
305 lastWrite = at
306 isDirty = false
307 try {
308 await $.fs.write(beatPath(me.id), JSON.stringify(me))
309 } catch {
310 // this session still shows itself
311 }
312 const self = selfRow()
313 if (self !== null) await update($, rowsAtom, list => [...list.filter(r => !r.isSelf), self].sort(byBirth))
314}
315
316async function writeEnded($: EngineInterface, beat: Beat): Promise<void> {
317 try {
318 await $.fs.write(beatPath(beat.id), JSON.stringify({ ...beat, ended: true, updatedAt: await $.clock.now() }))
319 } catch {
320 // its file goes stale and drops off the board
321 }
322}
323
324async function readBeat($: EngineInterface, path: string): Promise<Beat | null> {
325 try {
326 if (!(await $.fs.exists(path))) return null
327 return asBeat(JSON.parse(await $.fs.read(path)))
328 } catch {
329 return null
330 }
331}
332
333// /clear gives the session a new id: the old row ends and a fresh one starts.
334async function followClear($: EngineInterface): Promise<void> {
335 if (me === null) return
336 const id = await $.session.id()
337 if (id === me.id) return
338 await writeEnded($, me)
339 const at = await $.clock.now()
340 const keepTitle = titleFrom === 'desktop' || titleFrom === 'name' ? me.title : ''
341 me = { ...freshBeat(id, me.surface, me.cwd, at), title: keepTitle }
342 if (!keepTitle) titleFrom = 'folder'
343 tasks.clear()
344 myTurns = []
345 waitKind = ''
346 await refreshTitle($)
347 await writeSelf($)
348}
349
350// The desktop app's own title for this session (what its sidebar shows), else
351// the first prompt; the folder name until there is one.
352async function refreshTitle($: EngineInterface): Promise<void> {
353 if (me === null || titleFrom === 'name') return
354 if (hostId) {
355 if (!desktopRecord) desktopRecord = await findDesktopRecord($)
356 if (desktopRecord) {
357 try {
358 const raw = JSON.parse(await $.fs.read(desktopRecord)) as { title?: unknown }
359 const title = typeof raw.title === 'string' ? raw.title.replace(/\s+/g, ' ').trim() : ''
360 if (title) {
361 if (title !== me.title) {
362 me.title = title
363 isDirty = true
364 }
365 titleFrom = 'desktop'
366 return
367 }
368 } catch {
369 desktopRecord = ''
370 }
371 }
372 }
373 if (me.title) return
374 try {
375 const messages = await $.session.messages()
376 if (!Array.isArray(messages)) return
377 for (const m of messages) {
378 const title = m.role === 'user' ? titleFromPrompt(m.text) : ''
379 if (title) {
380 me.title = title
381 titleFrom = 'prompt'
382 isDirty = true
383 return
384 }
385 }
386 } catch {
387 // the folder name stands in
388 }
389}
390
391async function findDesktopRecord($: EngineInterface): Promise<string> {
392 const base = home + DESKTOP_SESSIONS
393 try {
394 if (!(await $.fs.exists(base))) return ''
395 for (const account of await $.fs.list(base)) {
396 if (account.kind !== 'dir') continue
397 for (const org of await $.fs.list(`${base}/${account.name}`)) {
398 if (org.kind !== 'dir') continue
399 const path = `${base}/${account.name}/${org.name}/${hostId}.json`
400 if (await $.fs.exists(path)) return path
401 }
402 }
403 } catch {
404 // no desktop record: the prompt names the session
405 }
406 return ''
407}
408
409async function loadPace($: EngineInterface): Promise<number[]> {
410 try {
411 const value = await $.store.get('pace')
412 return Array.isArray(value) ? value.filter((n): n is number => typeof n === 'number' && n > 0).slice(-60) : []
413 } catch {
414 return []
415 }
416}
417
418async function recordPace($: EngineInterface, ms: number): Promise<void> {
419 pace = [...(await loadPace($)), Math.round(ms)].slice(-60)
420 try {
421 await $.store.set('pace', pace)
422 } catch {
423 // learned again from the next turns
424 }
425}
426
427// Settings live in the store, so a change in one session reaches the others,
428// and the recording flag Recording Mode keeps masks titles while it is on.
429async function readSettings($: EngineInterface): Promise<void> {
430 try {
431 const saved = (await $.store.get('prefs')) as Partial<Prefs> | undefined
432 const prefs: Prefs = {
433 mode: saved?.mode === 'compact' || saved?.mode === 'off' ? saved.mode : 'full',
434 alerts: saved?.alerts !== false,
435 }
436 const current = await read($, prefsAtom)
437 if (current.mode !== prefs.mode || current.alerts !== prefs.alerts) await update($, prefsAtom, () => prefs)
438 } catch {
439 // defaults stand
440 }
441 let isRecording = false
442 try {
443 const path = `${home}/.claude/mods-data/recording.json`
444 if (await $.fs.exists(path)) isRecording = (JSON.parse(await $.fs.read(path)) as { on?: unknown }).on === true
445 } catch {
446 isRecording = false
447 }
448 if (isRecording !== (await read($, maskedAtom))) await update($, maskedAtom, () => isRecording)
449}
450
451async function savePrefs($: EngineInterface, change: (p: Prefs) => Prefs): Promise<Prefs> {
452 const prefs = await update($, prefsAtom, change)
453 try {
454 await $.store.set('prefs', prefs)
455 } catch {
456 // this session keeps it until it ends
457 }
458 return prefs
459}
460
461// ---------------------------------------------------------------------------
462// The turn: started, its tool calls, waits on the person, its end
463
464function setPlan(counts: { done: number; active: number; total: number } | null, source: string, at: number, activity: string): void {
465 if (me === null) return
466 const prev = me.plan
467 const plan = counts === null ? null : withChange(prev, counts, source, at)
468 // A new list, or one that started over, restarts the plan's clock.
469 if (plan !== null && (prev === null || prev.source !== source || plan.done < prev.done)) me.planAt = at
470 if (plan === null) me.planAt = 0
471 me.plan = plan
472 if (activity) me.activity = clip(activity, 60)
473 isDirty = true
474}
475
476async function readGoal($: EngineInterface): Promise<void> {
477 if (me === null) return
478 try {
479 const path = `${home}/.claude/mods-data/goal-meter/${me.id}.json`
480 if (!(await $.fs.exists(path))) return
481 const counts = goalPlan(JSON.parse(await $.fs.read(path)))
482 if (counts !== null) setPlan(counts, 'goal', await $.clock.now(), '')
483 } catch {
484 // Goal Meter's own band still shows it
485 }
486}
487
488async function startTurn($: EngineInterface, text: string): Promise<void> {
489 if (me === null) return
490 await followClear($)
491 const at = await $.clock.now()
492 const plan: Plan | null = me.plan !== null && me.plan.done < me.plan.total ? me.plan : null
493 if (plan === null) tasks.clear()
494 me = {
495 ...me,
496 state: 'working',
497 startedAt: at,
498 endedAt: 0,
499 waitMs: 0,
500 waitingSince: 0,
501 tools: 0,
502 activity: 'Thinking',
503 need: '',
504 plan,
505 planAt: plan === null ? 0 : me.planAt,
506 expectMs: expectedMs(pace, myTurns),
507 stopPct: 0,
508 agents: 0,
509 }
510 waitKind = ''
511 if (titleFrom === 'folder') {
512 const title = titleFromPrompt(text)
513 if (title) {
514 me.title = title
515 titleFrom = 'prompt'
516 }
517 }
518 await readGoal($)
519 await writeSelf($)
520}
521
522async function beginWait($: EngineInterface, kind: 'ask' | 'permission', need: string): Promise<void> {
523 if (me === null || me.state !== 'working') return
524 me.state = 'waiting'
525 me.waitingSince = await $.clock.now()
526 me.need = need
527 if (kind === 'permission') me.activity = 'Waiting for your permission'
528 waitKind = kind
529 await writeSelf($)
530}
531
532async function endWait($: EngineInterface): Promise<void> {
533 waitKind = ''
534 if (me === null || me.state !== 'waiting') return
535 const at = await $.clock.now()
536 me.waitMs += Math.max(0, at - me.waitingSince)
537 me.waitingSince = 0
538 me.state = 'working'
539 me.need = ''
540 await writeSelf($)
541}
542
543async function noteCall($: EngineInterface, tool: string, input: Record<string, unknown>, isMain: boolean, isAsk: boolean): Promise<void> {
544 if (me === null || !isLiveState(me.state)) return
545 // A tool runs once its permission was given.
546 if (waitKind === 'permission') await endWait($)
547 const at = await $.clock.now()
548 me.tools += 1
549 if (isMain) {
550 const activity = activityOf(tool, input)
551 if (activity) me.activity = activity
552 }
553 if (tool === 'TodoWrite') {
554 const found = todosPlan(input.todos)
555 setPlan(found === null ? null : found.counts, 'todos', at, found === null ? '' : found.activity)
556 } else if (tool === 'TaskUpdate') {
557 const id = String(input.taskId ?? '')
558 const prev = tasks.get(id)
559 if (id) {
560 tasks.set(id, {
561 status: typeof input.status === 'string' ? input.status : (prev?.status ?? 'pending'),
562 label: typeof input.activeForm === 'string' ? input.activeForm : (prev?.label ?? ''),
563 })
564 const found = tasksPlan(tasks)
565 setPlan(found === null ? null : found.counts, 'tasks', at, found === null ? '' : found.activity)
566 }
567 } else if (tool === SAVVY_PROGRESS || tool === SAVVY_STEP) {
568 setPlan(savvyPlan(input, me.plan), 'savvy', at, typeof input.note === 'string' ? input.note : '')
569 }
570 isDirty = true
571 if (isAsk) await beginWait($, 'ask', askNeed(tool, input))
572}
573
574function taskIdOf(ran: ToolCallResult): string {
575 const result = ran.result as { task?: { id?: unknown }; taskId?: unknown; id?: unknown } | undefined
576 const direct = result?.task?.id ?? result?.taskId ?? result?.id
577 if (typeof direct === 'string' || typeof direct === 'number') return String(direct)
578 const text = (ran as { text?: unknown }).text
579 const found = /#?(\d+)/.exec(typeof text === 'string' ? text : '')
580 return found?.[1] ?? ''
581}
582
583async function afterCall($: EngineInterface, tool: string, input: Record<string, unknown>, ran: ToolCallResult, isAsk: boolean): Promise<void> {
584 if (me === null) return
585 if (isAsk || waitKind === 'permission') await endWait($)
586 if (tool === 'TaskCreate' && ran.deny === undefined) {
587 const id = taskIdOf(ran)
588 if (id) {
589 tasks.set(id, { status: 'pending', label: String(input.activeForm ?? input.subject ?? '') })
590 const found = tasksPlan(tasks)
591 setPlan(found === null ? null : found.counts, 'tasks', await $.clock.now(), '')
592 }
593 }
594 if (tool === GOAL_TOOL) await readGoal($)
595}
596
597async function finishTurn($: EngineInterface, reason: string, durationMs: number): Promise<void> {
598 if (me === null || me.startedAt === 0 || !isLiveState(me.state)) return
599 const at = await $.clock.now()
600 if (me.waitingSince) {
601 me.waitMs += Math.max(0, at - me.waitingSince)
602 me.waitingSince = 0
603 }
604 const pct = estimate(me, at).pct
605 const took = durationMs > 0 ? durationMs : at - me.startedAt
606 const state: PulseState = reason === 'answer' ? 'done' : reason === 'aborted' ? 'stopped' : 'error'
607 me = { ...me, state, endedAt: at, turns: me.turns + 1, lastMs: took, stopPct: pct, activity: '', need: '' }
608 waitKind = ''
609 if (state === 'done') {
610 const worked = Math.max(SEC, took - me.waitMs)
611 myTurns = [...myTurns, worked].slice(-10)
612 if (worked > 3 * SEC) await recordPace($, worked)
613 }
614 try {
615 const running = (await $.agent.list()).filter(a => a.status === 'running').length
616 me.agents = running
617 if (running > 0) me.activity = `${running} background agent${running === 1 ? '' : 's'} still running`
618 } catch {
619 me.agents = 0
620 }
621 await writeSelf($)
622 if (titleFrom !== 'desktop' || me.turns <= 3) await refreshTitle($)
623}
624
625// A turn whose end this module never heard (a reload or an unload between its
626// start and its end) would stay "working" for good. The engine's own band says
627// whether a turn runs: past a short grace, a live row with no turn running is
628// over. Its length is unknown, so it teaches the pace nothing.
629async function settleMissedTurn($: EngineInterface, isWorking: boolean): Promise<void> {
630 if (isWorking || me === null || me.startedAt === 0 || !isLiveState(me.state)) return
631 const at = await $.clock.now()
632 if (at - me.startedAt < MISSED_TURN_GRACE_MS) return
633 me = { ...me, state: 'done', endedAt: at, turns: me.turns + 1, stopPct: 100, waitingSince: 0, activity: '', need: '' }
634 waitKind = ''
635 await writeSelf($)
636}
637
638// ---------------------------------------------------------------------------
639// Everyone else: read their heartbeats, notice who finished or needs you
640
641async function scan($: EngineInterface, at: number): Promise<void> {
642 if (me === null || !dir) return
643 let entries: readonly { name: string; kind: string; mtimeMs: number }[] = []
644 try {
645 entries = await $.fs.list(dir)
646 } catch {
647 entries = []
648 }
649 const beats: Beat[] = []
650 const old: string[] = []
651 const seen = new Set<string>()
652 hidden.clear()
653 for (const entry of entries) {
654 if (entry.kind !== 'file') continue
655 const beatName = BEAT_FILE.exec(entry.name)
656 const hideName = beatName === null ? HIDE_FILE.exec(entry.name) : null
657 if (beatName === null && hideName === null) continue
658 seen.add(entry.name)
659 const path = `${dir}/${entry.name}`
660 let cached = files.get(entry.name)
661 if (cached === undefined || cached.mtimeMs !== entry.mtimeMs) {
662 cached = { mtimeMs: entry.mtimeMs, beat: null, hiddenAt: 0 }
663 try {
664 const raw: unknown = JSON.parse(await $.fs.read(path))
665 if (beatName !== null) cached.beat = asBeat(raw)
666 else cached.hiddenAt = typeof (raw as { at?: unknown })?.at === 'number' ? (raw as { at: number }).at : 0
667 } catch {
668 cached.mtimeMs = -1 // caught mid-write: read it again next time
669 }
670 files.set(entry.name, cached)
671 }
672 if (hideName !== null) {
673 if (cached.hiddenAt > 0) hidden.set(hideName[1] ?? '', cached.hiddenAt)
674 else if (cached.mtimeMs > 0 && at - cached.mtimeMs > FORGET_MS) old.push(path)
675 continue
676 }
677 const beat = cached.beat
678 if (beat === null || beat.id === me.id) continue
679 if (beat.ended === true || at - beat.updatedAt > FORGET_MS) {
680 old.push(path)
681 continue
682 }
683 beats.push(beat)
684 }
685 for (const name of [...files.keys()]) if (!seen.has(name)) files.delete(name)
686 if (at < demoUntil) beats.push(...demo.map(b => ({ ...b, updatedAt: at })))
687 else demo = []
688
689 const others: Row[] = beats.map(b => ({
690 ...b,
691 isSelf: false,
692 isHidden: isDemo(b.id) ? demoHidden.has(b.id) : isHiddenBeat(b, hidden.get(b.id) ?? 0),
693 isStale: at - b.updatedAt > STALE_MS,
694 }))
695 const self = selfRow()
696 const next = (self === null ? others : [...others, self]).sort(byBirth)
697 await alert($, next)
698 const before = await read($, rowsAtom)
699 if (JSON.stringify(before) !== JSON.stringify(next)) await update($, rowsAtom, () => next)
700 if (old.length > 0 && at - lastPrune > 10 * MIN) {
701 lastPrune = at
702 await prune($, old)
703 }
704}
705
706async function prune($: EngineInterface, paths: readonly string[]): Promise<void> {
707 const safe = paths.filter(p => p.startsWith(`${dir}/`) && /\/(s|hide)-[A-Za-z0-9-]{8,64}\.json$/.test(p))
708 if (safe.length === 0) return
709 try {
710 await $.process.run(['rm', '-f', '--', ...safe], { timeoutMs: 5000 })
711 } catch {
712 // they stay off the board either way
713 }
714}
715
716async function alert($: EngineInterface, list: readonly Row[]): Promise<void> {
717 const prefs = await read($, prefsAtom)
718 const isMasked = await read($, maskedAtom)
719 list.forEach((r, i) => {
720 const before = lastState.get(r.id)
721 lastState.set(r.id, r.state)
722 if (!hasScanned || !prefs.alerts || r.isSelf || r.isHidden || r.isStale || before === undefined || before === r.state) return
723 const name = clip(labelOf(r, i, isMasked), 48)
724 if (r.state === 'done' && isLiveState(before)) $.ui.toast(`✓ ${name} finished · took ${durText(r.lastMs)}`)
725 else if (r.state === 'waiting') $.ui.toast(`● ${name} needs you`)
726 else if (r.state === 'error') $.ui.toast(`✗ ${name} stopped on an error`)
727 })
728 hasScanned = true
729}
730
731// ---------------------------------------------------------------------------
732// Acting on a row: switch to it, hide it
733
734const shellQuote = (s: string): string => `'${s.replace(/'/g, `'\\''`)}'`
735
736async function switchTo($: EngineInterface, row: Row, surface: RenderSurface): Promise<void> {
737 if (row.isSelf) {
738 $.ui.toast('This is the session you are in.')
739 return
740 }
741 if (isDemo(row.id)) {
742 $.ui.toast(`Demo row: a real "${clip(row.title, 40)}" session would open here.`)
743 return
744 }
745 if (HOST_ID.test(row.hostId)) {
746 const url = `claude://code/continue?session=${row.hostId}`
747 for (const argv of [['open', url], ['xdg-open', url]]) {
748 try {
749 const ran = await $.process.run(argv, { timeoutMs: 8000 })
750 if (ran.exitCode === 0) return
751 } catch {
752 // try the next opener
753 }
754 }
755 $.ui.toast('Could not open that session in the desktop app.')
756 return
757 }
758 const resume = `cd ${shellQuote(row.cwd)} && claude --resume ${row.id}`
759 let isCopied = false
760 try {
761 isCopied = (await $.ui.copy({ text: resume, surface })).isCopied
762 } catch {
763 isCopied = false
764 }
765 $.ui.toast(isCopied ? 'That session runs in a terminal. Its resume command is on the clipboard.' : `That session runs in a terminal: ${resume}`, { timeoutMs: 8000 })
766}
767
768async function hideRow($: EngineInterface, row: Row): Promise<void> {
769 if (isDemo(row.id)) {
770 demoHidden.add(row.id)
771 await update($, rowsAtom, list => list.map(r => (r.id === row.id ? { ...r, isHidden: true } : r)))
772 return
773 }
774 if (!dir) return
775 const at = await $.clock.now()
776 try {
777 await $.fs.write(hidePath(row.id), JSON.stringify({ at }))
778 } catch {
779 // hidden in this session only
780 }
781 hidden.set(row.id, at)
782 await update($, rowsAtom, list => list.map(r => (r.id === row.id ? { ...r, isHidden: true } : r)))
783}
784
785async function unhideAll($: EngineInterface): Promise<number> {
786 const ids = [...hidden.keys()]
787 for (const id of ids) {
788 try {
789 await $.fs.write(hidePath(id), JSON.stringify({ at: 0 }))
790 } catch {
791 // stays hidden until its next turn
792 }
793 }
794 const count = ids.length + demoHidden.size
795 hidden.clear()
796 demoHidden.clear()
797 await update($, rowsAtom, list => list.map(r => ({ ...r, isHidden: false })))
798 return count
799}
800
801async function openPane($: EngineInterface): Promise<void> {
802 // Docked beside the transcript where the surface docks panes (the right side).
803 await $.ui.open({ id: PANE, title: 'Sessions', focus: true, closeOnEscape: true, columns: 72 })
804}
805
806async function togglePane($: EngineInterface): Promise<boolean> {
807 const isOpen = (await $.ui.panes()).some(p => p.id === PANE)
808 if (isOpen) {
809 await $.ui.close({ id: PANE })
810 return false
811 }
812 await openPane($)
813 return true
814}
815
816// ---------------------------------------------------------------------------
817// Drawing
818
819// A dark tint of a state color, for the terminal's one-row chip behind its word.
820function chipTint(hex: string): string {
821 const m = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex)
822 if (m === null) return '#2a2a2a'
823 const mix = (h: string | undefined): string => Math.round(0x22 + (parseInt(h ?? '0', 16) - 0x22) * 0.18).toString(16).padStart(2, '0')
824 return `#${mix(m[1])}${mix(m[2])}${mix(m[3])}`
825}
826
827function viewOf(r: Row, at: number): View {
828 const est = estimate(r, at)
829 let pct = est.pct
830 if (isLiveState(r.state)) {
831 // Never step backwards while one turn runs (a plan appearing can lower the estimate).
832 const key = `${r.id}:${r.startedAt}`
833 pct = Math.max(pct, highest.get(key) ?? 0)
834 if (highest.size > 300) highest.clear()
835 highest.set(key, pct)
836 }
837 const color = colorOf(r.state, pct)
838 const pctText = r.state === 'idle' ? '' : `${Math.round(pct)}%`
839 const base = { pct, pctText, color, isLive: r.state === 'working', tailHex: '' }
840 // The time column says when (time left, or how long ago); the state column
841 // after it says what it is, or, while the session waits on you, what to do.
842 const time = (tail: string) => ({ tail, tailColor: 'subtle' })
843 const state = (word: string, c = 'subtle') => ({ state: word, stateColor: c })
844 switch (r.state) {
845 case 'working':
846 return { ...base, ...time(etaText(est.etaMs)), ...state('working', color) }
847 case 'waiting':
848 return { ...base, ...time(`${durText(Math.max(0, at - (r.waitingSince || at)))} waiting`), ...state(r.need || 'needs you', COLOR.waiting) }
849 case 'done':
850 return { ...base, ...time(agoText(at - r.endedAt)), ...state('done', COLOR.done) }
851 case 'stopped':
852 return { ...base, ...time(agoText(at - r.endedAt)), ...state('interrupted', COLOR.stopped) }
853 case 'error':
854 return { ...base, ...time(agoText(at - r.endedAt)), ...state('error', COLOR.error) }
855 default:
856 return { ...base, ...time(''), ...state(r.isStale ? 'offline' : 'idle', COLOR.idle) }
857 }
858}
859
860function termRow($: EngineInterface, ui: Kit, item: Item, at: number, cols: number, hotkey: string, need: Need): RenderElement {
861 const { Box, Text, Button } = ui
862 const r = item.row
863 const view = viewOf(r, at)
864 // The title column fits the longest title shown; the bar gives up room first
865 // (down to 10 cells) and a title is cut only past half the row.
866 // The alert column fits the longest shown whole, on one line.
867 const tailW = clamp(need.tail, 6, 14)
868 const paneW = clamp(need.state, 4, 32) + 1
869 const titleW = clamp(need.title, 10, Math.max(10, Math.min(Math.floor(cols * 0.5), cols - 18 - tailW - paneW - 10)))
870 const barW = clamp(cols - titleW - 18 - tailW - paneW, 6, 32)
871 const { fill, rest } = barCells(view.pct, barW)
872 const name = fit(r.isSelf ? `${item.label} (here)` : item.label, titleW)
873 const keys = hotkey ? { hotkey } : {}
874 return (
875 <Box key={`row-${r.id}`} flexDirection="row" width="100%" gap={1}>
876 <Box width={ICON_CELLS - 1} flexShrink={0}>
877 <Text color={view.color}>{iconOf(r.state)}</Text>
878 </Box>
879 <Button key={`go-${r.id}`} plain {...keys} onPress={press => void switchTo($, r, press.surface)}>
880 <Text bold={r.isSelf}>{name}</Text>
881 </Button>
882 <Box flexDirection="row">
883 <Text color={view.color}>{fill}</Text>
884 <Text color="subtle">{rest}</Text>
885 </Box>
886 <Text bold>{view.pctText.padStart(4)}</Text>
887 <Text color={view.tailColor}>{fit(view.tail, tailW)}</Text>
888 {r.state === 'waiting' ? <Text color={COLOR.waiting}>{fit(view.state, paneW - 1)}</Text> : <Text dimColor>{fit(view.state, paneW - 1)}</Text>}
889 <Box flexGrow={1} />
890 <Button key={`x-${r.id}`} label="×" plain dimColor onPress={() => void hideRow($, r)} />
891 </Box>
892 )
893}
894
895function svgRow($: EngineInterface, ui: Kit, Svg: SvgElement, item: Item, at: number, cols: number, hotkey: string, site: string, need: Need): RenderElement {
896 const { Box, Text, Button } = ui
897 const r = item.row
898 const view = viewOf(r, at)
899 // The row is laid out in the band's columns (about 8 CSS px each; the app's
900 // text runs about 0.85 of a column per character), so it never runs past the
901 // band's right edge. The title and time-left columns fit the longest of each
902 // shown; the buttons take their own width; the bar gets the rest, down to 10
903 // columns, and a title is cut only past half the row.
904 const span = clamp(cols, 40, 220)
905 const PCT = 4
906 // The alert column gets room to spare (0.95 of a column a character, plus
907 // two), so a "needs you" phrase always fits whole on one line.
908 // Time, then state (or what to do), each as wide as its longest shown, whole on one line.
909 const TIME = Math.max(4, Math.ceil(need.tail * 0.95) + 1)
910 // The state column, its longest word whole on one line.
911 const PANE = Math.max(4, Math.ceil(need.state * 0.95) + 1)
912 const TAIL = TIME
913 const BUTTONS = 6 // ↗ and ×: their labels plus the buttons' padding
914 const GAPS = 7
915 const FIXED = PCT + TAIL + PANE + BUTTONS + GAPS + 2
916 const maxTitle = Math.max(10, Math.min(Math.floor(span * 0.5), span - FIXED - 10))
917 const titleCells = clamp(Math.ceil(need.title * 0.9) + 3, 10, maxTitle)
918 const titleChars = Math.floor((titleCells - 3) / 0.9)
919 const barCells = Math.max(8, span - titleCells - FIXED)
920 // The bar and, at its right end, the rolling percent in the app's own glyphs.
921 const width = (barCells + 1 + PCT) * 8
922 const keys = hotkey ? { hotkey } : {}
923 const name = item.label
924 const shownKey = `${site}:${r.id}`
925 const before = lastShown.get(shownKey)
926 if (lastShown.size > 300) lastShown.clear()
927 lastShown.set(shownKey, { pct: view.pct, pctText: view.pctText })
928 const source = barSvg({
929 pct: view.pct,
930 color: view.color,
931 width,
932 pctText: view.pctText,
933 prevPct: before?.pct,
934 prevPctText: before !== undefined && before.pctText !== view.pctText ? before.pctText : '',
935 tail: '',
936 tailColor: '',
937 isLive: view.isLive,
938 font: digitFont,
939 pctW: PCT * 8,
940 })
941 return (
942 <Box key={`row-${r.id}`} flexDirection="row" alignItems="center" width="100%" gap={1}>
943 {/* The dot and the title are one plain line of text, drawn as the
944 status line above draws "● cache warm", so both line up with it
945 exactly (a button's own padding would push the title right). The ↗
946 before the × switches to the session. No wrap prop (the desktop draws
947 a wrapped text larger): the title is cut instead. */}
948 <Box width={titleCells} flexShrink={0} flexDirection="row" alignItems="center">
949 <Text>
950 <Text color={view.color}>{iconOf(r.state)}</Text>
951 <Text dimColor>{` ${cells(name) <= titleChars ? name : clip(name, Math.max(8, titleChars))}`}</Text>
952 </Text>
953 </Box>
954 {/* The bar starts right after the title column, so it always sits
955 between the title and its percent; spare room goes before the ×. */}
956 <Svg source={source} alt={`${name}: ${[view.pctText, view.tail].filter(Boolean).join(', ')}`} width={width} height={SVG_HEIGHT} />
957 {/* Time, then state (or what to do while it waits on you), each column
958 as wide as its longest shown, so the × buttons line up row to row. */}
959 <Box width={TAIL} flexShrink={0}>
960 <Text color={view.tailColor}>{view.tail}</Text>
961 </Box>
962 {/* The state in dim text; what to do, while it waits on you, in blue. */}
963 <Box width={PANE} flexShrink={0}>
964 {r.state === 'waiting' ? <Text color={COLOR.waiting}>{view.state}</Text> : <Text dimColor>{view.state}</Text>}
965 </Box>
966 <Box flexGrow={1} />
967 <Button key={`go-${r.id}`} label="↗" plain dimColor {...keys} onPress={press => void switchTo($, r, press.surface)} />
968 <Button key={`x-${r.id}`} label="×" plain dimColor onPress={() => void hideRow($, r)} />
969 </Box>
970 )
971}
972
973// The terminal draws text bars; the desktop and the other remote surfaces an
974// SVG bar. The surface decides, not the table: the terminal's carries an Svg too.
975function rowOf($: EngineInterface, ui: ReturnType<EngineInterface['ui']['resolve']>, surface: RenderSurface, item: Item, at: number, cols: number, hotkey: string, site: string, need: Need): RenderElement {
976 return surface !== 'terminal' && 'Svg' in ui ? svgRow($, ui, ui.Svg, item, at, cols, hotkey, `${surface}:${site}`, need) : termRow($, ui, item, at, cols, hotkey, need)
977}
978
979// The cells the longest shown title, time and state need, so every row's
980// columns are as wide as their widest entry and nothing is cut while the row has room.
981type Need = { title: number; tail: number; state: number }
982
983function columnNeeds(items: readonly Item[], surface: RenderSurface, at: number): Need {
984 return items.reduce(
985 (a, x) => ({
986 title: Math.max(a.title, cells(surface === 'terminal' && x.row.isSelf ? `${x.label} (here)` : x.label)),
987 tail: Math.max(a.tail, cells(viewOf(x.row, at).tail)),
988 state: Math.max(a.state, cells(viewOf(x.row, at).state)),
989 }),
990 { title: 0, tail: 0, state: 0 },
991 )
992}
993
994function compactBand($: EngineInterface, ui: Kit, surface: RenderSurface, items: readonly Item[], at: number): RenderElement {
995 const { Box, Text, Button } = ui
996 const views = items.map(x => ({ row: x.row, view: viewOf(x.row, at), est: estimate(x.row, at) }))
997 const working = views.filter(v => v.row.state === 'working')
998 const waiting = views.filter(v => v.row.state === 'waiting')
999 const finished = views.filter(v => v.row.state === 'done')
1000 const failed = views.filter(v => v.row.state === 'error' || v.row.state === 'stopped')
1001 const parts: RenderElement[] = []
1002 if (working.length > 0) {
1003 const avg = working.reduce((a, v) => a + v.view.pct, 0) / working.length
1004 const soonest = Math.min(...working.map(v => v.est.etaMs ?? Infinity))
1005 const next = Number.isFinite(soonest) ? ` · next ${etaText(soonest)}` : ''
1006 parts.push(<Text color={workingColor(avg)}>{`● ${working.length} working${next}`}</Text>)
1007 }
1008 if (waiting.length > 0) parts.push(<Text color={COLOR.waiting}>{`● ${waiting.length} need${waiting.length === 1 ? 's' : ''} you`}</Text>)
1009 if (finished.length > 0) parts.push(<Text color={COLOR.done}>{`✓ ${finished.length} done`}</Text>)
1010 if (failed.length > 0) parts.push(<Text color={COLOR.stopped}>{`■ ${failed.length} stopped`}</Text>)
1011 const children: (RenderElement | string)[] = []
1012 parts.forEach((p, i) => {
1013 if (i > 0) children.push(' ')
1014 children.push(p)
1015 })
1016 // A desktop button insets its label and drops the colors, so off the
1017 // terminal the line is plain text that starts where the band's other text does.
1018 if (surface !== 'terminal') {
1019 return (
1020 <Box flexDirection="row" width="100%">
1021 {children}
1022 </Box>
1023 )
1024 }
1025 return (
1026 <Box flexDirection="row" width="100%">
1027 <Button key="pulse-open" plain onPress={() => void openPane($)}>
1028 {children}
1029 </Button>
1030 </Box>
1031 )
1032}
1033
1034function stateWord(r: Row): string {
1035 switch (r.state) {
1036 case 'working':
1037 return 'Working'
1038 case 'waiting':
1039 return 'Needs you'
1040 case 'done':
1041 return 'Done'
1042 case 'stopped':
1043 return 'Interrupted'
1044 case 'error':
1045 return 'Stopped on an error'
1046 default:
1047 return 'Idle'
1048 }
1049}
1050
1051// The second line of a pane row: what the session is doing or how its last
1052// turn went, then the plan, the folder and where it runs.
1053function detailOf(r: Row, at: number, isMasked: boolean): string {
1054 const since = (t: number): string => durText(Math.max(0, at - t))
1055 const activity = !isMasked && r.activity ? r.activity : ''
1056 let lead: string
1057 if (r.isStale) lead = `No heartbeat for ${since(r.updatedAt)}, probably closed`
1058 else if (r.state === 'working') lead = `${activity || 'Working'} · ${since(r.startedAt)} so far${r.plan === null ? ' · time left estimated from past turns' : ''}`
1059 else if (r.state === 'waiting') lead = `Needs you: ${r.need || (activity ? activity.replace(/^Waiting for /, '') : 'your answer')} · waiting ${since(r.waitingSince || r.startedAt)}`
1060 else if (r.state === 'done') lead = `Finished ${agoText(at - r.endedAt)} · took ${durText(r.lastMs)}`
1061 else if (r.state === 'stopped') lead = `Interrupted ${agoText(at - r.endedAt)}`
1062 else if (r.state === 'error') lead = `Stopped on an error ${agoText(at - r.endedAt)}`
1063 else lead = r.turns > 0 ? `Idle since ${agoText(at - r.endedAt)}` : 'Open, no prompt yet'
1064 const parts = [lead]
1065 if (r.plan !== null) {
1066 parts.push(r.plan.source === 'goal' ? `goal ${Math.round((100 * r.plan.done) / r.plan.total)}% done` : `${r.plan.done} of ${r.plan.total} tasks done`)
1067 }
1068 if (r.agents > 0 && !isLiveState(r.state)) parts.push(`${r.agents} background agent${r.agents === 1 ? '' : 's'} running`)
1069 if (!isMasked && r.project) parts.push(r.project)
1070 parts.push(r.hostId ? 'Desktop app' : 'Terminal')
1071 return parts.join(' · ')
1072}
1073
1074function boardText(list: readonly Row[], at: number, isMasked: boolean): string {
1075 const live = list.filter(r => r.ended !== true)
1076 if (live.length === 0) return 'No sessions on the board.'
1077 return live
1078 .map((r, i) => {
1079 const view = viewOf(r, at)
1080 return `${iconOf(r.state)} ${labelOf(r, i, isMasked)}${r.isSelf ? ' (here)' : ''} ${view.pctText} ${view.tail}`.trimEnd()
1081 })
1082 .join('\n')
1083}
1084
1085const HELP = [
1086 '/pulse: open or close the session board.',
1087 '/pulse compact | full | off: one summary line, a row per session, or no band.',
1088 '/pulse alerts on | off: a toast when another session finishes or needs you.',
1089 '/pulse show: bring hidden rows back.',
1090 '/pulse name <title>: rename this session on the board.',
1091 '/pulse demo [off]: five made-up sessions for three minutes, to see every state.',
1092].join('\n')
1093
1094async function runCommand($: EngineInterface, args: string): Promise<CommandRunResult> {
1095 const [verb = '', ...rest] = args.trim().split(/\s+/).filter(Boolean)
1096 const word = verb.toLowerCase()
1097 if (word === '') {
1098 if ((await $.session.surfaces()).length === 0) {
1099 return { text: boardText(await read($, rowsAtom), await $.clock.now(), await read($, maskedAtom)) }
1100 }
1101 const isOpen = await togglePane($)
1102 return isOpen ? {} : { text: 'Session board closed.' }
1103 }
1104 if (word === 'full' || word === 'compact' || word === 'off') {
1105 const mode: Prefs['mode'] = word
1106 await savePrefs($, p => ({ ...p, mode }))
1107 return { text: mode === 'off' ? 'Session band off. /pulse still opens the board.' : `Session band: ${mode}.` }
1108 }
1109 if (word === 'alerts') {
1110 const value = (rest[0] ?? '').toLowerCase()
1111 const isOn = value === 'on' ? true : value === 'off' ? false : !(await read($, prefsAtom)).alerts
1112 await savePrefs($, p => ({ ...p, alerts: isOn }))
1113 return { text: `Session alerts ${isOn ? 'on' : 'off'}.` }
1114 }
1115 if (word === 'show') {
1116 const count = await unhideAll($)
1117 return { text: count === 0 ? 'No hidden sessions.' : `Showing ${count} hidden session${count === 1 ? '' : 's'} again.` }
1118 }
1119 if (word === 'demo') {
1120 const at = await $.clock.now()
1121 demoHidden.clear()
1122 if ((rest[0] ?? '').toLowerCase() === 'off') {
1123 demo = []
1124 demoUntil = 0
1125 await saveDemo($, 0)
1126 await scan($, at)
1127 return { text: 'Demo rows removed.' }
1128 }
1129 demo = demoBeats(at)
1130 demoUntil = at + 3 * MIN
1131 await saveDemo($, at)
1132 await scan($, at)
1133 return { text: 'Five demo sessions for three minutes (/pulse demo off ends it). Clicks and × work on them; nothing is written.' }
1134 }
1135 if (word === 'name') {
1136 const title = clip(rest.join(' ').trim(), 80)
1137 if (me === null) return { text: 'This session is not on the board.' }
1138 if (!title) {
1139 titleFrom = 'folder'
1140 me.title = ''
1141 await refreshTitle($)
1142 } else {
1143 titleFrom = 'name'
1144 me.title = title
1145 }
1146 await writeSelf($)
1147 return { text: title ? `This session is "${title}" on the board.` : 'Board title reset.' }
1148 }
1149 return { text: HELP }
1150}
1151
1152const COMMAND_DESCRIPTION = 'Session board: every open session with progress and time left'
1153const COMMAND_HINT = '[compact|full|off|alerts on|off|show|name <title>|demo]'
1154
1155// /pulse, or /session-pulse when another plugin took the short name.
1156async function registerCommand($: EngineInterface): Promise<string> {
1157 try {
1158 await $.command.register({ name: 'pulse', description: COMMAND_DESCRIPTION, argumentHint: COMMAND_HINT, immediate: true })
1159 return 'pulse'
1160 } catch {
1161 try {
1162 await $.command.register({ name: 'session-pulse', description: COMMAND_DESCRIPTION, argumentHint: COMMAND_HINT, immediate: true })
1163 return 'session-pulse'
1164 } catch {
1165 return ''
1166 }
1167 }
1168}
1169
1170// ---------------------------------------------------------------------------
1171
1172export const register: Register = on => {
1173 on('session.start', async ($, e, next) => {
1174 const started = await next(e)
1175 try {
1176 home = (await $.env.get('HOME')) ?? ''
1177 if (!home) return started
1178 dir = home + DATA
1179 const host = (await $.env.get('CLAUDE_CODE_HOST_SESSION_ID')) ?? ''
1180 hostId = HOST_ID.test(host) ? host : ''
1181 await readSettings($)
1182 commandName = await registerCommand($)
1183 // A headless child (claude -p) has no surface and stays off the board; a
1184 // desktop session may attach its surface after it starts.
1185 // A hot reload in an already attached session raises no new attach, so
1186 // the surfaces already drawing count too.
1187 const surfaces = await $.session.surfaces()
1188 if (e.isInteractive || e.surface !== null || surfaces.length > 0) {
1189 await startTracking($, e.cwd, e.surface ?? surfaces[0] ?? 'terminal')
1190 }
1191 } catch {
1192 // the board stays empty in this session
1193 }
1194 return started
1195 })
1196
1197 on('session.attach', async ($, e, next) => {
1198 const attached = await next(e)
1199 try {
1200 if (!isTracked && dir) await startTracking($, await $.session.cwd(), e.surface)hooks/estimate.ts 663 lines1// Session Pulse's math and text: how far along a session is, how long it has
2// left, and how a row reads. Pure functions with no mods API calls, so the
3// tests drive them directly.
4
5import type { Beat, Plan, PulseState } from '../types'
6
7export const SEC = 1000
8export const MIN = 60 * SEC
9export const HOUR = 60 * MIN
10
11/** A turn's length before anything has been learned. */
12export const DEFAULT_TURN_MS = 90 * SEC
13const MIN_EXPECT = 15 * SEC
14const MAX_EXPECT = 2 * HOUR
15
16export const STATES: readonly PulseState[] = ['working', 'waiting', 'done', 'stopped', 'error', 'idle']
17
18export const clamp = (n: number, lo: number, hi: number): number => Math.min(hi, Math.max(lo, n))
19
20const finite = (n: unknown, fallback = 0): number => (typeof n === 'number' && Number.isFinite(n) ? n : fallback)
21
22export function median(list: readonly number[]): number {
23 const s = list.filter(n => Number.isFinite(n) && n > 0).sort((a, b) => a - b)
24 if (s.length === 0) return 0
25 const m = Math.floor(s.length / 2)
26 const mid = s[m] ?? 0
27 return s.length % 2 ? mid : ((s[m - 1] ?? mid) + mid) / 2
28}
29
30// ---------------------------------------------------------------------------
31// Progress and time left
32
33/**
34 * How long the next turn should take: this session's last five turns
35 * (geometric mean), pulled toward every session's median, harder the fewer
36 * turns this one has had.
37 */
38export function expectedMs(all: readonly number[], mine: readonly number[]): number {
39 const global = median(all) || DEFAULT_TURN_MS
40 const recent = mine.filter(n => Number.isFinite(n) && n > 0).slice(-5)
41 if (recent.length === 0) return clamp(global, MIN_EXPECT, MAX_EXPECT)
42 const own = Math.exp(recent.reduce((a, d) => a + Math.log(Math.max(d, SEC)), 0) / recent.length)
43 const w = recent.length / (recent.length + 2)
44 return Math.round(clamp(Math.exp(w * Math.log(own) + (1 - w) * Math.log(global)), MIN_EXPECT, MAX_EXPECT))
45}
46
47/** Time the turn has spent working; waits on the person don't count. */
48export function workedMs(b: Beat, now: number): number {
49 const waiting = b.waitingSince ? Math.max(0, now - b.waitingSince) : 0
50 return Math.max(0, now - b.startedAt - b.waitMs - waiting)
51}
52
53export type Estimate = {
54 /** 0 to 100; 100 only once the turn is done. */
55 pct: number
56 /** Rough time left, or null when nothing is running. */
57 etaMs: number | null
58 basis: 'plan' | 'pace' | 'final'
59}
60
61export function estimate(b: Beat, now: number): Estimate {
62 if (b.state === 'done') return { pct: 100, etaMs: null, basis: 'final' }
63 if (b.state === 'idle') return { pct: 0, etaMs: null, basis: 'final' }
64 if (b.state === 'stopped' || b.state === 'error') {
65 return { pct: clamp(Math.round(b.stopPct), 0, 99), etaMs: null, basis: 'final' }
66 }
67 const expect = Math.max(b.expectMs || DEFAULT_TURN_MS, MIN_EXPECT)
68 if (b.plan !== null && b.plan.total >= 2) return planEstimate(b.plan, b, now, expect)
69
70 // No plan: a curve over the expected length. At the expected length the bar
71 // shows two thirds; it slows as the turn runs long and stays under 100
72 // until the turn ends.
73 const worked = workedMs(b, now)
74 const pct = 95 * (1 - Math.exp((-1.2 * worked) / expect))
75 // A turn past its expected length is likely a long one: what is left grows with it.
76 const etaMs = Math.max(expect - worked, 0.5 * worked, 5 * SEC)
77 return { pct: clamp(pct, 1, 95), etaMs, basis: 'pace' }
78}
79
80function planEstimate(plan: Plan, b: Beat, now: number, expect: number): Estimate {
81 const total = plan.total
82 const done = clamp(plan.done, 0, total)
83 const since = b.planAt || b.startedAt
84 // The pace per task so far; before the first task is done, the expected turn
85 // split across the plan.
86 const spent = Math.max(0, plan.changedAt - since)
87 const per = done >= 1 && spent > 0 ? Math.max(spent / done, 5 * SEC) : Math.max(expect / total, 20 * SEC)
88 // A task in progress creeps toward done as its expected time passes.
89 const active = clamp(plan.active, 0, total - done)
90 const creep = Math.min(0.9, 1 - Math.exp(-Math.max(0, now - plan.changedAt) / per))
91 const units = done + active * creep
92 const pct = clamp((100 * units) / total, 1, 99)
93 return { pct, etaMs: Math.max(per * Math.max(total - units, 0.1), 5 * SEC), basis: 'plan' }
94}
95
96/**
97 * The expected length and time worked that put a plan-less turn at `pct` with
98 * about `etaMs` left: the pace curve run backwards, for `/pulse demo`.
99 */
100export function demoTiming(pct: number, etaMs: number): { expectMs: number; workedMs: number } {
101 const x = -Math.log(1 - clamp(pct, 1, 94) / 95) / 1.2
102 // Up to two thirds of the expected length what is left is the rest of it;
103 // past that, half the time worked.
104 const expectMs = x <= 2 / 3 ? etaMs / (1 - x) : etaMs / (0.5 * x)
105 return { expectMs: Math.round(expectMs), workedMs: Math.round(x * expectMs) }
106}
107
108// ---------------------------------------------------------------------------
109// Plans
110
111type PlanCounts = { done: number; active: number; total: number }
112
113/** A plan with `changedAt` kept when the counts did not move. */
114export function withChange(prev: Plan | null, next: PlanCounts, source: string, now: number): Plan | null {
115 if (next.total < 1) return null
116 const isSame = prev !== null && prev.done === next.done && prev.active === next.active && prev.total === next.total
117 return { ...next, source, changedAt: isSame ? prev.changedAt : now }
118}
119
120/** TodoWrite's list: its counts and the item in progress. */
121export function todosPlan(todos: unknown): { counts: PlanCounts; activity: string } | null {
122 if (!Array.isArray(todos) || todos.length === 0) return null
123 let done = 0
124 let active = 0
125 let activity = ''
126 for (const t of todos) {
127 const item = (t ?? {}) as { status?: unknown; activeForm?: unknown; content?: unknown }
128 if (item.status === 'completed') done += 1
129 if (item.status === 'in_progress') {
130 active += 1
131 if (!activity) activity = clip(oneLine(str(item.activeForm) || str(item.content)), 60)
132 }
133 }
134 return { counts: { done, active, total: todos.length }, activity }
135}
136
137export type TaskEntry = { status: string; label: string }
138
139/** The Task tools' list (TaskCreate, TaskUpdate), kept by the session as it sees the calls. */
140export function tasksPlan(tasks: ReadonlyMap<string, TaskEntry>): { counts: PlanCounts; activity: string } | null {
141 const live = [...tasks.values()].filter(t => t.status !== 'deleted')
142 if (live.length === 0) return null
143 const active = live.filter(t => t.status === 'in_progress')
144 return {
145 counts: { done: live.filter(t => t.status === 'completed').length, active: active.length, total: live.length },
146 activity: active[0]?.label ?? '',
147 }
148}
149
150type GoalTask = { status?: unknown; size?: unknown; replaced?: unknown }
151const SIZE: Record<string, number> = { S: 1, M: 2, L: 3 }
152
153/** Goal Meter's plan file for this session, weighted by task size as Goal Meter weighs it. */
154export function goalPlan(raw: unknown): PlanCounts | null {
155 const g = (raw ?? {}) as { status?: unknown; tasks?: unknown }
156 if (g.status !== 'running' || !Array.isArray(g.tasks)) return null
157 const live = (g.tasks as GoalTask[]).filter(t => t && !t.replaced && t.status !== 'dropped')
158 if (live.length === 0) return null
159 const w = (t: GoalTask): number => SIZE[String(t.size ?? 'M').toUpperCase()] ?? 2
160 return {
161 done: live.filter(t => t.status === 'done').reduce((a, t) => a + w(t), 0),
162 active: live.filter(t => t.status === 'active').reduce((a, t) => a + w(t), 0),
163 total: live.reduce((a, t) => a + w(t), 0),
164 }
165}
166
167/** savvy-progress's `progress` and `step` calls. */
168export function savvyPlan(input: Record<string, unknown>, prev: Plan | null): PlanCounts | null {
169 const total = Math.round(finite(input.total, prev?.total ?? 0))
170 if (total < 1) return null
171 if (input.finished === true) return { done: total, active: 0, total }
172 const done = clamp(Math.round(finite(input.done, prev?.done ?? 0)), 0, total)
173 return { done, active: done < total ? 1 : 0, total }
174}
175
176// ---------------------------------------------------------------------------
177// What a session is doing, from its tool calls
178
179const str = (v: unknown): string => (typeof v === 'string' ? v : '')
180const oneLine = (s: string): string => s.replace(/\s+/g, ' ').trim()
181
182export function clip(s: string, max: number): string {
183 const chars = [...s]
184 return chars.length > max ? chars.slice(0, Math.max(1, max - 1)).join('') + '…' : s
185}
186
187export function basename(path: string): string {
188 return path.split(/[\\/]/).filter(Boolean).pop() ?? path
189}
190
191export function activityOf(tool: string, input: Record<string, unknown>): string {
192 switch (tool) {
193 case 'Edit':
194 case 'MultiEdit':
195 case 'Write':
196 return `Editing ${basename(str(input.file_path))}`
197 case 'NotebookEdit':
198 return `Editing ${basename(str(input.notebook_path))}`
199 case 'Read':
200 return `Reading ${basename(str(input.file_path))}`
201 case 'Bash': {
202 const what = oneLine(str(input.description))
203 if (what) return clip(what, 60)
204 const head = oneLine(str(input.command)).split(' ').slice(0, 3).join(' ')
205 return clip(`Running ${head}`, 60)
206 }
207 case 'Grep':
208 case 'Glob':
209 case 'ToolSearch':
210 return 'Searching'
211 case 'WebFetch':
212 case 'WebSearch':
213 return 'Researching the web'
214 case 'Agent':
215 case 'Task': {
216 const what = oneLine(str(input.description))
217 return what ? clip(`Delegating: ${what}`, 60) : 'Running a subagent'
218 }
219 case 'AskUserQuestion':
220 return 'Waiting for your answer'
221 case 'ExitPlanMode':
222 return 'Waiting for plan approval'
223 case 'Skill':
224 return str(input.skill) ? clip(`Using skill ${str(input.skill)}`, 60) : 'Using a skill'
225 case 'TodoWrite':
226 case 'TaskCreate':
227 case 'TaskUpdate':
228 case 'TaskGet':
229 case 'TaskList':
230 return ''
231 }
232 if (tool.startsWith('mcp__')) {
233 const name = tool.split('__').slice(2).join('__') || tool.slice(5)
234 return clip(`Using ${name.replace(/_/g, ' ')}`, 60)
235 }
236 return clip(`Using ${tool}`, 60)
237}
238
239/**
240 * What a waiting session needs from you, in two or three words: the tool it
241 * asks to run, the question it asks, the plan it wants approved.
242 */
243export function permissionNeed(tool: string): string {
244 const name: Record<string, string> = {
245 Bash: 'bash',
246 Edit: 'edit',
247 MultiEdit: 'edit',
248 Write: 'write',
249 NotebookEdit: 'notebook edit',
250 WebFetch: 'web fetch',
251 WebSearch: 'web search',
252 Read: 'read',
253 Agent: 'subagent',
254 Task: 'subagent',
255 }
256 if (name[tool] !== undefined) return `allow ${name[tool]}`
257 if (tool.startsWith('mcp__')) return clip(`allow ${(tool.split('__').slice(2).join(' ') || tool.slice(5)).replace(/_/g, ' ')}`, 30)
258 return tool ? clip(`allow ${tool.toLowerCase()}`, 30) : 'allow tool'
259}
260
261export function askNeed(tool: string, input: Record<string, unknown>): string {
262 if (tool === 'ExitPlanMode') return 'approve plan'
263 const first = Array.isArray(input.questions) ? (input.questions[0] as { header?: unknown } | undefined) : undefined
264 const header = typeof first?.header === 'string' ? oneLine(first.header) : ''
265 return header ? clip(`pick ${header.toLowerCase()}`, 30) : 'answer question'
266}
267
268/** A board title from a prompt: its first line, one space between words. */
269export function titleFromPrompt(text: string): string {
270 const line = text.split(/\r?\n/).map(l => l.trim()).find(Boolean) ?? ''
271 if (line.startsWith('<')) return ''
272 return clip(oneLine(line), 80)
273}
274
275// ---------------------------------------------------------------------------
276// Reading another session's heartbeat safely
277
278const ID = /^[A-Za-z0-9-]{8,64}$/
279export const HOST_ID = /^local_[A-Za-z0-9-]{1,64}$/
280
281/** A heartbeat read from disk, checked field by field; null when it is not one. */
282export function asBeat(raw: unknown): Beat | null {
283 if (raw === null || typeof raw !== 'object') return null
284 const r = raw as Record<string, unknown>
285 if (r.v !== 1 || typeof r.id !== 'string' || !ID.test(r.id)) return null
286 const state = STATES.includes(r.state as PulseState) ? (r.state as PulseState) : 'idle'
287 const p = r.plan as Record<string, unknown> | null | undefined
288 const plan: Plan | null =
289 p && typeof p === 'object' && finite(p.total) >= 1
290 ? {
291 done: Math.max(0, finite(p.done)),
292 active: Math.max(0, finite(p.active)),
293 total: finite(p.total),
294 source: clip(str(p.source), 20),
295 changedAt: finite(p.changedAt),
296 }
297 : null
298 return {
299 v: 1,
300 id: r.id,
301 hostId: HOST_ID.test(str(r.hostId)) ? str(r.hostId) : '',
302 surface: clip(str(r.surface), 16),
303 title: clip(oneLine(str(r.title)), 120),
304 project: clip(str(r.project), 80),
305 cwd: str(r.cwd).slice(0, 1024),
306 state,
307 bornAt: finite(r.bornAt),
308 startedAt: finite(r.startedAt),
309 endedAt: finite(r.endedAt),
310 waitMs: Math.max(0, finite(r.waitMs)),
311 waitingSince: Math.max(0, finite(r.waitingSince)),
312 tools: Math.max(0, finite(r.tools)),
313 activity: clip(oneLine(str(r.activity)), 80),
314 plan,
315 planAt: finite(r.planAt),
316 expectMs: finite(r.expectMs, DEFAULT_TURN_MS),
317 turns: Math.max(0, finite(r.turns)),
318 lastMs: Math.max(0, finite(r.lastMs)),
319 stopPct: clamp(finite(r.stopPct), 0, 100),
320 agents: Math.max(0, finite(r.agents)),
321 need: clip(oneLine(str(r.need)), 32),
322 ended: r.ended === true,
323 updatedAt: finite(r.updatedAt),
324 }
325}
326
327// ---------------------------------------------------------------------------
328// Text and color
329
330export function durText(ms: number): string {
331 if (ms < MIN) return `${Math.max(1, Math.round(ms / SEC))}s`
332 const m = Math.round(ms / MIN)
333 if (m < 60) return `${m}m`
334 const h = Math.floor(m / 60)
335 const rest = m % 60
336 return rest ? `${h}h ${rest}m` : `${h}h`
337}
338
339/** The time-left label, `~30m` as the board shows it. */
340export function etaText(ms: number | null): string {
341 if (ms === null || !Number.isFinite(ms)) return ''
342 if (ms < MIN) return '<1m'
343 return `~${durText(ms)}`
344}
345
346export function agoText(ms: number): string {
347 return ms < MIN ? 'just now' : `${durText(ms)} ago`
348}
349
350// Muted throughout: no state color is far from grey, so the band reads calm.
351export const COLOR = {
352 done: '#4f9a72',
353 waiting: '#6f9fd6',
354 error: '#cf6a62',
355 stopped: '#8e8e8e',
356 idle: '#767676',
357 track: '#3a3a3a',
358} as const
359
360function hex(n: number): string {
361 return Math.round(clamp(n, 0, 1) * 255)
362 .toString(16)
363 .padStart(2, '0')
364}
365
366export function hsl(h: number, s: number, l: number): string {
367 const k = (n: number): number => (n + h / 30) % 12
368 const a = s * Math.min(l, 1 - l)
369 const f = (n: number): number => l - a * Math.max(-1, Math.min(k(n) - 3, 9 - k(n), 1))
370 return `#${hex(f(0))}${hex(f(8))}${hex(f(4))}`
371}
372
373/** Orange at the start, warming to yellow-green as the bar fills. */
374/**
375 * Light to deep as the bar fills: a pale straw yellow at the start, a soft
376 * lime midway, a deep sage near the end, and the done green past it. Hue,
377 * saturation and lightness move together, saturation kept low throughout.
378 */
379export function workingColor(pct: number): string {
380 const t = clamp(pct, 0, 99) / 99
381 return hsl(52 + t * 88, 0.58 - t * 0.2, 0.76 - t * 0.3)
382}
383
384export function colorOf(state: PulseState, pct: number): string {
385 switch (state) {
386 case 'working':
387 return workingColor(pct)
388 case 'waiting':
389 return COLOR.waiting
390 case 'done':
391 return COLOR.done
392 case 'error':
393 return COLOR.error
394 case 'stopped':
395 return COLOR.stopped
396 default:
397 return COLOR.idle
398 }
399}
400
401export function iconOf(state: PulseState): string {
402 switch (state) {
403 case 'done':
404 return '✓'
405 case 'error':
406 return '✗'
407 case 'stopped':
408 return '■'
409 case 'idle':
410 return '○'
411 default:
412 return '●'
413 }
414}
415
416/** Cells a code point takes on a terminal: CJK and emoji take two. */
417export function charCells(cp: number): number {
418 if (cp < 32 || (cp >= 0x7f && cp < 0xa0)) return 0
419 if ((cp >= 0x300 && cp <= 0x36f) || cp === 0x200d || (cp >= 0xfe00 && cp <= 0xfe0f)) return 0
420 const isWide =
421 (cp >= 0x1100 && cp <= 0x115f) ||
422 (cp >= 0x2e80 && cp <= 0x303e) ||
423 (cp >= 0x3041 && cp <= 0x33ff) ||
424 (cp >= 0x3400 && cp <= 0x4dbf) ||
425 (cp >= 0x4e00 && cp <= 0x9fff) ||
426 (cp >= 0xa000 && cp <= 0xa4cf) ||
427 (cp >= 0xac00 && cp <= 0xd7a3) ||
428 (cp >= 0xf900 && cp <= 0xfaff) ||
429 (cp >= 0xfe30 && cp <= 0xfe4f) ||
430 (cp >= 0xff00 && cp <= 0xff60) ||
431 (cp >= 0xffe0 && cp <= 0xffe6) ||
432 (cp >= 0x1f300 && cp <= 0x1f64f) ||
433 (cp >= 0x1f900 && cp <= 0x1f9ff) ||
434 (cp >= 0x20000 && cp <= 0x3fffd)
435 return isWide ? 2 : 1
436}
437
438export function cells(s: string): number {
439 let n = 0
440 for (const ch of s) n += charCells(ch.codePointAt(0) ?? 0)
441 return n
442}
443
444/** Cut to `width` cells with an ellipsis, then pad with spaces to exactly `width`. */
445export function fit(s: string, width: number): string {
446 if (width <= 0) return ''
447 if (cells(s) <= width) return s + ' '.repeat(width - cells(s))
448 let out = ''
449 let used = 0
450 for (const ch of s) {
451 const w = charCells(ch.codePointAt(0) ?? 0)
452 if (used + w > width - 1) break
453 out += ch
454 used += w
455 }
456 return out + '…' + ' '.repeat(Math.max(0, width - used - 1))
457}
458
459/** A terminal bar: the filled part and the track, `width` cells in all. */
460export function barCells(pct: number, width: number): { fill: string; rest: string } {
461 const raw = Math.round((clamp(pct, 0, 100) / 100) * width)
462 const n = pct > 0 ? clamp(raw, 1, width) : 0
463 return { fill: '━'.repeat(n), rest: '━'.repeat(Math.max(0, width - n)) }
464}
465
466const FONT = "-apple-system, BlinkMacSystemFont, 'Segoe UI', Inter, Roboto, sans-serif"
467
468const xml = (s: string): string =>
469 s.replace(/[<>&'"]/g, c => ({ '<': '<', '>': '>', '&': '&', "'": ''', '"': '"' })[c] ?? c)
470
471export const SVG_HEIGHT = 22
472
473// Rolling numbers: each changed digit is a strip of 0-9 behind a window that
474// rolls from the old digit to the new one, odometer style; digits that kept
475// their value stand still. SMIL, so it plays inside the SVG image.
476//
477// With the app's own glyphs (DigitFont, extracted from the Claude app's UI
478// font by scripts/digits-from-app-font.py) the digits are drawn as outlines at
479// the app's text size, so they match the band's text exactly; without them,
480// as text in the system font.
481
482export type DigitFont = {
483 unitsPerEm: number
484 capHeight: number
485 glyphs: Record<string, { advance: number; path: string }>
486}
487
488/** The extracted glyph file, checked; null when it is not one. */
489export function asDigitFont(raw: unknown): DigitFont | null {
490 const f = raw as { unitsPerEm?: unknown; capHeight?: unknown; glyphs?: Record<string, { advance?: unknown; path?: unknown }> } | null
491 if (f === null || typeof f !== 'object' || typeof f.unitsPerEm !== 'number' || typeof f.capHeight !== 'number' || f.glyphs === undefined) return null
492 const glyphs: DigitFont['glyphs'] = {}
493 const isGlyph = (g: { advance?: unknown; path?: unknown } | undefined): g is { advance: number; path: string } =>
494 g !== undefined && typeof g.advance === 'number' && typeof g.path === 'string' && /^[MLHVQCZz0-9 .,-]*$/.test(g.path)
495 // The digits and % are required; letters (for the state chips) are taken when present.
496 for (const ch of '0123456789%') {
497 const g = f.glyphs[ch]
498 if (!isGlyph(g)) return null
499 glyphs[ch] = { advance: g.advance, path: g.path }
500 }
501 for (const [ch, g] of Object.entries(f.glyphs)) {
502 if ([...ch].length === 1 && glyphs[ch] === undefined && isGlyph(g)) glyphs[ch] = { advance: g.advance, path: g.path }
503 }
504 return { unitsPerEm: f.unitsPerEm, capHeight: f.capHeight, glyphs }
505}
506
507/**
508 * A state chip styled as the app's hotkey keycap (the handoff button's H): a
509 * rounded rectangle a shade off the background with a thin border, no color,
510 * sized to the word
511 * and to the row (18px tall in a 22px row); grey as the band's dim text, in
512 * either theme. Drawn in the app's own glyphs when every letter is there, else
513 * in the system font. (`color` is kept for a coloured variant; unused.)
514 */
515export function chipSvg(word: string, color: string, font: DigitFont | null): { source: string; width: number; height: number } {
516 const H = 18
517 const PAD = 7
518 const size = 12
519 void color
520 const hasGlyphs = font !== null && [...word].every(ch => ch === ' ' || font.glyphs[ch] !== undefined)
521 let inner: string
522 let textW: number
523 if (hasGlyphs && font !== null) {
524 const k = size / font.unitsPerEm
525 const baseline = H / 2 + (font.capHeight * k) / 2
526 let x = PAD
527 const uses: string[] = []
528 for (const ch of word) {
529 const g = font.glyphs[ch]
530 if (g !== undefined && g.path) uses.push(`<path d="${g.path}" transform="translate(${x.toFixed(2)} ${baseline.toFixed(2)}) scale(${k} ${-k})"/>`)
531 x += (g?.advance ?? font.unitsPerEm * 0.3) * k
532 }
533 textW = x - PAD
534 inner = `<g class="c">${uses.join('')}</g>`
535 } else {
536 textW = cells(word) * size * 0.56
537 inner = `<text class="c" x="${PAD}" y="${H / 2 + 4}" font-family="${FONT}" font-size="${size}">${xml(word)}</text>`
538 }
539 const W = Math.ceil(textW + PAD * 2)
540 const source = `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
541<style>.c{fill:#7f7d78}.b{stroke:#d3d1cc;fill:#f3f2ef}
542@media (prefers-color-scheme: dark){.c{fill:#9a9893}.b{stroke:#4a4a48;fill:#272727}}</style>
543<rect class="b" x="0.5" y="0.5" width="${W - 1}" height="${H - 1}" rx="4"/>
544${inner}
545</svg>`
546 return { source, width: W, height: H }
547}
548
549/** The app's text size in the band, measured against its own font. */
550export const APP_TEXT_PX = 13
551const DIGIT_W = 8
552const ROW_H = 18
553const ROLL = 'dur="0.6s" fill="freeze" calcMode="spline" keyTimes="0;1" keySplines="0.2 0.8 0.2 1"'
554
555export function rollingNumber(text: string, prev: string, right: number, H: number, font: DigitFont | null = null): string {
556 const m = /^(\d+)(.*)$/.exec(text)
557 if (font !== null && m !== null && [...text].every(ch => font.glyphs[ch] !== undefined)) return rollingGlyphs(text, prev, right, H, font)
558 const baseline = H / 2 + 5
559 const plain = (x: number, ch: string, anchor = 'middle'): string =>
560 `<text class="t" x="${x}" y="${baseline}" text-anchor="${anchor}" font-family="${FONT}" font-size="13">${xml(ch)}</text>`
561 if (m === null) return text ? plain(right, text, 'end') : ''
562 const digits = m[1] ?? ''
563 const suffix = m[2] ?? ''
564 const suffixW = suffix ? 10 : 0
565 const was = (/^(\d+)/.exec(prev)?.[1] ?? '').padStart(digits.length, ' ').slice(-digits.length)
566 const x0 = right - suffixW - digits.length * DIGIT_W
567 const top = H / 2 - ROW_H / 2
568 const parts: string[] = [`<defs><clipPath id="win"><rect x="${x0 - 1}" y="${top}" width="${digits.length * DIGIT_W + 2}" height="${ROW_H}"/></clipPath></defs>`]
569 const rolls: string[] = []
570 ;[...digits].forEach((ch, i) => {
571 const cx = x0 + i * DIGIT_W + DIGIT_W / 2
572 const from = was[i] ?? ' '
573 if (prev === '' || from === ch) {
574 parts.push(plain(cx, ch))
575 return
576 }
577 const strip = Array.from({ length: 10 }, (_, d) =>
578 `<text class="t" x="${cx}" y="${baseline + d * ROW_H}" text-anchor="middle" font-family="${FONT}" font-size="13">${d}</text>`).join('')
579 const start = from === ' ' ? ROW_H : -Number(from) * ROW_H
580 const end = -Number(ch) * ROW_H
581 rolls.push(`<g transform="translate(0 ${end})"><animateTransform attributeName="transform" type="translate" from="0 ${start}" to="0 ${end}" ${ROLL}/>${strip}</g>`)
582 })
583 if (rolls.length) parts.push(`<g clip-path="url(#win)">${rolls.join('')}</g>`)
584 if (suffix) parts.push(plain(right, suffix, 'end'))
585 return parts.join('')
586}
587
588// The same roll with the app's glyph outlines: each glyph defined once, used
589// by reference, scaled to the app's text size and centred on the bar's line.
590function rollingGlyphs(text: string, prev: string, right: number, H: number, font: DigitFont): string {
591 const k = APP_TEXT_PX / font.unitsPerEm
592 const baseline = H / 2 + (font.capHeight * k) / 2
593 const advance = (ch: string): number => (font.glyphs[ch]?.advance ?? 0) * k
594 const m = /^(\d+)(.*)$/.exec(text)
595 const digits = m?.[1] ?? ''
596 const suffix = m?.[2] ?? ''
597 const was = (/^(\d+)/.exec(prev)?.[1] ?? '').padStart(digits.length, ' ').slice(-digits.length)
598 const isRolling = prev !== '' && [...digits].some((ch, i) => (was[i] ?? ' ') !== ch)
599 const used = new Set(isRolling ? '0123456789' + suffix : text)
600 const defs = [...used].map(ch => `<path id="g${ch === '%' ? 'p' : ch}" d="${font.glyphs[ch]?.path ?? ''}"/>`).join('')
601 const use = (ch: string, x: number, y: number): string =>
602 `<use href="#g${ch === '%' ? 'p' : ch}" transform="translate(${x.toFixed(2)} ${y.toFixed(2)}) scale(${k} ${-k})"/>`
603 const total = [...text].reduce((a, ch) => a + advance(ch), 0)
604 let x = right - total
605 const top = H / 2 - ROW_H / 2
606 const parts: string[] = []
607 const rolls: string[] = []
608 for (const [i, ch] of [...digits].entries()) {
609 const w = advance(ch)
610 const from = was[i] ?? ' '
611 if (prev === '' || from === ch) {
612 parts.push(use(ch, x, baseline))
613 } else {
614 // A strip of 0-9 down the slot, each glyph centred in it.
615 const strip = Array.from({ length: 10 }, (_, d) => use(String(d), x + (w - advance(String(d))) / 2, baseline + d * ROW_H)).join('')
616 const start = from === ' ' ? ROW_H : -Number(from) * ROW_H
617 const end = -Number(ch) * ROW_H
618 rolls.push(`<g transform="translate(0 ${end})"><animateTransform attributeName="transform" type="translate" from="0 ${start}" to="0 ${end}" ${ROLL}/>${strip}</g>`)
619 }
620 x += w
621 }
622 for (const ch of suffix) {
623 parts.push(use(ch, x, baseline))
624 x += advance(ch)
625 }
626 const clip = rolls.length ? `<clipPath id="win"><rect x="${right - total - 1}" y="${top}" width="${total + 2}" height="${ROW_H}"/></clipPath>` : ''
627 return `<defs>${defs}${clip}</defs><g class="d">${parts.join('')}${rolls.length ? `<g clip-path="url(#win)">${rolls.join('')}</g>` : ''}</g>`
628}
629
630/**
631 * A desktop bar: a rounded track, the fill, the percent and the time left dim,
632 * as the board's mock-up draws them. Light and dark theme alike. Given what
633 * the last drawing showed (`prevPct`, `prevPctText`), the fill grows from its
634 * old length and the changed digits roll to their new values.
635 */
636export function barSvg(o: { pct: number; color: string; width: number; pctText: string; prevPct?: number; prevPctText?: string; tail: string; tailColor: string; isLive: boolean; isBarOnly?: boolean; font?: DigitFont | null; pctW?: number }): string {
637 const W = Math.max(120, Math.round(o.width))
638 const H = SVG_HEIGHT
639 // Bar only: the percent and time left are drawn as text beside the picture.
640 // No tail given: the time left is drawn as text beside the picture.
641 const tailW = o.isBarOnly === true || o.tail === '' ? 0 : 64
642 const pctW = o.isBarOnly === true ? 0 : (o.pctW ?? 48)
643 const barW = o.isBarOnly === true ? W : Math.max(40, W - tailW - pctW - (tailW === 0 ? 8 : 12))
644 const barH = 8
645 const y = (H - barH) / 2
646 const widthOf = (pct: number): number => (pct > 0 ? Math.max(barH, Math.round((barW * clamp(pct, 0, 100)) / 100)) : 0)
647 const fillW = widthOf(o.pct)
648 const fromW = o.prevPct === undefined ? fillW : widthOf(o.prevPct)
649 const grow = fromW !== fillW ? `<animate attributeName="width" from="${fromW}" to="${fillW}" ${ROLL}/>` : ''
650 // An inline style, since the theme class would win over a fill attribute.
651 const tailStyle = /^#[0-9a-f]{6}$/i.test(o.tailColor) ? ` style="fill:${o.tailColor}"` : ' class="m"'
652 return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
653<style>.k{fill:#e2e2df}.t{fill:#1f1f1f}.m{fill:#8a8a86}.d{fill:#7f7d78}
654@media (prefers-color-scheme: dark){.k{fill:#3a3a3a}.t{fill:#f4f4f2}.m{fill:#8f8f8b}.d{fill:#898782}}
655.p{animation:pl 1.8s ease-in-out infinite}@keyframes pl{50%{opacity:.6}}
656@media (prefers-reduced-motion: reduce){.p{animation:none}}</style>
657<rect class="k" x="0" y="${y}" width="${barW}" height="${barH}" rx="${barH / 2}"/>
658${fillW ? `<rect${o.isLive ? ' class="p"' : ''} x="0" y="${y}" width="${fillW}" height="${barH}" rx="${barH / 2}" fill="${o.color}">${grow}</rect>` : ''}
659${o.isBarOnly === true ? '' : rollingNumber(o.pctText, o.prevPctText ?? '', barW + 8 + pctW, H, o.font ?? null)}
660${o.isBarOnly === true ? '' : `<text${tailStyle} x="${W}" y="${H / 2 + 5}" text-anchor="end" font-family="${FONT}" font-size="13" font-variant-numeric="tabular-nums">${xml(o.tail)}</text>`}
661</svg>`
662}
663types/index.d.ts 83 lines1// Session Pulse's state contract: what each session publishes about itself
2// (a Beat), what the board draws (a Row), and the person's display settings.
3
4/** Where a session stands. `stopped` is an interrupted turn. */
5export type PulseState = 'working' | 'waiting' | 'done' | 'stopped' | 'error' | 'idle'
6
7/** A task plan the session is following, from TodoWrite, the Task tools or another mod. */
8export type Plan = {
9 done: number
10 active: number
11 total: number
12 source: string
13 /** When `done` or `active` last changed. */
14 changedAt: number
15}
16
17/** One session's heartbeat, written to its own file under mods-data. */
18export type Beat = {
19 v: 1
20 /** The Claude Code session id (the transcript's name). */
21 id: string
22 /** The desktop app's `local_…` id, or '' outside the desktop app. */
23 hostId: string
24 surface: string
25 title: string
26 /** The working directory's last folder name. */
27 project: string
28 cwd: string
29 state: PulseState
30 /** When the board first saw the session: rows keep this order, so they never jump under the pointer. */
31 bornAt: number
32 /** When the current (or last) turn started. */
33 startedAt: number
34 /** When the last turn ended; 0 while working. */
35 endedAt: number
36 /** Time spent waiting on the person during this turn. */
37 waitMs: number
38 /** When the current wait began; 0 when not waiting. */
39 waitingSince: number
40 tools: number
41 activity: string
42 plan: Plan | null
43 /** When the current plan first appeared. */
44 planAt: number
45 /** How long this turn was expected to take when it started. */
46 expectMs: number
47 turns: number
48 lastMs: number
49 /** The bar's position when an interrupted or failed turn stopped. */
50 stopPct: number
51 /** Subagents still running after the turn ended. */
52 agents: number
53 /** What the session needs from you while waiting, in a few words ("allow bash"); '' otherwise. */
54 need?: string
55 ended?: boolean
56 updatedAt: number
57}
58
59/** A board row: a beat plus how this viewer sees it. */
60export type Row = Beat & {
61 isSelf: boolean
62 isHidden: boolean
63 isStale: boolean
64}
65
66export type Prefs = {
67 /** `full`: a row per session; `compact`: one summary line; `off`: no band. */
68 mode: 'full' | 'compact' | 'off'
69 /** Toast when another session finishes or needs you. */
70 alerts: boolean
71}
72
73declare module 'claude-code' {
74 interface PluginState {
75 'session-pulse': {
76 rows: Row[]
77 now: number
78 prefs: Prefs
79 isMasked: boolean
80 }
81 }
82}
83