SLOPSHOPPER

session-pulse

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)

newpanebandguardcommandprocess
v0.1.5MITupdated 2026-10-11meatball-cat/my-mods/session-pulse
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-pulse
│ ┃ Sessions ✕ › fix the failing auth test and add an audit log call │ ┃ 1 session 1 finished Band above prompt: │ ┃ ⏺ Read(src/auth.ts) │ ┃ Finished ⎿ Read 6 lines │ ┃ ✓ go-preview-session ━━━━━━━━━━ 100% just no ⏺ Update(src/auth.ts) │ ┃ Finished just now · took 42s · app · Termin… ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ Click a title to switch to that session · × ⎿ 3 pass, 1 fail │ ┃ hides a row until that session starts again │ ┃ · options: /pulse help ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /pulse │ │ ✓ go-preview-session ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 100% just now done × [ pane ] ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
✓ go-preview-session ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 100% just now done × [ pane ] ⟨Claude Code's own drawing⟩
Pane · Sessions
1 session 1 finished Band above prompt: one row per sess Finished ✓ go-preview-session ━━━━━━━━━━ 100% just now done × Finished just now · took 42s · app · Terminal Click a title to switch to that session · × hides a row until that session starts again · options: /pulse help
README

Session 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 ×
  • A row per live session: working, waiting on you, or finished within the last hour. The bar warms from orange to yellow-green as it fills, green with a check when done, blue when the session needs you (a permission or a question), grey when interrupted, red on an error.
  • A rough time left at the end of each row (~30m, <1m).
  • Click a row to switch to that session. A desktop session opens in the Claude app (claude://code/continue?session=local_…). A terminal session can't be focused from here, so its cd … && claude --resume … command is copied.
  • × hides a row until that session starts its next turn.
  • Alerts: a toast when another session finishes or needs you.
  • /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).

Commands

CommandDoes
/pulseOpen or close the board pane
/pulse compact · full · offOne summary line, a row per session, or no band
/pulse alerts on · offToasts when another session finishes or needs you
/pulse showBring hidden rows back
/pulse name <title>Rename this session on the board (no title: back to automatic)
/pulse demo · demo offFive made-up sessions for three minutes, to see every state

If another plugin owns /pulse, the command is /session-pulse.

How the percent is worked out

  • With a plan (TodoWrite, the Task tools, savvy-progress, or a goal-meter goal): finished tasks out of the plan, with the task in progress creeping forward at the pace the earlier tasks took. Time left is that pace times the work left.
  • Without a plan: how long turns usually take, learned from this session's last turns and every session's median (90 s before anything is learned). At the usual length the bar shows two thirds; it slows as a turn runs long and only reaches 100% when the turn ends. Time spent waiting on you doesn't count.

It's an estimate, marked with ~. It never moves backwards during a turn.

Where it keeps things

  • ~/.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.
  • The plugin's store: settings and recent turn lengths.
  • Titles come from the desktop app's own session title when there is one, else the first prompt.
  • While Recording Mode is on (~/.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.

Source 3 files
hooks/register.tsx 1416 lines
1// 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 lines
1// 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 => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', "'": '&apos;', '"': '&quot;' })[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}
663
types/index.d.ts 83 lines
1// 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