SLOPSHOPPER

token-optimizer

The Token Optimizer status bar on its own, for its tests. It ships inside the token-optimizer plugin.

newbandguardtoastpromptmodel
★ 2,518v?PolyForm-Noncommercial-1.0.0updated 2026-10-04alexgreensh/token-optimizer/desktop/token-optimizer-desktop
A shopper browsing a rack in a slop shop
README

Token Optimizer Desktop

A status bar for the Claude desktop app. It draws above your prompt and shows session quality, context fill, cache countdown, and your 5-hour and weekly limits, with Clawd acting out what the session is doing.

Install

It ships inside the Token Optimizer plugin (hooks/hooks.json names this folder's hooks/register.tsx under modules), so there is nothing extra to install. This folder keeps its own manifest only so its tests run on their own with claude plugin test.

Needs Claude Code 2.1.287 or newer and Anthropic's mods feature. Anthropic can switch mods off remotely; when off, nothing breaks and the bar just does not appear. The terminal keeps its existing status line. VS Code is not supported.

What you get

  • One sentence about the most urgent thing, with at most one button.
  • Five marks with hover cards: quality grade, context fill, cache countdown, 5-hour limit, weekly limit.
  • The arrow beside Clawd unfolds one more line: branch, session time, tool calls, compactions (when any), when the last checkpoint was saved (any kind, or a relevant one from an earlier session), and the tokens Token Optimizer saved you in the last 30 days, the same total the dashboard shows.
  • Buttons: Clean up (compacts with Token Optimizer's guidance), Start fresh (second click saves a checkpoint, clears, and hands it to your first message), Keep warm (manual one-click cache refresh, only while the cache is warm, never automatic, uses a small amount of usage).

The cache countdown is an estimate: 1 hour on Claude plans, 5 minutes on the API, measured from the session itself when possible.

Settings

TOKEN_OPTIMIZER_STATUS_BAR=0 hides the bar and TOKEN_OPTIMIZER_STATUS_BAR_ANIMATE=0 keeps Clawd still, set in the env block of ~/.claude/settings.json.

Full docs

See the Token Optimizer README and the docs site page "Claude desktop app".

Source 13 files
hooks/register.tsx 1479 lines
1// The Token Optimizer band above the prompt in the desktop app.
2//
3// This is the plugin's one hooks module and the only file that touches `$`.
4// Every decision lives in ../src (pure, Node-tested) and ./data.ts (the data
5// gatherer over a port). The engine allows `$` only into functions declared
6// at the top of this file, so each `$` helper below is one, and closures made
7// inside hooks only ever hand `$` on to them.
8//
9// State the drawing reads lives in `$.state` atoms (types/index.d.ts) so a hot
10// reload keeps it; the pose reducer also runs from a module copy so streaming
11// chunks do not write on every piece.
12import { atom, read, update } from 'claude-code'
13import type { Elements, EngineInterface, Register } from 'claude-code'
14
15import type { TokenOptimizerDesktopSession } from '../types/index.d.ts'
16import {
17  BUSY_TIMEOUT_MS,
18  CAPTURE_TIMEOUT_MS,
19  HANDOFF_KEY,
20  HANDOFF_TTL_MS,
21  RESUME_TIMEOUT_MS,
22  WARM_PROMPT,
23  attachesHandoff,
24  busyNow,
25  handoffFate,
26  initialUi,
27  isArmed,
28  lifetimeFromUsage,
29  noteNow,
30  prepareHandoff,
31  requestContextTokens,
32  stripCrossSessionPointer,
33  warmToast,
34  withBusy,
35  withNote,
36  type Busy,
37  type Handoff,
38  type HandoffPort,
39  type UiState,
40} from '../src/actions.ts'
41import { canKeepWarm, initialClock, reduceClock, view, type ClockEvent, type ClockState } from '../src/clock.ts'
42import { LIGHT, clawdSvg, type Gaze, type Palette } from '../src/clawd.ts'
43import type { Snapshot } from '../src/contracts.ts'
44import { ICONS, ICON_ALT, iconSvg, type IconName } from '../src/icons.ts'
45import { COMPACT_HEAVY, QUALITY_FLOOR, moodOf, sentence, type ActionId, type Run } from '../src/ladder.ts'
46import { cards, marks, row, type Card, type Mark, type MarkTone } from '../src/marks.ts'
47import { DEBOUNCE_MS, initialPose, reducePose, type PoseEvent, type PoseState } from '../src/pose.ts'
48import {
49  QUALITY_REFRESH_MS,
50  STATUS_AFTER_TURN_MS,
51  TICK_IDLE_MS,
52  TICK_WARNING_MS,
53  cleanId,
54  findTokenOptimizerRoot,
55  gather,
56  mergeStored,
57  readHome,
58  runMeasure,
59  type DataIo,
60  type GatherOptions,
61} from './data.ts'
62
63const sessionAtom = atom({ plugin: 'token-optimizer', key: 'session' } as const, null)
64const handoffAtom = atom({ plugin: 'token-optimizer', key: 'handoff' } as const, null)
65const clockAtom = atom({ plugin: 'token-optimizer', key: 'clock' } as const, null)
66const poseAtom = atom({ plugin: 'token-optimizer', key: 'pose' } as const, null)
67const engineCallAtom = atom({ plugin: 'token-optimizer', key: 'engineCall' } as const, null)
68const uiAtom = atom({ plugin: 'token-optimizer', key: 'ui' } as const, null)
69const frameAtom = atom({ plugin: 'token-optimizer', key: 'frame' } as const, 0)
70
71type Desktop = Elements['desktop']
72type Tones = { good: string; caution: string; bad: string; cold: string; ink: string; track: string; card: string; line: string }
73
74/** The design page's tone colours (light; each picture follows dark mode itself, see DARK_STYLE). */
75function tonesFor(p: Palette): Tones {
76  return { good: '#2f9e55', caution: '#c98a1b', bad: p.bad, cold: p.cold, ink: p.ink, track: '#d9d6cd', card: p.card, line: '#e2dfd6' }
77}
78
79// ---- module state: plain variables, rebuilt from the atoms after a reload ----
80
81let enabled = true
82let animate = true
83let active = false
84/** Whether the TOKEN_OPTIMIZER_STATUS_BAR switches have been read in this environment. */
85let switchesRead = false
86let timers: { cancel: () => void }[] = []
87let statusTimer: { cancel: () => void } | null = null
88let poseLive: PoseState | null = null
89let poseSig = ''
90let settleQueued = false
91/** The cache coldness last told to Clawd; null until the first tick tells him, so a cold pose kept across a reload never sticks. */
92let lastCold: boolean | null = null
93let frameText = ''
94/** The transcript Claude Code named for one session: never handed to another session's read. */
95let transcript: { sessionId: string; path: string } | null = null
96
97function noteTranscript(sessionId: unknown, path: unknown): void {
98  if (typeof path === 'string' && path && typeof sessionId === 'string' && sessionId) transcript = { sessionId: cleanId(sessionId), path }
99}
100
101function transcriptFor(sid: string): string | undefined {
102  return transcript !== null && sid !== '' && transcript.sessionId === sid ? transcript.path : undefined
103}
104let warmInFlight = false
105/**
106 * Which session the band is serving: bumped when one starts, is cleared or is
107 * swapped in. Work begun under one generation drops its result in the next.
108 */
109let sessionGen = 0
110/**
111 * A press is being claimed: a second press meanwhile is ignored (no double
112 * compaction). A claim older than the busy timeout is stale: a stuck press
113 * never holds the buttons past it.
114 */
115let claim: { at: number } | null = null
116/**
117 * The one engine call the buttons may have running: our `$.session.compact()`
118 * or our `$.command.run({ command: 'clear' })`. Set before the call's first
119 * await, cleared only when it settles, whatever the busy timeout says: a
120 * second compaction or clear never overlaps it. `engineCallAtom` records it
121 * too, so a reload mid-call still refuses (see runningCall).
122 */
123let engineCall: 'compact' | 'clear' | null = null
124/** When engineCall was claimed: a call that never settles stops blocking after HANDOFF_TTL_MS. */
125let engineCallAt = 0
126/** The live session a render last asked to re-read after finding another session's figures. */
127let resyncFor = ''
128/** Compactions that landed in this process, so Clean up can tell one happened during its /compact. */
129let compactionsLanded = 0
130
131const attempt = async <T,>(work: () => Promise<T>, fallback: T): Promise<T> => {
132  try {
133    return await work()
134  } catch {
135    return fallback
136  }
137}
138
139/** Session ids become file names; keep what Token Optimizer's sanitizer keeps (as data.ts does). */
140
141// ---- data ----
142
143/** The gatherer's port over `$` (data.ts never sees `$`). */
144function dataIo($: EngineInterface): DataIo {
145  return {
146    now: () => $.clock.now(),
147    sessionId: () => $.session.id(),
148    cwd: () => $.session.cwd(),
149    envHome: () => $.env.get('HOME'),
150    envUserProfile: () => $.env.get('USERPROFILE'),
151    envConfigDir: () => $.env.get('CLAUDE_CONFIG_DIR'),
152    usage: () => $.session.usage(),
153    list: path => $.fs.list(path),
154    stat: path => $.fs.stat(path),
155    read: async path => {
156      const text = await $.fs.read(path)
157      return typeof text === 'string' ? text : ''
158    },
159    run: (argv, init) => $.process.run(argv, init),
160    pluginRoot: () => $.plugin.root,
161    log: async text => {
162      await $.ui.log(text, { to: 'debug' })
163    },
164  }
165}
166
167/** Gather and store the session's figures, then let the cache clock learn from them. */
168async function refresh($: EngineInterface, options: GatherOptions = {}): Promise<void> {
169  const gen = sessionGen
170  const current = await attempt(() => read($, sessionAtom), null)
171  const liveSid = cleanId(options.sessionId ?? (await attempt(() => $.session.id(), '')))
172  const path = options.transcript ?? transcriptFor(liveSid)
173  const fresh = await gather(dataIo($), current, path ? { ...options, transcript: path } : options)
174  // Begun before a clear or a session change: its figures belong to the old session.
175  if (gen !== sessionGen) return
176  if (!options.reset && current !== null && fresh.sessionId !== '' && current.sessionId !== fresh.sessionId) {
177    // Another session (a new one, or a resume): nothing of the last one carries over.
178    sessionGen += 1
179    toolsPending = 0
180    await feedClock($, { type: 'clear' })
181    await setUi($, () => initialUi())
182    lastCold = null
183    await feedPose($, { type: 'session-start' })
184  }
185  const latest = await attempt(() => read($, sessionAtom), null)
186  const merged = mergeStored(latest, fresh, options.reset, options.savings === true)
187  // Nothing shown changed (gatheredAt always does): no write, so no redraw.
188  if (latest !== null && sameShown(latest, merged)) return void (await syncClock($, fresh))
189  await update($, sessionAtom, cur => mergeStored(cur, fresh, options.reset, options.savings === true))
190  await syncClock($, fresh)
191}
192
193function sameShown(a: TokenOptimizerDesktopSession, b: TokenOptimizerDesktopSession): boolean {
194  return JSON.stringify({ ...a, gatheredAt: 0 }) === JSON.stringify({ ...b, gatheredAt: 0 })
195}
196
197/**
198 * Runs work off the current event (the status command can take seconds). Where no
199 * clock can schedule it, it runs now rather than not at all.
200 */
201async function later($: EngineInterface, work: () => Promise<void>): Promise<void> {
202  // Work for the session as it is now: a clear or session change before it runs drops it.
203  const gen = sessionGen
204  const guarded = async () => {
205    if (gen === sessionGen) await work()
206  }
207  try {
208    $.clock.after(0, () => void attempt(guarded, undefined))
209  } catch {
210    await attempt(guarded, undefined)
211  }
212}
213
214/** A prompt is taking the hand-off: a second prompt in the same moment does not. */
215let takingHandoff = false
216
217/** Tool calls the band counted since the last write: written alongside a redraw that happens anyway. */
218let toolsPending = 0
219
220async function flushTools($: EngineInterface): Promise<void> {
221  if (toolsPending === 0) return
222  const n = toolsPending
223  toolsPending = 0
224  await attempt(() => update($, sessionAtom, cur => (cur ? { ...cur, toolCallsSeen: (cur.toolCallsSeen ?? 0) + n } : cur)), undefined)
225}
226
227/** The status command's anchor and measured lifetime feed the clock when they are newer than what it holds. */
228async function syncClock($: EngineInterface, s: TokenOptimizerDesktopSession): Promise<void> {
229  await attempt(
230    () =>
231      update($, clockAtom, cur => {
232        let c: ClockState = cur ?? initialClock()
233        if (s.cacheLifetime && c.lifetime !== s.cacheLifetime) c = reduceClock(c, { type: 'lifetime-measured', lifetime: s.cacheLifetime })
234        if (s.lastRequestEpoch !== null) {
235          const at = s.lastRequestEpoch * 1000
236          if (c.anchor === null || at > c.anchor) {
237            c = reduceClock(c, { type: 'request-done', at, lifetime: s.cacheLifetime, contextTokens: s.contextTokens ?? c.contextTokens ?? 0 })
238          }
239        }
240        if (c.contextTokens === null && s.contextTokens !== null) c = { ...c, contextTokens: s.contextTokens }
241        return c
242      }),
243    undefined,
244  )
245}
246
247async function feedClock($: EngineInterface, event: ClockEvent, now?: number): Promise<void> {
248  await attempt(() => update($, clockAtom, cur => reduceClock(cur ?? initialClock(), event, now)), undefined)
249}
250
251/**
252 * One pose event. The reducer runs on the module copy; the atom is written
253 * only when something the drawing or a reload needs has changed.
254 */
255async function feedPose($: EngineInterface, event: PoseEvent): Promise<void> {
256  try {
257    const now = await $.clock.now()
258    if (!poseLive) poseLive = (await read($, poseAtom)) ?? initialPose(now)
259    poseLive = reducePose(poseLive, event, now)
260    const s = poseLive
261    const sig = JSON.stringify([s.pose, s.since, s.working, s.sub, s.agents, s.permissions, s.questions, s.compacting, s.cold, s.until])
262    if (sig !== poseSig) {
263      poseSig = sig
264      await update($, poseAtom, () => s)
265    }
266    // A sub-pose waiting out its debounce settles on a tick of its own.
267    if (s.rawSub !== s.sub && !settleQueued) {
268      settleQueued = true
269      $.clock.after(DEBOUNCE_MS, () => {
270        settleQueued = false
271        void feedPose($, { type: 'tick', now: 0 })
272      })
273    }
274  } catch {
275    // A pose that cannot be stored never breaks the event it rode on.
276  }
277}
278
279/** Permission prompts and questions have no closing event of their own: close them on the next sign of life. */
280async function closeAsks($: EngineInterface): Promise<void> {
281  // After a reload the module copy is empty until the first pose event: read the stored one.
282  if (!poseLive) poseLive = await attempt(() => read($, poseAtom), null)
283  for (let i = poseLive?.permissions ?? 0; i > 0; i--) await feedPose($, { type: 'permission-closed' })
284  for (let i = poseLive?.questions ?? 0; i > 0; i--) await feedPose($, { type: 'question-closed' })
285}
286
287function planDefault(s: TokenOptimizerDesktopSession | null): 3600 | 300 {
288  // Rate limits mean a Claude plan: an hour of cache; the API keeps five minutes.
289  // A limit seen once this session keeps it a plan, even if usage briefly stops reporting one.
290  return s && (s.fiveHour || s.week || s.sawLimits) ? 3600 : 300
291}
292
293/**
294 * Keeps Clawd's transients and naps moving and redraws when what the band
295 * shows changes: by the second near a deadline or during a hold, otherwise
296 * every 5 s, with at most one redraw a minute while nothing moves.
297 */
298/** Last tick that did its full work: when nothing moves by the second, every 5 s is enough. */
299let fullTickAt = 0
300const QUIET_TICK_MS = 5000
301/** Something on screen changes by the second (the last cache minutes, a note, a pending press). */
302let nearDeadline = false
303
304async function tick($: EngineInterface): Promise<void> {
305  try {
306    const now = await $.clock.now()
307    // By the second only while a countdown is near its end, a transient pose holds, or a
308    // press is pending; otherwise every 5 s (each tick is several engine calls).
309    // A hold still running at the last full tick keeps ticking by the second until it ends.
310    const urgent = poseLive !== null && Object.values(poseLive.until).some(t => t > fullTickAt)
311    if (!urgent && !settleQueued && now - fullTickAt < QUIET_TICK_MS && !nearDeadline) return
312    fullTickAt = now
313    await feedPose($, { type: 'tick', now })
314    const clock = (await read($, clockAtom)) ?? initialClock()
315    const session = await read($, sessionAtom)
316    const ui = (await read($, uiAtom)) ?? initialUi()
317    const v = view(clock, now, planDefault(session))
318    const cold = v.state === 'cold'
319    if (cold !== lastCold) {
320      lastCold = cold
321      await feedPose($, { type: 'cache-cold-changed', cold })
322    }
323    // Only a change the band shows redraws it, at most once a minute: the desktop restarts
324    // Clawd's picture on every redraw, so a per-second countdown made him blink each second.
325    // One minute clock: the cache countdown's when it runs, else the wall clock's (for "27m ago").
326    const minute = v.secondsLeft != null ? `c${Math.ceil(v.secondsLeft / 60)}` : `w${Math.floor(now / TICK_IDLE_MS)}`
327    const text = [v.state, minute, busyNow(ui, now), noteNow(ui, now), isArmed(ui, now)].join('|')
328    nearDeadline = v.state === 'warning' || v.state === 'warming' || busyNow(ui, now) !== null || noteNow(ui, now) !== null || isArmed(ui, now)
329    if (text !== frameText) await flushTools($)
330    if (text !== frameText) {
331      frameText = text
332      await update($, frameAtom, n => (n ?? 0) + 1)
333    }
334  } catch {
335    // The next tick tries again.
336  }
337}
338
339function startCadence($: EngineInterface): void {
340  for (const t of timers) t.cancel()
341  timers = []
342  try {
343    timers.push($.clock.every(TICK_WARNING_MS, () => void tick($)))
344    timers.push($.clock.every(QUALITY_REFRESH_MS, () => void attempt(() => refresh($), undefined)))
345  } catch {
346    // No timers: the band still redraws on events.
347  }
348}
349
350/** Everything a desktop session needs once: a pending hand-off from disk, figures, the cadence. */
351async function start($: EngineInterface): Promise<void> {
352  sessionGen += 1
353  // A warm-up marked running with no fork in flight here was cut off by a reload.
354  const clock = await attempt(() => read($, clockAtom), null)
355  if (clock?.warming && !warmInFlight) await feedClock($, { type: 'warm-failed' })
356  const held = await heldHandoff($)
357  if (held) await handoffThatFits($, held)
358  await feedPose($, { type: 'session-start' })
359  // The quick reads now, so the band fills at once; the status command (seconds, at worst)
360  // runs off the start event, so the session never waits on it.
361  await attempt(() => refresh($), undefined)
362  await later($, () => refresh($, { savings: true }))
363  startCadence($)
364}
365
366function isHandoff(v: unknown): v is Handoff {
367  if (!v || typeof v !== 'object') return false
368  const h = v as Record<string, unknown>
369  return typeof h.fromSessionId === 'string' && typeof h.cwd === 'string' && typeof h.text === 'string' && h.text !== '' && typeof h.checkpointPath === 'string' && typeof h.createdAt === 'number'
370}
371
372/**
373 * The pending hand-off. `$.store` is the truth (it survives restarts and is
374 * deleted when one session takes it); the atom only mirrors it for drawing. A
375 * store that cannot be read holds nothing: no stale mirror is ever attached.
376 */
377async function heldHandoff($: EngineInterface): Promise<Handoff | null> {
378  let stored: unknown
379  try {
380    stored = await $.store.get(HANDOFF_KEY)
381  } catch {
382    return null
383  }
384  const held = isHandoff(stored) ? stored : null
385  if (stored != null && !held) await attempt(() => $.store.delete(HANDOFF_KEY), undefined)
386  const mirror = await attempt(() => read($, handoffAtom), null)
387  if (JSON.stringify(mirror) !== JSON.stringify(held)) await attempt(() => update($, handoffAtom, () => held), undefined)
388  return held
389}
390
391/** When the live session began (`$.clock.now()` ms), or null when the engine cannot say. */
392async function sessionStartedAt($: EngineInterface): Promise<number | null> {
393  const usage = await attempt(() => $.session.usage(), null)
394  return typeof usage?.startedAt === 'number' && Number.isFinite(usage.startedAt) ? usage.startedAt : null
395}
396
397/**
398 * The held hand-off when it joins this session: another session than
399 * the one that saved it, in its project, started since the save, within 10
400 * minutes of it. An expired one in this project is dropped with a one-line
401 * note; another project's is left alone. `as` names the session when the
402 * caller knows its id better than the engine does yet (a clear's own start event).
403 */
404async function handoffThatFits($: EngineInterface, h: Handoff, as?: { sessionId: string; startedAt: number | null }): Promise<Handoff | null> {
405  const sessionId = as?.sessionId ?? cleanId(await attempt(() => $.session.id(), ''))
406  const cwd = await attempt(() => $.session.cwd(), '')
407  const now = await $.clock.now()
408  const startedAt = as ? as.startedAt : await sessionStartedAt($)
409  const fate = handoffFate(h, { sessionId, cwd, startedAt, now })
410  if (fate === 'attach') return h
411  if (fate === 'skip') return null
412  await dropHandoff($)
413  const line = `Start fresh's saved hand-off was discarded: ${fate.drop}.`
414  await setUi($, u => withNote(u, line, now))
415  toast($, line)
416  return null
417}
418
419/** The refusal for a press while our compaction or clear still runs. */
420function stillRunning(kind: 'compact' | 'clear'): string {
421  return kind === 'compact' ? 'Still finishing the last clean-up.' : 'Still clearing.'
422}
423
424/**
425 * Our compaction or clear still running: this module's own, or one recorded
426 * before a reload that is under 10 minutes old (an older record is cleared).
427 */
428async function runningCall($: EngineInterface): Promise<'compact' | 'clear' | null> {
429  if (engineCall) {
430    const at = await attempt(() => $.clock.now(), null)
431    if (at === null || at - engineCallAt < HANDOFF_TTL_MS) return engineCall
432    // Never settled: the record's own expiry below decides, as after a reload.
433    engineCall = null
434  }
435  const held = await attempt(() => read($, engineCallAtom), null)
436  if (engineCall) return engineCall
437  if (!held) return null
438  // A clock that cannot be read keeps the record: refuse rather than overlap.
439  const now = await attempt(() => $.clock.now(), null)
440  if (now === null || now - held.startedAt < HANDOFF_TTL_MS) return held.kind
441  await attempt(() => update($, engineCallAtom, cur => (cur && cur.startedAt === held.startedAt && cur.kind === held.kind ? null : cur)), undefined)
442  return null
443}
444
445/**
446 * Claims the one engine call (the caller checked runningCall with no await
447 * since) and records it for a reload. False when it cannot be recorded: the
448 * call is not made.
449 */
450async function claimCall($: EngineInterface, kind: 'compact' | 'clear'): Promise<{ kind: 'compact' | 'clear'; startedAt: number } | null> {
451  // Never over a call already in flight, whatever the caller checked before.
452  if (engineCall !== null) return null
453  engineCall = kind
454  try {
455    const mine = { kind, startedAt: await $.clock.now() }
456    engineCallAt = mine.startedAt
457    await update($, engineCallAtom, () => mine)
458    return mine
459  } catch {
460    engineCall = null
461    return null
462  }
463}
464
465/** The call settled: release it here and in the record (only our own record). */
466async function releaseCall($: EngineInterface, mine: { kind: 'compact' | 'clear'; startedAt: number }): Promise<void> {
467  engineCall = null
468  await attempt(() => update($, engineCallAtom, cur => (cur && cur.kind === mine.kind && cur.startedAt === mine.startedAt ? null : cur)), undefined)
469}
470
471// ---- buttons ----
472
473function toast($: EngineInterface, text: string): void {
474  try {
475    $.ui.toast(text)
476  } catch {
477    // A toast that cannot show is not worth failing a press over.
478  }
479}
480
481async function setUi($: EngineInterface, fn: (ui: UiState) => UiState): Promise<void> {
482  await attempt(() => update($, uiAtom, cur => fn(cur ?? initialUi())), undefined)
483}
484
485async function disarm($: EngineInterface): Promise<void> {
486  const ui = await attempt(() => read($, uiAtom), null)
487  if (ui?.freshArmedAt != null) await setUi($, u => ({ ...u, freshArmedAt: null }))
488}
489
490const BUSY_WORDS: Record<Exclude<Busy, null>, string> = {
491  clean: 'Clean up',
492  'fresh-capture': 'Start fresh',
493  'fresh-clear': 'Start fresh',
494}
495
496/** A busy state ends by itself: a step label never outlives its work. */
497function armBusyTimeout($: EngineInterface, busy: Exclude<Busy, null>, since: number): void {
498  try {
499    $.clock.after(BUSY_TIMEOUT_MS, () => void expireBusy($, busy, since))
500  } catch {
501    // busyNow() still treats it as over after the timeout.
502  }
503}
504
505async function expireBusy($: EngineInterface, busy: Exclude<Busy, null>, since: number): Promise<void> {
506  const ui = await attempt(() => read($, uiAtom), null)
507  if (!ui || ui.busy !== busy || ui.busySince !== since) return
508  const now = await $.clock.now()
509  // The clear waits for the turn to end and can still land: the hand-off stays
510  // for the conversation it creates, within 10 minutes of the save.
511  const line = busy === 'fresh-clear' ? 'Start fresh clears when the current turn ends.' : `${BUSY_WORDS[busy]} timed out.`
512  await setUi($, u => withNote(withBusy(u, null, now), line, now))
513  toast($, line)
514}
515
516/** Deletes the held hand-off, store first; true only when the delete succeeded (the mirror follows it). */
517async function dropHandoff($: EngineInterface): Promise<boolean> {
518  try {
519    await $.store.delete(HANDOFF_KEY)
520  } catch {
521    return false
522  }
523  await attempt(() => update($, handoffAtom, () => null), undefined)
524  return true
525}
526
527async function isTurnRunning($: EngineInterface): Promise<boolean> {
528  const clock = await attempt(() => read($, clockAtom), null)
529  return Boolean(clock?.working || poseLive?.working)
530}
531
532/** Clean up: compaction with Token Optimizer's own PreCompact guidance, run outside the press. */
533async function cleanUp($: EngineInterface): Promise<void> {
534  const now = await $.clock.now()
535  const ui = (await attempt(() => read($, uiAtom), null)) ?? initialUi()
536  if (busyNow(ui, now) !== null) return
537  await disarm($)
538  const running = await runningCall($)
539  if (running) {
540    toast($, stillRunning(running))
541    return
542  }
543  if (await isTurnRunning($)) {
544    toast($, 'Clean up waits until the turn finishes.')
545    return
546  }
547  await setUi($, u => withBusy(u, 'clean', now))
548  armBusyTimeout($, 'clean', now)
549  $.clock.after(0, () => void attempt(() => runCompact($, now), undefined))
550}
551
552async function runCompact($: EngineInterface, since: number): Promise<void> {
553  // A turn that started since the press would be cut short.
554  if (await isTurnRunning($)) {
555    const ui = await attempt(() => read($, uiAtom), null)
556    if (!ui || ui.busy !== 'clean' || ui.busySince !== since) return
557    const now = await $.clock.now()
558    await setUi($, u => withBusy(u, null, now))
559    toast($, 'Clean up waits until the turn finishes.')
560    return
561  }
562  const running = await runningCall($)
563  if (running || engineCall) {
564    const ui = await attempt(() => read($, uiAtom), null)
565    if (!ui || ui.busy !== 'clean' || ui.busySince !== since) return
566    const now = await $.clock.now()
567    await setUi($, u => withBusy(u, null, now))
568    toast($, stillRunning(running ?? engineCall ?? 'compact'))
569    return
570  }
571  let skip: string | null = null
572  // Claimed with no await between the check and the claim; recorded before the call.
573  const mine = await claimCall($, 'compact')
574  if (!mine) {
575    skip = 'it could not be recorded'
576  } else {
577    try {
578      // The command, as if typed: $.session.compact() is refused in a headless
579      // session, and the desktop app runs its sessions headless.
580      const landedBefore = compactionsLanded
581      const answer = await $.command.run({ command: 'compact' })
582      // Claude Code answers a refused /compact ("Not enough messages to compact.")
583      // without throwing: no compaction landed, and its line says why.
584      const said = answer?.text?.trim().split('\n')[0] ?? ''
585      if (compactionsLanded === landedBefore && said !== '' && !/^compacted\b/i.test(said)) skip = said
586    } catch (error) {
587      skip = error instanceof Error && error.message ? error.message.split('\n')[0] ?? 'it failed' : 'it failed'
588    } finally {
589      await releaseCall($, mine)
590    }
591  }
592  const ui = await attempt(() => read($, uiAtom), null)
593  // Timed out meanwhile, or another step took over: nothing to report here.
594  if (!ui || ui.busy !== 'clean' || ui.busySince !== since) return
595  const now = await $.clock.now()
596  if (skip !== null) {
597    const line = `Clean up skipped: ${skip.replace(/\.$/, '')}.`
598    await setUi($, u => withNote(withBusy(u, null, now), line, now))
599    toast($, line)
600  } else {
601    await setUi($, u => withNote(withBusy(u, null, now), 'Cleaned up.', now))
602  }
603  $.clock.after(0, () => void attempt(() => refresh($), undefined))
604}
605
606/** Keep warm: guarded at press time, one fork, never on a timer. */
607async function keepWarm($: EngineInterface): Promise<void> {
608  const now = await $.clock.now()
609  await disarm($)
610  const clock = (await attempt(() => read($, clockAtom), null)) ?? initialClock()
611  if (warmInFlight || clock.warming) {
612    toast($, 'A warm-up is already running.')
613    return
614  }
615  if (!canKeepWarm(clock, now)) {
616    toast($, clock.working || (await isTurnRunning($)) ? 'Keep warm waits until the turn finishes.' : 'The cache is too close to dropping to keep it warm.')
617    return
618  }
619  warmInFlight = true
620  const gen = sessionGen
621  await feedClock($, { type: 'warm-start' }, now)
622  $.clock.after(0, () => void attempt(() => runWarm($, clock.contextTokens, gen), undefined))
623}
624
625type ForkReply = Awaited<ReturnType<EngineInterface['model']['fork']>>
626
627/** The warm-up fork, or null once `ms` pass without an answer; a late answer is then ignored. */
628function forkWithin($: EngineInterface, ms: number): Promise<ForkReply | null> {
629  return new Promise<ForkReply | null>((resolve, reject) => {
630    let timer: { cancel: () => void } | null = null
631    try {
632      timer = $.clock.after(ms, () => resolve(null))
633    } catch {
634      // No timer: the fork alone decides.
635    }
636    $.model.fork({ prompt: WARM_PROMPT }).then(
637      reply => {
638        timer?.cancel()
639        resolve(reply)
640      },
641      error => {
642        timer?.cancel()
643        reject(error)
644      },
645    )
646  })
647}
648
649async function runWarm($: EngineInterface, known: number | null, gen: number): Promise<void> {
650  try {
651    const session = await attempt(() => read($, sessionAtom), null)
652    // A recorded 0 is no size at all: fall back to the session's own.
653    const contextTokens = known || session?.contextTokens || 0
654    const reply = await forkWithin($, BUSY_TIMEOUT_MS)
655    // Cleared meanwhile: this warm-up says nothing about the new session.
656    if (gen !== sessionGen) return
657    const at = await $.clock.now()
658    if (reply === null) {
659      await feedClock($, { type: 'warm-failed' })
660      toast($, warmToast({ ok: false, reason: 'the request failed' }))
661    } else if (reply.isAnswered) {
662      await feedClock($, { type: 'warm-done', at, cacheReadTokens: reply.usage.cache_read_input_tokens, contextTokens })
663      const after = await attempt(() => read($, clockAtom), null)
664      const line = warmToast({
665        ok: true,
666        lapsed: Boolean(after?.lapsed),
667        contextTokens,
668        cacheRead: reply.usage.cache_read_input_tokens,
669        lifetimeS: view(after ?? initialClock(), at, planDefault(session)).lifetime,
670      })
671      toast($, line)
672      // Also in the sentence for a few seconds, where the eye already is.
673      await setUi($, u => withNote(u, line, at))
674    } else {
675      await feedClock($, { type: 'warm-failed' })
676      toast($, warmToast({ ok: false, reason: reply.reason }))
677    }
678  } catch {
679    if (gen !== sessionGen) return
680    await feedClock($, { type: 'warm-failed' })
681    toast($, warmToast({ ok: false, reason: 'the request failed' }))
682  } finally {
683    warmInFlight = false
684  }
685}
686
687/** Start fresh: first press arms, a second within 5 s runs it. */
688async function startFresh($: EngineInterface): Promise<void> {
689  const now = await $.clock.now()
690  const ui = (await attempt(() => read($, uiAtom), null)) ?? initialUi()
691  if (busyNow(ui, now) !== null) return
692  if (!isArmed(ui, now)) {
693    await setUi($, u => ({ ...u, freshArmedAt: now }))
694    return
695  }
696  const running = await runningCall($)
697  if (running) {
698    await disarm($)
699    toast($, stillRunning(running))
700    return
701  }
702  if (await isTurnRunning($)) {
703    await disarm($)
704    toast($, 'Start fresh waits until the turn finishes.')
705    return
706  }
707  const sid = cleanId(await attempt(() => $.session.id(), ''))
708  if (!sid) {
709    await disarm($)
710    toast($, 'Start fresh stopped: no session to save. Nothing was cleared.')
711    return
712  }
713  const gen = sessionGen
714  await setUi($, u => withBusy(u, 'fresh-capture', now))
715  armBusyTimeout($, 'fresh-capture', now)
716  $.clock.after(0, () => void attempt(() => runFresh($, sid, now, gen), undefined))
717}
718
719/** Still the session Start fresh was pressed in: a typed /clear meanwhile means stand down. */
720async function stillSession($: EngineInterface, sid: string, gen: number): Promise<boolean> {
721  return gen === sessionGen && cleanId(await attempt(() => $.session.id(), '')) === sid
722}
723
724async function runFresh($: EngineInterface, sid: string, since: number, gen: number): Promise<void> {
725  const io = dataIo($)
726  const stop = async (reason: string): Promise<void> => {
727    const ui = await attempt(() => read($, uiAtom), null)
728    if (!ui || ui.busySince !== since) return
729    const now = await $.clock.now()
730    const line = `Start fresh stopped: ${reason}. Nothing was cleared.`
731    await setUi($, u => withNote(withBusy(u, null, now), line, now))
732    toast($, line)
733  }
734
735  const root = await findTokenOptimizerRoot(io, await readHome(io))
736  if (!root) return stop('Token Optimizer was not found')
737  const port: HandoffPort = {
738    run: (args, stdin) =>
739      runMeasure(io, root, args, {
740        timeoutMs: args[0] === 'compact-capture' ? CAPTURE_TIMEOUT_MS : RESUME_TIMEOUT_MS,
741        ...(stdin === undefined ? {} : { stdin }),
742      }),
743    read: path => io.read(path),
744  }
745  const cwd = await attempt(() => $.session.cwd(), '')
746  // `now` is a placeholder: the hand-off is stamped once its save lands (below).
747  const result = await prepareHandoff(port, { sessionId: sid, transcriptPath: transcriptFor(cleanId(sid)) ?? null, cwd, now: since })
748  const standDown = async (): Promise<void> => {
749    const now = await $.clock.now()
750    await setUi($, u => (u.busySince === since ? withBusy(u, null, now) : u))
751  }
752  // The session changed under it (a typed /clear, a resume): clear nothing, and say so.
753  const changed = async (): Promise<void> => {
754    await standDown()
755    toast($, 'Start fresh stopped: the session changed.')
756  }
757  if (!(await stillSession($, sid, gen))) return changed()
758  const ui = await attempt(() => read($, uiAtom), null)
759  // Timed out meanwhile: the person was told; clear nothing.
760  if (!ui || ui.busy !== 'fresh-capture' || ui.busySince !== since) return
761  if (!result.ok) return stop(result.reason)
762  // Our compaction or clear still running: never overlap it.
763  const running = await runningCall($)
764  if (running) return stop(running === 'compact' ? 'the last clean-up is still running' : 'the last clear is still running')
765  // A turn started during the capture: a queued clear would wipe it.
766  if (await isTurnRunning($)) {
767    await standDown()
768    toast($, 'Start fresh waits until the turn finishes.')
769    return
770  }
771
772  // Saved, then stamped from the clock right after the save lands: the
773  // 10-minute window and "started since the save" both count from there.
774  let handoff: Handoff
775  try {
776    // First kept with a stamp in the far future, so no session can count as "started
777    // since the save" until the real stamp below lands.
778    await $.store.set(HANDOFF_KEY, { ...result.handoff, createdAt: Number.MAX_SAFE_INTEGER, pendingSince: since })
779  } catch {
780    return stop('the hand-off could not be kept on disk')
781  }
782  try {
783    handoff = { ...result.handoff, createdAt: await $.clock.now() }
784    await $.store.set(HANDOFF_KEY, handoff)
785  } catch {
786    await dropHandoff($)
787    return stop('the hand-off could not be kept on disk')
788  }
789  try {
790    await update($, handoffAtom, () => handoff)
791  } catch {
792    // The new session would never see it.
793    await dropHandoff($)
794    return stop('the hand-off could not be kept on disk')
795  }
796  if (!(await stillSession($, sid, gen))) {
797    await dropHandoff($)
798    return changed()
799  }
800  const now = await $.clock.now()
801  await setUi($, u => withBusy(u, 'fresh-clear', now))
802  armBusyTimeout($, 'fresh-clear', now)
803  $.clock.after(0, () => void attempt(() => runClear($, now, handoff, sid, gen), undefined))
804}
805
806async function runClear($: EngineInterface, since: number, handoff: Handoff, sid: string, gen: number): Promise<void> {
807  const ours = (h: Handoff | null) => h !== null && h.fromSessionId === handoff.fromSessionId && h.createdAt === handoff.createdAt
808  const fail = async (line: string): Promise<void> => {
809    const held = await attempt(() => read($, handoffAtom), null)
810    if (ours(held)) await dropHandoff($)
811    const now = await $.clock.now()
812    await setUi($, u => withNote(u.busy === 'fresh-clear' && u.busySince === since ? withBusy(u, null, now) : u, line, now))
813    toast($, line)
814  }
815  // The session changed while the clear waited its turn: clear nothing.
816  if (gen !== sessionGen || cleanId(await attempt(() => $.session.id(), '')) !== sid) {
817    const held = await attempt(() => read($, handoffAtom), null)
818    if (ours(held)) await dropHandoff($)
819    const now = await $.clock.now()
820    await setUi($, u => (u.busy === 'fresh-clear' && u.busySince === since ? withBusy(u, null, now) : u))
821    toast($, 'Start fresh stopped: the session changed.')
822    return
823  }
824  const running = await runningCall($)
825  if (running || engineCall) return fail(`Start fresh stopped: ${(running ?? engineCall) === 'compact' ? 'the last clean-up is still running' : 'the last clear is still running'}. Nothing was cleared.`)
826  // Claimed with no await between the check and the claim; recorded before the call.
827  const mine = await claimCall($, 'clear')
828  if (!mine) return fail('Start fresh could not clear. Your conversation is unchanged.')
829  let cleared = false
830  try {
831    await $.command.run({ command: 'clear' })
832    cleared = true
833  } catch {
834    // Reported below, once the call has settled.
835  } finally {
836    await releaseCall($, mine)
837  }
838  if (!cleared) return fail('Start fresh could not clear. Your conversation is unchanged.')
839  // The hand-off now waits for the first prompt of a conversation started since the save.
840  const now = await $.clock.now()
841  await setUi($, u => (u.busy === 'fresh-clear' && u.busySince === since ? withBusy(u, null, now) : u))
842}
843
844async function toggleDetails($: EngineInterface): Promise<void> {
845  await disarm($)
846  await flushTools($)
847  const current = await attempt(() => read($, sessionAtom), null)
848  if (!current) return
849  const opening = !current.sheetOpen
850  await update($, sessionAtom, cur => (cur ? { ...cur, sheetOpen: !cur.sheetOpen } : cur))
851  // Savings read when the row opens.
852  if (opening) $.clock.after(0, () => void attempt(() => refresh($, { savings: true }), undefined))
853}
854
855async function act($: EngineInterface, id: ActionId): Promise<void> {
856  // One press at a time: the claim is taken before the first await, so a second press
857  // arriving while this one reads the clock sees it (an unread time counts as now).
858  const prior = claim
859  if (prior !== null && prior.at === Number.POSITIVE_INFINITY) return
860  const mine = { at: Number.POSITIVE_INFINITY }
861  claim = mine
862  const now = await attempt(() => $.clock.now(), 0)
863  if (prior !== null && now - prior.at < BUSY_TIMEOUT_MS) {
864    if (claim === mine) claim = prior
865    return
866  }
867  mine.at = now
868  try {
869    if (id === 'clean' || id === 'clean-first') await cleanUp($)
870    else if (id === 'fresh') await startFresh($)
871    else await keepWarm($)
872  } catch {
873    toast($, 'That did not work. Try again in a moment.')
874  } finally {
875    if (claim === mine) claim = null
876  }
877}
878
879// ---- drawing ----
880
881const ring = (p: number, color: string, track: string, cold: boolean): string => {
882  const c = 2 * Math.PI * 7
883  const bg = cold ? `stroke="${color}" stroke-dasharray="2.2 2.2"` : `class="t" stroke="${track}"`
884  return (
885    `<circle cx="10" cy="10" r="7" fill="none" stroke-width="3.2" ${bg}/>` +
886    (p > 0 ? `<circle cx="10" cy="10" r="7" fill="none" stroke-width="3.2" stroke="${color}" stroke-linecap="round" stroke-dasharray="${((c * p) / 100).toFixed(1)} ${c.toFixed(1)}" transform="rotate(-90 10 10)"/>` : '')
887  )
888}
889
890/**
891 * The desktop gives the band no light/dark signal (the config's theme is the
892 * terminal's), so the marks and icons follow the app's own appearance through their
893 * colour-scheme query; the drawn colours are the light ones, the fallback.
894 * Classes: k = ink stroke, kf = ink fill, t = track stroke, tf = track fill.
895 */
896const DARK_STYLE = '<style>@media (prefers-color-scheme: dark){.k{stroke:#f3f1ea}.kf{fill:#f3f1ea}.t{stroke:#4b4a46}.tf{fill:#4b4a46}}</style>'
897
898function themed(svg: string, rootClass?: string): string {
899  const open = svg.indexOf('>') + 1
900  const head = rootClass ? svg.slice(0, open - 1).replace('<svg ', `<svg class="${rootClass}" `) + '>' : svg.slice(0, open)
901  return head + DARK_STYLE + svg.slice(open)
902}
903
904const esc = (v: string): string => v.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
905
906/** A mark's ring or grade badge, as the artifact draws it: the colour lives here, the text stays ink. */
907function markSvg(mark: Mark, t: Tones): string {
908  const color = toneColor(mark.tone, t)
909  const right =
910    mark.badge !== undefined
911      ? `<rect x="1" y="1" width="18" height="18" rx="5"${mark.tone === 'none' ? ' class="tf"' : ''} fill="${mark.tone === 'none' ? t.track : color}"/>` +
912        `<text x="10" y="14" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11.5" font-weight="700" fill="${t.card}">${esc(mark.badge)}</text>`
913      : ring(mark.ringPercent ?? 0, color, t.track, mark.tone === 'cold')
914  return themed(`<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20" role="img" aria-label="${esc(mark.alt)}"><title>${esc(mark.alt)}</title>${right}</svg>`)
915}
916
917function toneColor(tone: MarkTone, t: Tones): string {
918  return tone === 'none' ? t.track : t[tone]
919}
920
921function runsOf(D: Desktop, runs: Run[], t: Tones) {
922  const { Text } = D
923  return runs.map(r => (r.lose ? <Text bold color={t.bad}>{r.text}</Text> : r.strong ? <Text bold>{r.text}</Text> : r.text))
924}
925
926function icon(D: Desktop, name: IconName, color: string, alt?: string) {
927  const { Svg } = D
928  const label = alt ?? ICON_ALT[name]
929  const svg = iconSvg(name, color, { alt: label })
930  return <Svg source={color === LIGHT.ink ? themed(svg, 'k') : svg} alt={label} width={16} height={16} />
931}
932
933type Model = {
934  snap: Snapshot
935  tones: Tones
936  sheetOpen: boolean
937  savingsReason: string | null
938  /** Keep warm can run now (the one guard): only then does the row offer it. */
939  canWarm: boolean
940  /** Clawd's pictures, bottom first: the previous pose stays beneath a new one while it fades in. */
941  clawd: ClawdLayer[]
942  /** While watching: one picture per look, each shown while the pointer is over its part of the band. */
943  gazes: (ClawdLayer & { gaze: Gaze })[]
944}
945
946type ClawdLayer = { key: string; source: string; alt: string }
947
948/** How long the previous pose stays beneath a new one: the fade plus the picture's own load. */
949const UNDERLAY_MS = 900
950let clawdTop: ClawdLayer | null = null
951let clawdUnder: (ClawdLayer & { until: number }) | null = null
952
953/**
954 * The desktop shows nothing while a changed picture loads, so a bare swap
955 * blinks. Keep the old pose drawn, unchanged and under its own key, beneath
956 * the new one until the new one has faded in, then drop it.
957 */
958function clawdLayers($: EngineInterface, layer: ClawdLayer, now: number): ClawdLayer[] {
959  if (clawdTop !== null && clawdTop.key !== layer.key) {
960    clawdUnder = { ...clawdTop, until: now + UNDERLAY_MS }
961    $.clock.after(UNDERLAY_MS + 50, () => void attempt(() => update($, frameAtom, n => (n ?? 0) + 1), undefined))
962  }
963  clawdTop = layer
964  if (clawdUnder !== null && (clawdUnder.until <= now || clawdUnder.key === layer.key)) clawdUnder = null
965  return clawdUnder !== null ? [clawdUnder, layer] : [layer]
966}
967
968/** Below this, "saved this session" is noise and stays off the row. */
969const SESSION_SAVED_MIN = 1000
970
971type Handlers = { act: (id: ActionId) => void; details: () => void }
972
973function cardBox(D: Desktop, card: Card, index: number, t: Tones, on: Handlers) {
974  const { Box, Text, Button } = D
975  const side = index < 2 ? { left: 0 } : { right: 0 }
976  return (
977    <Box
978      position="absolute"
979      bottom={1}
980      {...side}
981      display="none"
982      hover={{ display: 'flex' }}
983      flexDirection="column"
984      width={34}
985      paddingX={1}
986      borderStyle="round"
987      borderColor={t.line}
988      backgroundColor={t.card}
989    >
990      <Text bold>{card.title}</Text>
991      <Text wrap="wrap">{runsOf(D, card.body, t)}</Text>
992      {card.actions.length > 0 ? (
993        <Box flexDirection="row" columnGap={1} marginTop={1}>
994          {card.actions.map(a => (
995            <Button key={`card-${card.id}-${a.id}`} label={a.label} onPress={() => on.act(a.id)} />
996          ))}
997        </Box>
998      ) : (
999        ''
1000      )}
1001    </Box>
1002  )
1003}
1004
1005function drawBand(D: Desktop, m: Model, on: Handlers) {
1006  const { Box, Text, Button, Svg } = D
1007  const { snap, tones: t } = m
1008  const say = sentence(snap)
1009  const markList = marks(snap)
1010  const cardList = cards(snap)
1011  const detail = row(snap)
1012  // A card action joins the row only when the moment calls for it (quality sagging, the cache
1013  // about to drop) and the sentence's own button is not already offering it.
1014  const q = snap.quality
1015  const sagging = q !== null && (q.score < QUALITY_FLOOR || q.compactions >= COMPACT_HEAVY)
1016  const rowActions = [
1017    ...(sagging ? cardList.find(c => c.id === 'quality')?.actions ?? [] : []),
1018    ...(m.canWarm && snap.cache.state === 'warning' ? [{ id: 'warm' as const, label: 'Keep warm' }] : []),
1019  ].filter(a => a.id !== say.action?.id && !(a.id === 'clean' && say.action?.id === 'clean-first'))
1020
1021  // Said plainly: a bare "47M past 30 days" reads like tokens spent, and a 30-bar chart
1022  // ruled by one big day said nothing. The total is the dashboard's own.
1023  const sessionSaved = (detail.savings.sessionTokens ?? 0) >= SESSION_SAVED_MIN
1024  // No figure yet: say why (or that it is being measured), never "Saved -- tokens".
1025  const savingsBlock =
1026    detail.savings.last30Tokens == null ? (
1027      <Box flexDirection="row" alignItems="center" columnGap={1}>
1028        {icon(D, 'saved', t.ink)}
1029        <Text>{detail.savings.state === 'loading' ? 'Measuring savings…' : (m.savingsReason ?? detail.savings.reason ?? 'Savings appear once Token Optimizer has measured some.')}</Text>
1030      </Box>
1031    ) : (
1032      <Box flexDirection="row" alignItems="center" columnGap={1}>
1033        {icon(D, 'saved', t.good)}
1034        <Text>
1035          Saved <Text bold>{detail.savings.last30Text}</Text> tokens in 30 days
1036          {sessionSaved ? (
1037            <Text>
1038              {' '}(<Text bold>{detail.savings.sessionText}</Text> this session)
1039            </Text>
1040          ) : (
1041            ''
1042          )}
1043        </Text>
1044      </Box>
1045    )
1046
1047  // The artifact's layout: Clawd and his arrow beside the sentence and the marks; the
1048  // unfolded row under all of it, from Clawd's left edge, savings on the right.
1049  return (
1050    // Keyed: the whole band is one hover zone (the desktop reveals within a keyed Box,
1051    // not across separate hover groups), so a pointer anywhere on it wakes Clawd to look.
1052    <Box key="band" flexDirection="column" paddingX={1} rowGap={1}>
1053      <Box flexDirection="row" alignItems="center" columnGap={2}>
1054        <Box flexDirection="row" alignItems="center" columnGap={1} flexShrink={0}>
1055          {/* Box sizes count text cells on desktop, so the bottom picture sizes the stack and the new one sits over it.
1056              Only a Button can be pressed, and its label is text, so the arrow beside him opens the row. */}
1057          {/* No hover on this Box: the desktop rebuilds a hover box's pictures on every redraw (Clawd blanked once a second). */}
1058          <Box position="relative">
1059            {m.clawd.map((c, i) => (
1060              <Box key={c.key} {...(i === 0 ? {} : { position: 'absolute' as const, top: 0, left: 0 })}>
1061                {/* Not isInteractive: the desktop reloads an interactive picture on every redraw (a blank frame); a plain one keeps its animation. */}
1062                <Svg source={c.source} alt={c.alt} width={72} height={57} />
1063              </Box>
1064            ))}
1065            {/* Hover can reveal but not move: his look toward the band is its own picture, drawn hidden over him. */}
1066            {m.gazes.map(g => (
1067              // No key of its own: a keyed Box drawn hidden is a hover zone nobody can point at.
1068              <Box position="absolute" top={0} left={0} display="none" hover={{ display: 'flex' }}>
1069                <Svg source={g.source} alt={g.alt} width={72} height={57} />
1070              </Box>
1071            ))}
1072          </Box>
1073          <Box>
1074            {/* A native button, not a bare glyph: its frame says "press me". */}
1075            <Button
1076              key="details"
1077              label={m.sheetOpen ? '▴' : '▾'}
1078              onPress={() => on.details()}
1079            />
1080          </Box>
1081        </Box>
1082        <Box flexDirection="column" flexGrow={1} flexShrink={1} rowGap={1}>
1083          <Box flexDirection="row" alignItems="center" justifyContent="space-between" columnGap={2}>
1084            <Box flexDirection="row" alignItems="center" columnGap={1} flexShrink={1}>
1085              <Text bold>Token Optimizer</Text>
1086              {icon(D, say.icon, toneColor(say.tone, t))}
1087              <Text wrap="wrap">{runsOf(D, say.runs, t)}</Text>
1088            </Box>
1089            {say.action ? <Button key="action" variant="primary" label={say.action.label} onPress={() => on.act(say.action!.id)} /> : ''}
1090          </Box>
1091          {/* One line, never wrapped: a wrapped mark's card would open over the marks above it. */}
1092          <Box flexDirection="row" flexWrap="nowrap" columnGap={3}>
1093            {markList.map((mark, i) => (
1094              <Box key={`mark-${mark.id}`} position="relative" flexDirection="row" alignItems="center" columnGap={1}>
1095                <Svg source={markSvg(mark, t)} alt={mark.alt} width={20} height={20} />
1096                <Text bold>{mark.value}</Text>
1097                {/* Always labelled: without the word, nobody knows which number is which. */}
1098                <Text>{mark.label}</Text>
1099                {(() => {
1100                  // Matched by id, not position: a missing limit never shifts a card under the wrong mark.
1101                  const card = cardList.find(c => c.id === mark.id)
1102                  return card ? cardBox(D, card, i, t, on) : ''
1103                })()}
1104              </Box>
1105            ))}
1106          </Box>
1107        </Box>
1108      </Box>
1109      {m.sheetOpen ? (
1110        <Box key="row" flexDirection="row" flexWrap="wrap" alignItems="center" justifyContent="space-between" columnGap={2} rowGap={1}>
1111          <Box flexDirection="row" flexWrap="wrap" alignItems="center" columnGap={2} rowGap={1}>
1112            {detail.facts.map(f => (
1113              <Box flexDirection="row" alignItems="center" columnGap={1}>
1114                {icon(D, f.icon, t.ink)}
1115                <Text>{runsOf(D, f.runs, t)}</Text>
1116              </Box>
1117            ))}
1118            {rowActions.length > 0 ? (
1119              <Box flexDirection="row" alignItems="center" columnGap={1}>
1120                {rowActions.map(a => (
1121                  <Button key={`row-${a.id}`} label={a.label} onPress={() => on.act(a.id)} />
1122                ))}
1123              </Box>
1124            ) : (
1125              ''
1126            )}
1127          </Box>
1128          {savingsBlock}
1129        </Box>
1130      ) : (
1131        ''
1132      )}
1133    </Box>
1134  )
1135}
1136
1137/** The band's side of a finished turn: Clawd, the clock, and the refreshes after it. */
1138async function endTurn($: EngineInterface, reason: Extract<PoseEvent, { type: 'turn-complete' }>['reason'], agentId: string | undefined): Promise<void> {
1139  try {
1140    if (agentId !== undefined) {
1141      await feedPose($, { type: 'turn-complete', reason, agentId })
1142      return
1143    }
1144    await closeAsks($)
1145    await flushTools($)
1146    await feedClock($, { type: 'working-changed', working: false })
1147    await feedPose($, { type: 'turn-complete', reason })
1148    await feedPose($, { type: 'working-changed', working: false })
1149    // Quality after each turn now; savings and the clock facts a little later.
1150    $.clock.after(0, () => void attempt(() => refresh($), undefined))
1151    statusTimer?.cancel()
1152    statusTimer = $.clock.after(STATUS_AFTER_TURN_MS, () => void attempt(() => refresh($, { savings: true }), undefined))
1153  } catch {
1154    // The band never breaks the turn it watched.
1155  }
1156}
1157
1158/** Set to 0, false, off or no, a Token Optimizer switch turns its feature off. */
1159function switchedOff(value: string | undefined): boolean {
1160  return /^(0|false|off|no)$/i.test((value ?? '').trim())
1161}
1162
1163/** Reads the TOKEN_OPTIMIZER_STATUS_BAR switches once per environment. */
1164async function readSwitches($: EngineInterface): Promise<void> {
1165  if (switchesRead) return
1166  switchesRead = true
1167  if (switchedOff(await attempt(() => $.env.get('TOKEN_OPTIMIZER_STATUS_BAR'), undefined))) enabled = false
1168  if (switchedOff(await attempt(() => $.env.get('TOKEN_OPTIMIZER_STATUS_BAR_ANIMATE'), undefined))) animate = false
1169}
1170
1171/**
1172 * Reads the switches after the band is live (a wait before that would drop a
1173 * turn ending meanwhile) and, when switched off, stops it: no timers, no drawing.
1174 */
1175async function applySwitches($: EngineInterface): Promise<void> {
1176  await readSwitches($)
1177  if (enabled) return
1178  active = false
1179  for (const t of timers) t.cancel()
1180  timers = []
1181  statusTimer?.cancel()
1182  statusTimer = null
1183}
1184
1185// ---- hooks ----
1186
1187export const register: Register = on => {
1188  enabled = true
1189  animate = true
1190
1191  on('session.start', async ($, e, next) => {
1192    const result = await next(e)
1193    active = enabled && e.isInteractive && e.surface !== 'terminal' && e.surface !== 'vscode'
1194    if (active) await start($)
1195    return result
1196  })
1197
1198  // A clear: no session.start follows; this start carries the new id.
1199  on('classic.SessionStart', async ($, e, next) => {
1200    const result = await next(e)
types/index.d.ts 153 lines
1// The status bar's state contract. Plain JSON only, and
2// self-contained: the engine ships this file to other plugins as is.
3//
4// `session` is one atom keyed by the session it describes. Readers compare its
5// sessionId to the live session and reset on a mismatch, so a /clear never
6// shows the previous session's figures. null means nothing read yet.
7//
8// `handoff` is Start fresh's pending hand-off. It deliberately lives outside
9// `session`: it is written in the old session and read in the new one, so a
10// session reset must not wipe it.
11
12export type TokenOptimizerDesktopLimit = { percentUsed: number; resetsAt: string | null }
13
14export type TokenOptimizerDesktopQuality = {
15  score: number
16  grade: string
17  drag: string | null
18  toolCalls: number | null
19  compactions: number
20  /** Epoch seconds. */
21  checkpointEpoch: number | null
22  /** Epoch seconds. */
23  sessionStartEpoch: number | null
24  /** Token Optimizer's own context fill, %. */
25  fillPct?: number | null
26}
27
28export type TokenOptimizerDesktopSavings = {
29  sessionTokens: number | null
30  last30Tokens: number | null
31  /** 30 entries, oldest first; today last. */
32  daily: number[]
33}
34
35export type TokenOptimizerDesktopSession = {
36  sessionId: string
37  /** `$.clock.now()` when these figures were gathered (ms). */
38  gatheredAt: number
39  quality: TokenOptimizerDesktopQuality | null
40  contextPercent: number | null
41  contextTokens: number | null
42  contextWindow: number | null
43  fiveHour: TokenOptimizerDesktopLimit | null
44  week: TokenOptimizerDesktopLimit | null
45  /** Current git branch; null outside a repository or on a detached HEAD. */
46  branch: string | null
47  /** Last known savings; kept while a refresh loads or times out. */
48  savings: TokenOptimizerDesktopSavings | null
49  savingsState: 'fresh' | 'stale' | 'loading' | 'unavailable'
50  /** One short reason when savings is null. */
51  savingsReason: string | null
52  /** Last main-thread request, epoch seconds (the cache clock's anchor). */
53  lastRequestEpoch: number | null
54  /** Last measured cache lifetime; null while unmeasured. */
55  cacheLifetime: '1h' | '5m' | null
56  /** When Token Optimizer last saved a checkpoint, epoch seconds. */
57  checkpointEpoch: number | null
58  /** The earlier session's checkpoint flagged as resumable for this one (epoch seconds). */
59  /** A rate limit was reported at least once this session: it runs on a Claude plan. */
60  sawLimits?: boolean
61  /** When the live session began (ms), from the engine. */
62  startedAtMs?: number | null
63  /** Main-thread tool calls and compactions the band watched itself: the row never waits on Token Optimizer's quality file. */
64  toolCallsSeen?: number
65  compactionsSeen?: number
66  /** Compactions counted in the transcript by the status command. */
67  compactions?: number | null
68  earlierCheckpoint?: { epoch: number; about: string | null } | null
69  /** The detail row under Clawd is unfolded. */
70  sheetOpen: boolean
71}
72
73export type TokenOptimizerDesktopState = TokenOptimizerDesktopSession | null
74
75/**
76 * Start fresh's hand-off, waiting for the first prompt of a session in the
77 * same project that started since it was saved, within 10 minutes.
78 */
79export type TokenOptimizerDesktopHandoff = {
80  fromSessionId: string
81  /** The project it was saved in; another project's session never touches it. */
82  cwd: string
83  checkpointPath: string
84  text: string
85  /** `$.clock.now()` when it was recorded (ms). */
86  createdAt: number
87  /** While the save is being stamped: the press time (createdAt is a far-future placeholder). */
88  pendingSince?: number
89} | null
90
91/** The cache clock's reducer state (src/clock.ts ClockState). */
92export type TokenOptimizerDesktopClock = {
93  anchor: number | null
94  lifetime: '1h' | '5m' | null
95  contextTokens: number | null
96  working: boolean
97  warming: boolean
98  lapsed: boolean
99}
100
101/** Clawd's pose reducer state (src/pose.ts PoseState). */
102export type TokenOptimizerDesktopPose = {
103  pose: 'wake' | 'idle' | 'think' | 'read' | 'type' | 'lift' | 'ask' | 'write' | 'compact' | 'done' | 'stop' | 'error' | 'cold' | 'sleep'
104  since: number
105  now: number
106  working: boolean
107  rawSub: 'think' | 'read' | 'type' | 'write' | 'lift' | null
108  rawSince: number
109  sub: 'think' | 'read' | 'type' | 'write' | 'lift' | null
110  agents: Readonly<Record<string, boolean>>
111  permissions: number
112  questions: number
113  compacting: boolean
114  cold: boolean
115  lastActivity: number
116  until: Readonly<Record<'wake' | 'done' | 'stop' | 'error', number>>
117}
118
119/** What a button is doing, the last outcome, and the Start fresh arm (src/actions.ts UiState). */
120export type TokenOptimizerDesktopUi = {
121  busy: 'clean' | 'fresh-capture' | 'fresh-clear' | null
122  busySince: number | null
123  note: string | null
124  noteUntil: number
125  freshArmedAt: number | null
126}
127
128/**
129 * The compaction or clear the buttons have running, recorded before the call
130 * and cleared when it settles, so a reload mid-call still refuses a second
131 * one. A record older than 10 minutes is treated as gone.
132 */
133export type TokenOptimizerDesktopEngineCall = {
134  kind: 'compact' | 'clear'
135  /** `$.clock.now()` when the call was recorded (ms). */
136  startedAt: number
137} | null
138
139declare module 'claude-code' {
140  interface PluginState {
141    'token-optimizer': {
142      session: TokenOptimizerDesktopState
143      handoff: TokenOptimizerDesktopHandoff
144      clock: TokenOptimizerDesktopClock | null
145      pose: TokenOptimizerDesktopPose | null
146      ui: TokenOptimizerDesktopUi | null
147      engineCall: TokenOptimizerDesktopEngineCall
148      /** Bumped by the clock tick when the visible clock changes. */
149      frame: number
150    }
151  }
152}
153
src/actions.ts 227 lines
1// The three buttons' pure parts. register.tsx owns
2// every `$` call; this file decides. Nothing here imports from 'claude-code'.
3import { tokens } from './format.ts'
4
5/** Start fresh waits this long for its second click. */
6export const FRESH_ARM_MS = 5_000
7/** A busy state ends by itself after this, so no step label outlives its work. */
8export const BUSY_TIMEOUT_MS = 120_000
9/** How long an outcome note replaces "All clear." */
10export const NOTE_MS = 8_000
11/** Keep warm's one-line fork prompt. */
12export const WARM_PROMPT = 'Reply with exactly: ok'
13/** The `$.store` key of a pending Start fresh hand-off (survives restarts). */
14export const HANDOFF_KEY = 'handoff'
15/** compact-capture reads a transcript; generous, still bounded. */
16export const CAPTURE_TIMEOUT_MS = 30_000
17/** The budget compact-capture is given (`--budget-seconds`), under CAPTURE_TIMEOUT_MS so it answers first. */
18export const CAPTURE_BUDGET_SECONDS = 25
19/** A saved hand-off joins a new session only this soon after it was saved; older ones are dropped. */
20export const HANDOFF_TTL_MS = 10 * 60_000
21/** resume-lean is a token-free read of checkpoints and the session log. */
22export const RESUME_TIMEOUT_MS = 20_000
23
24export type Busy = 'clean' | 'fresh-capture' | 'fresh-clear' | null
25
26/** The band's own UI state: what a button is doing, the last outcome, the Start fresh arm. Plain JSON. */
27export type UiState = {
28  busy: Busy
29  /** When `busy` was set (ms); the timeout counts from here. */
30  busySince: number | null
31  note: string | null
32  /** The note shows until this time (ms). */
33  noteUntil: number
34  /** First Start fresh click (ms); null when disarmed. */
35  freshArmedAt: number | null
36}
37
38/** Start fresh's saved hand-off: who saved it, where, when (`$.clock.now()` ms), and the text it carries. */
39export type Handoff = {
40  fromSessionId: string
41  cwd: string
42  checkpointPath: string
43  text: string
44  createdAt: number
45  /** While the save is being stamped: createdAt is the far-future placeholder, this the press time. */
46  pendingSince?: number
47}
48
49/** A hand-off still being stamped this long after its press was cut off by a crash. */
50const PENDING_MAX_MS = 60_000
51
52export type HandoffFate = 'attach' | 'skip' | { drop: string }
53
54/**
55 * What the session `at.sessionId` in `at.cwd` does with a held hand-off.
56 * It joins a different session than the one that saved it, in the
57 * same project, that started at or after it was saved (`startedAt`, null when
58 * unknown), within 10 minutes of the save. No marker ties it to one clear, so
59 * a typed /clear, a reload or a lost start event cannot strand or steal it.
60 * Another project's hand-off is never this session's to touch; an
61 * expired one in this project is dropped (the reason ends a one-line note).
62 */
63export function handoffFate(h: Handoff, at: { sessionId: string; cwd: string; startedAt: number | null; now: number }): HandoffFate {
64  if (h.cwd !== at.cwd) return 'skip'
65  if (h.pendingSince !== undefined) {
66    // Being stamped right now; one a crash left half-saved is dropped after a minute.
67    return at.now - h.pendingSince > PENDING_MAX_MS ? { drop: 'its save never finished' } : 'skip'
68  }
69  if (at.now - h.createdAt > HANDOFF_TTL_MS) return { drop: 'it is more than 10 minutes old' }
70  if (at.sessionId === '' || at.sessionId === h.fromSessionId) return 'skip'
71  return at.startedAt !== null && at.startedAt >= h.createdAt ? 'attach' : 'skip'
72}
73
74export function initialUi(): UiState {
75  return { busy: null, busySince: null, note: null, noteUntil: 0, freshArmedAt: null }
76}
77
78export function isArmed(ui: UiState, now: number): boolean {
79  return ui.freshArmedAt !== null && now - ui.freshArmedAt < FRESH_ARM_MS
80}
81
82/** The busy state as it stands at `now`: a stale one has timed out. */
83export function busyNow(ui: UiState, now: number): Busy {
84  if (ui.busy === null || ui.busySince === null) return null
85  return now - ui.busySince < BUSY_TIMEOUT_MS ? ui.busy : null
86}
87
88export function withBusy(ui: UiState, busy: Busy, now: number): UiState {
89  return { ...ui, busy, busySince: busy === null ? null : now, freshArmedAt: null }
90}
91
92export function withNote(ui: UiState, note: string, now: number): UiState {
93  return { ...ui, note, noteUntil: now + NOTE_MS }
94}
95
96export function noteNow(ui: UiState, now: number): string | null {
97  return ui.note !== null && now < ui.noteUntil ? ui.note : null
98}
99
100/** The path compact-capture printed (`[Token Optimizer] Checkpoint saved: <path>`), or null. */
101export function checkpointPathFrom(stdout: string): string | null {
102  const match = /Checkpoint saved: (.+?)\s*$/m.exec(stdout)
103  return match?.[1] ? match[1] : null
104}
105
106/** compact_capture writes this note when it found no transcript to read. */
107export function isStubCheckpoint(contents: string): boolean {
108  if (!contents.includes('No transcript data available')) return false
109  // Empty means the phrase with almost nothing else; a real checkpoint that quotes it is not.
110  const rest = contents.replace('No transcript data available', '').replace(/^#.*$/gm, '').replace(/\s+/g, '')
111  return rest.length < STUB_MAX_CHARS
112}
113
114/** A checkpoint with less than this much besides its headings and the empty notice saved nothing. */
115const STUB_MAX_CHARS = 200
116
117const POINTER = /^.*Cross-session checkpoint.*$/
118
119/**
120 * Removes Token Optimizer's "Cross-session checkpoint" pointer lines from a
121 * SessionStart's context: after Start fresh the held hand-off replaces it.
122 * Every other line stays; an entry left empty is dropped.
123 */
124export function stripCrossSessionPointer(entries: readonly string[] | undefined): string[] | undefined {
125  if (!entries) return undefined
126  const out: string[] = []
127  for (const entry of entries) {
128    const kept = entry.split('\n').filter(line => !POINTER.test(line)).join('\n')
129    if (kept.trim() !== '') out.push(kept)
130  }
131  return out
132}
133
134type Json = Record<string, unknown>
135const num = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) ? v : 0)
136
137/**
138 * The lifetime one request's cache writes used, when its usage carries the
139 * 1h/5m split (transcript rows do; `TurnUsage` may not). Null when unknown.
140 */
141export function lifetimeFromUsage(usage: unknown): '1h' | '5m' | null {
142  if (!usage || typeof usage !== 'object') return null
143  const split = (usage as Json).cache_creation
144  if (!split || typeof split !== 'object') return null
145  if (num((split as Json).ephemeral_1h_input_tokens) > 0) return '1h'
146  if (num((split as Json).ephemeral_5m_input_tokens) > 0) return '5m'
147  return null
148}
149
150/** Prompt tokens one request carried: what the next request re-reads (per request, never the turn's sum). */
151export function requestContextTokens(usage: unknown): number {
152  if (!usage || typeof usage !== 'object') return 0
153  const u = usage as Json
154  return num(u.input_tokens) + num(u.cache_read_input_tokens) + num(u.cache_creation_input_tokens)
155}
156
157export type WarmOutcome =
158  | { ok: true; lapsed: boolean; contextTokens: number; cacheRead?: number; lifetimeS?: number }
159  | { ok: false; reason: string }
160
161/** One line for the Keep warm outcome; a lapsed cache names what the warm-up cost. */
162export function warmToast(o: WarmOutcome): string {
163  if (!o.ok) return `Keep warm did not run: ${o.reason}.`
164  if (o.lapsed) return `The cache had already dropped, so the warm-up re-read ${tokens(o.contextTokens)} tokens at full price.`
165  // The receipt: what the warm-up read from the cache, and that the clock restarted.
166  const read = o.cacheRead && o.cacheRead > 0 ? `: re-read ${tokens(o.cacheRead)} tokens from the cache at a tenth of the price` : ''
167  const clock = o.lifetimeS ? ` The clock is back to ${Math.round(o.lifetimeS / 60)}m.` : ''
168  return `Cache kept warm${read}.${clock}`
169}
170
171const TYPED = new Set(['composer', 'sdk', 'bridge', 'unclassified'])
172
173/** The hand-off joins a prompt the person sent, never a notification, peer or automatic one. */
174export function attachesHandoff(originKind: string | undefined): boolean {
175  return originKind === undefined || TYPED.has(originKind)
176}
177
178/** What Start fresh needs from the host: measure.py by arguments (stdin optional), and a file read. */
179export type HandoffPort = {
180  run: (args: string[], stdin?: string) => Promise<{ exitCode: number; stdout: string }>
181  read: (path: string) => Promise<string>
182}
183
184export type HandoffResult = { ok: true; handoff: Handoff } | { ok: false; reason: string }
185
186/**
187 * Start fresh up to the clear: save a checkpoint, check it is real,
188 * build the lean resume text. Any failure returns a one-line reason and
189 * nothing is cleared.
190 */
191export async function prepareHandoff(
192  port: HandoffPort,
193  input: { sessionId: string; transcriptPath: string | null; cwd: string; now: number },
194): Promise<HandoffResult> {
195  const fail = (reason: string): HandoffResult => ({ ok: false, reason })
196  const stdin = JSON.stringify(input.transcriptPath ? { session_id: input.sessionId, transcript_path: input.transcriptPath } : { session_id: input.sessionId })
197
198  let captured: { exitCode: number; stdout: string }
199  try {
200    captured = await port.run(['compact-capture', '--trigger', 'start-fresh', '--budget-seconds', String(CAPTURE_BUDGET_SECONDS)], stdin)
201  } catch {
202    return fail('the checkpoint could not be saved')
203  }
204  if (captured.exitCode !== 0) return fail('the checkpoint could not be saved')
205  const path = checkpointPathFrom(captured.stdout)
206  if (!path) return fail('the checkpoint could not be saved')
207
208  let contents: string
209  try {
210    contents = await port.read(path)
211  } catch {
212    return fail('the saved checkpoint could not be read')
213  }
214  if (isStubCheckpoint(contents)) return fail('the checkpoint came out empty')
215
216  let lean: { exitCode: number; stdout: string }
217  try {
218    lean = await port.run(['resume-lean', input.sessionId, '--print'])
219  } catch {
220    return fail('the hand-off text could not be built')
221  }
222  const text = lean.exitCode === 0 ? lean.stdout.trim() : ''
223  if (!text) return fail('the hand-off text came out empty')
224
225  return { ok: true, handoff: { fromSessionId: input.sessionId, cwd: input.cwd, checkpointPath: path, text, createdAt: input.now } }
226}
227
src/clock.ts 124 lines
1// The cache clock as a pure state machine. The anchor is the last
2// main-thread request; the deadline is anchor plus lifetime. Time is passed
3// in; `canKeepWarm` is the single guard every Keep warm caller uses.
4import type { CacheState, CacheView } from './contracts.ts'
5
6/** Keep warm is refused this close to expiry. Agent default (plan Assumptions). */
7export const KEEP_WARM_MARGIN_MS = 15_000
8/** Lifetime the warning and cold rules use until a measurement lands. */
9const UNMEASURED_RULE_SECONDS = 300
10/** A warm-up that read less than this share of the context found the cache lapsed. */
11const LAPSED_READ_SHARE = 0.5
12/** Below this many tokens read, a warm-up of unknown context size found the cache gone. */
13const LAPSED_FLOOR_TOKENS = 1024
14
15export type Lifetime = '1h' | '5m'
16
17export type ClockEvent =
18  | { type: 'request-done'; at: number; lifetime: Lifetime | null; contextTokens: number }
19  | { type: 'working-changed'; working: boolean }
20  | { type: 'warm-start' }
21  | { type: 'warm-done'; at: number; cacheReadTokens: number; contextTokens: number }
22  | { type: 'warm-failed' }
23  | { type: 'clear' }
24  | { type: 'lifetime-measured'; lifetime: Lifetime }
25  | { type: 'tick'; now: number }
26
27export type ClockState = {
28  /** Time of the last main-thread request (ms); null until one lands. */
29  anchor: number | null
30  /** Measured lifetime; null while unmeasured. */
31  lifetime: Lifetime | null
32  contextTokens: number | null
33  working: boolean
34  warming: boolean
35  /** A warm-up found the cache lapsed; cold until a real request lands. */
36  lapsed: boolean
37}
38
39export function initialClock(): ClockState {
40  return { anchor: null, lifetime: null, contextTokens: null, working: false, warming: false, lapsed: false }
41}
42
43const seconds = (l: Lifetime): number => (l === '1h' ? 3600 : 300)
44
45/**
46 * One step. `now` matters only for `warm-start`, which re-checks the guard at
47 * press time because a button handle can outlive the drawing that showed it.
48 */
49export function reduceClock(state: ClockState, event: ClockEvent, now?: number): ClockState {
50  switch (event.type) {
51    case 'request-done':
52      // An older request than the anchor says nothing about the cache now.
53      if (state.anchor !== null && event.at < state.anchor) return state
54      return {
55        ...state,
56        anchor: event.at,
57        lifetime: event.lifetime ?? state.lifetime,
58        contextTokens: event.contextTokens,
59        lapsed: false,
60      }
61    case 'working-changed':
62      return { ...state, working: event.working }
63    case 'warm-start':
64      // No press time given: refuse rather than guess.
65      return now !== undefined && canKeepWarm(state, now) ? { ...state, warming: true } : state
66    case 'warm-done':
67      // With no known context size, a warm-up that read (almost) nothing from the cache found it gone.
68      if (event.contextTokens > 0 ? event.cacheReadTokens < LAPSED_READ_SHARE * event.contextTokens : event.cacheReadTokens < LAPSED_FLOOR_TOKENS) {
69        return { ...state, warming: false, lapsed: true, contextTokens: event.contextTokens }
70      }
71      return {
72        ...state,
73        warming: false,
74        lapsed: false,
75        anchor: Math.max(state.anchor ?? event.at, event.at),
76        contextTokens: event.contextTokens,
77      }
78    case 'warm-failed':
79      return { ...state, warming: false }
80    case 'clear':
81      // A new session: re-measure before trusting a lifetime again.
82      return { ...initialClock(), working: state.working }
83    case 'lifetime-measured':
84      return { ...state, lifetime: event.lifetime }
85    case 'tick':
86      return state
87  }
88}
89
90/** The single Keep warm guard. */
91export function canKeepWarm(state: ClockState, now: number): boolean {
92  if (state.working || state.warming || state.lapsed) return false
93  if (state.anchor === null || state.lifetime === null) return false
94  const deadline = state.anchor + seconds(state.lifetime) * 1000
95  return now < deadline - KEEP_WARM_MARGIN_MS
96}
97
98/**
99 * The clock as the band shows it. While unmeasured, the countdown runs against
100 * the plan default (1h on Claude plans, 5m on the API) but warning and cold use
101 * five minutes, so a lapsed cache is never shown as warm.
102 */
103export function view(state: ClockState, now: number, planDefault: 3600 | 300): CacheView {
104  const measured = state.lifetime !== null
105  const lifetime = state.lifetime !== null ? seconds(state.lifetime) : planDefault
106  const base = { lifetime, measured, tokensAtStake: state.contextTokens }
107
108  if (state.working) return { ...base, state: 'refreshing', secondsLeft: lifetime }
109  if (state.anchor === null) return { ...base, state: 'unknown', secondsLeft: null, tokensAtStake: null }
110
111  const left = Math.max(0, Math.ceil((state.anchor + lifetime * 1000 - now) / 1000))
112  if (state.warming) return { ...base, state: 'warming', secondsLeft: left }
113  if (state.lapsed) return { ...base, state: 'cold', secondsLeft: 0 }
114
115  const rule = measured ? lifetime : UNMEASURED_RULE_SECONDS
116  const window = rule >= 3600 ? 300 : 60
117  const ruleDeadline = state.anchor + rule * 1000
118  let cache: CacheState
119  if (now >= ruleDeadline) cache = 'cold'
120  else if (now >= ruleDeadline - window * 1000) cache = 'warning'
121  else cache = 'warm'
122  return { ...base, state: cache, secondsLeft: cache === 'cold' ? 0 : left }
123}
124
src/clawd.ts 280 lines
1// Clawd, drawn on an 18 x 14.2 pixel grid, one SVG document per pose.
2//
3// The desktop draws an interactive Svg in a sandboxed frame where SMIL plays
4// but page CSS never reaches, so every loop is an <animate>/<animateTransform>
5// and every colour is inlined from the palette. With `animate` off the same
6// markup is emitted without any animation element: the pose's still frame.
7
8import type { Mood, Pose } from './contracts.ts'
9
10export type Palette = {
11  skin: string
12  eye: string
13  blush: string
14  ink: string
15  laptop: string
16  spark: string
17  card: string
18  bad: string
19  cold: string
20  ground: string
21}
22
23export const LIGHT: Palette = {
24  skin: '#d97757',
25  eye: '#2a1a12',
26  blush: '#f4a58a',
27  ink: '#1f1e1d',
28  laptop: '#3d3b37',
29  spark: '#e9a820',
30  card: '#ffffff',
31  bad: '#d6453d',
32  cold: '#4f8fd0',
33  ground: '#1f1e1d',
34}
35
36export const DARK: Palette = {
37  skin: '#e08a6c',
38  eye: '#1b100b',
39  blush: '#f7b9a2',
40  ink: '#f3f1ea',
41  laptop: '#d8d4c8',
42  spark: '#f5c451',
43  card: '#3a3a37',
44  bad: '#f0685f',
45  cold: '#7db4ee',
46  ground: '#f3f1ea',
47}
48
49/** Where Clawd looks while the pointer is on the band: over at it. */
50export type Gaze = 'right'
51
52/** Eye offsets per gaze, in the picture's own units (the face is 9 wide). */
53export const GAZE: Record<Gaze, [number, number]> = {
54  right: [1.5, 0],
55}
56
57export type ClawdOptions = {
58  animate: boolean
59  palette: Palette
60  /** A watching Clawd's steady look toward the pointer; no glance, no fade in. */
61  gaze?: Gaze
62  /** Fade in when drawn (a new pose); off for the gaze pictures stacked over him. */
63  fadeIn?: boolean
64}
65
66const ALT: Record<Pose, string> = {
67  wake: 'Clawd: waking up',
68  idle: 'Clawd: watching',
69  think: 'Clawd: thinking',
70  read: 'Clawd: reading',
71  type: 'Clawd: typing',
72  lift: 'Clawd: heavy lifting',
73  ask: 'Clawd: needs you',
74  write: 'Clawd: writing',
75  compact: 'Clawd: compacting',
76  done: 'Clawd: done',
77  stop: 'Clawd: stopped',
78  error: 'Clawd: dizzy',
79  cold: 'Clawd: cold',
80  sleep: 'Clawd: napping',
81}
82
83export function clawdAlt(pose: Pose): string {
84  return ALT[pose]
85}
86
87// The CSS design used ease-in-out; SMIL takes it as a spline per segment.
88const EASE = '0.42 0 0.58 1'
89
90type Loop = { dur: number; begin?: number; ease?: boolean; keyTimes?: string }
91
92function splines(values: string, ease: boolean): string {
93  if (!ease) return ''
94  const segments = values.split(';').length - 1
95  return ` calcMode="spline" keySplines="${Array(segments).fill(EASE).join(';')}"`
96}
97
98function timing(o: Loop, values: string): string {
99  const begin = o.begin ? ` begin="${o.begin}s"` : ''
100  const times = o.keyTimes ? ` keyTimes="${o.keyTimes}"` : ''
101  return ` dur="${o.dur}s" repeatCount="indefinite"${begin}${times}${splines(values, o.ease ?? true)}`
102}
103
104/** Builds SMIL loops, or nothing when the still frame is wanted. */
105function smil(animate: boolean) {
106  return {
107    attr(name: string, values: string, o: Loop): string {
108      return animate ? `<animate attributeName="${name}" values="${values}"${timing(o, values)}/>` : ''
109    },
110    move(type: 'translate' | 'rotate' | 'scale', values: string, o: Loop): string {
111      return animate ? `<animateTransform attributeName="transform" type="${type}" values="${values}"${timing(o, values)} additive="sum"/>` : ''
112    },
113  }
114}
115
116/** Wraps `inner` so a scale animation pivots on (cx, cy) instead of the origin. */
117function pivot(cx: number, cy: number, anim: string, inner: string): string {
118  if (!anim) return inner
119  return `<g transform="translate(${cx} ${cy})"><g>${anim}<g transform="translate(${-cx} ${-cy})">${inner}</g></g></g>`
120}
121
122function rect(x: number, y: number, w: number, h: number, fill: string, extra = ''): string {
123  return `<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="${fill}"${extra}/>`
124}
125
126const BODY = 'M4 4H13V9H12.5V11H11.5V9H10.5V11H9.5V9H7.5V11H6.5V9H5.5V11H4.5V9H4Z'
127
128/** Seconds a new pose takes to fade in. */
129export const FADE_IN_S = 0.35
130
131export function clawdSvg(pose: Pose, mood: Mood, opts: ClawdOptions): string {
132  const p = opts.palette
133  const a = smil(opts.animate)
134  const stroke = (color: string, width: number) => ` fill="none" stroke="${color}" stroke-width="${width}" stroke-linecap="round" stroke-linejoin="round"`
135
136  // Eyes.
137  const eyeH = mood === 'calm' ? 1.6 : 2
138  const openEyes = pivot(8.5, 6.2, a.move('scale', '1 1;1 1;1 0.1;1 1', { dur: 5.2, keyTimes: '0;0.93;0.96;1', ease: false }),
139    rect(6, 5.2, 1, eyeH, p.eye) + rect(10, 5.2, 1, eyeH, p.eye) +
140    rect(6, 5.2, 0.4, 0.4, '#ffffff', ' opacity="0.9"') + rect(10, 5.2, 0.4, 0.4, '#ffffff', ' opacity="0.9"'))
141  const smile = (d: string) => `<path d="${d}"${stroke(p.eye, 0.55)}/>`
142  const eyesFor: Partial<Record<Pose, string>> = {
143    done: smile('M5.9 6.5L6.5 5.6L7.1 6.5') + smile('M9.9 6.5L10.5 5.6L11.1 6.5'),
144    lift: rect(5.8, 5.9, 1.4, 0.6, p.eye) + rect(9.8, 5.9, 1.4, 0.6, p.eye),
145    compact: smile('M5.8 5.6l1.2.6-1.2.6M11.2 5.6l-1.2.6 1.2.6'),
146    sleep: smile('M5.8 6.3h1.4M9.8 6.3h1.4'),
147    wake: rect(6, 5.9, 1, 0.8, p.eye) + rect(10, 5.9, 1, 0.8, p.eye),
148    error: smile('M6.5 6.1a.45.45 0 1 1 .45.45a.9.9 0 1 1-.9-.9M10.5 6.1a.45.45 0 1 1 .45.45a.9.9 0 1 1-.9-.9'),
149    stop: rect(5.8, 5, 1.3, 2.3, p.eye) + rect(9.8, 5, 1.3, 2.3, p.eye) +
150      rect(5.8, 5, 0.5, 0.5, '#ffffff', ' opacity="0.9"') + rect(9.8, 5, 0.5, 0.5, '#ffffff', ' opacity="0.9"'),
151  }
152  const look = pose === 'think' ? ' transform="translate(0.5 -0.45)"' : pose === 'write' ? ' transform="translate(0.2 0.5)"' : ''
153  // Watching with no pointer to follow: centred eyes that glance left, then right, about every 7 s.
154  const gazeAt = opts.gaze ? GAZE[opts.gaze] : null
155  const scan = gazeAt ? '' : pose === 'type'
156    ? a.move('translate', '-0.5 0.55;0.5 0.55;-0.5 0.55', { dur: 2.6 })
157    : pose === 'idle'
158      ? a.move('translate', '0 0;0 0;-0.6 0;-0.6 0;0.6 0;0.6 0;0 0;0 0', { dur: 7, keyTimes: '0;0.7;0.74;0.8;0.85;0.91;0.95;1' })
159      : ''
160  const eyes = `<g${gazeAt ? ` transform="translate(${gazeAt[0]} ${gazeAt[1]})"` : look}>${scan}${eyesFor[pose] ?? openEyes}</g>`
161
162  // Arms.
163  const arm = (side: 'l' | 'r', y: number, h = 1.2, anim = '', x?: number, w = 1.3) =>
164    `<rect x="${x ?? (side === 'l' ? 2.9 : 12.8)}" y="${y}" width="${w}" height="${h}" fill="${p.skin}">${anim}</rect>`
165  const key = (begin: number) => a.move('translate', '0 0;0 0.9;0 0', { dur: 0.26, begin, ease: false })
166  const reach = (side: 'l' | 'r') => pivot(side === 'l' ? 3.55 : 13.45, 7.2,
167    a.move('scale', '1 1;1 1;1 1.75;1 1.75;1 1', { dur: 1.7, keyTimes: '0;0.12;0.45;0.62;1' }), arm(side, 3.5, 3.7))
168  const sign = `<rect x="12.6" y="-1.5" width="3.9" height="2.9" rx="0.35" fill="${p.card}" stroke="${p.ink}" stroke-width="0.25"/>` +
169    `<path d="M13.95 -0.6q.6-.75 1.25 0q0 .55-.62.8v.35"${stroke(p.ink, 0.4)}/><circle cx="14.58" cy="1.05" r="0.17" fill="${p.ink}"/>`
170  const waveArm = `<g transform="translate(13.45 5.2)"><g>${a.move('rotate', '-8;8;-8', { dur: 1.8 })}<g transform="translate(-13.45 -5.2)">${arm('r', 1.8, 3.4)}${sign}</g></g></g>`
171  const armsFor: Partial<Record<Pose, string>> = {
172    think: arm('l', 6) + arm('r', 4.7),
173    type: arm('l', 7, 1.2, key(0)) + arm('r', 7, 1.2, key(0.13)),
174    done: arm('l', 4.3) + arm('r', 4.3),
175    lift: reach('l') + reach('r'),
176    write: arm('l', 7.6) + arm('r', 7.6),
177    read: arm('l', 6) + arm('r', 7.2),
178    stop: arm('l', 2.4, 2.8) + arm('r', 2.4, 2.8),
179    wake: arm('l', 1.6, 3.6) + arm('r', 1.6, 3.6),
180    compact: arm('l', 4.6) + arm('r', 4.6),
181    ask: arm('l', 6) + waveArm,
182  }
183  const arms = armsFor[pose] ?? arm('l', 6) + arm('r', 6)
184
185  // What Clawd holds.
186  const hold = pose === 'lift'
187    ? `<g>${a.move('translate', '0 0;0 0;0 -2.7;0 -2.7;0 0', { dur: 1.7, keyTimes: '0;0.12;0.45;0.62;1' })}` +
188      rect(0.6, 3.1, 15.8, 0.55, '#6b6861', ' rx="0.2"') +
189      rect(0.9, 2, 0.9, 2.75, p.ink, ' rx="0.25"') + rect(1.95, 2.45, 0.6, 1.85, p.ink, ' rx="0.2"') +
190      rect(15.2, 2, 0.9, 2.75, p.ink, ' rx="0.25"') + rect(14.45, 2.45, 0.6, 1.85, p.ink, ' rx="0.2"') + '</g>'
191    : ''
192
193  // The sweat drop shows when the session is in trouble or Clawd is straining.
194  const sweating = mood !== 'calm' || pose === 'lift'
195  const drop = sweating
196    ? `<path d="M13.4 2.7c.5.7.7 1 .7 1.3a.7.7 0 0 1-1.4 0c0-.3.2-.6.7-1.3z" fill="#5aa9e6">` +
197      `${a.move('translate', '0 0;0 2.4', { dur: 1.8, ease: false })}${a.attr('opacity', '0;1;0', { dur: 1.8, keyTimes: '0;0.2;1', ease: false })}</path>`
198    : ''
199
200  // The whole body's motion, pivoting on the feet.
201  const rigMotion: Partial<Record<Pose, string>> = {
202    idle: mood === 'panic'
203      ? a.move('translate', '0 0;-0.3 0;0.3 0;0 0', { dur: 0.28, ease: false })
204      : a.move('translate', '0 0;0 -0.4;0 0', { dur: 2.6 }),
205    think: a.move('rotate', '-1.4 8.5 11;1.4 8.5 11;-1.4 8.5 11', { dur: 3.6 }),
206    type: a.move('translate', '0 0;0 0.3;0 0', { dur: 0.22, ease: false }),
207    lift: pivotScale('1 0.95;1 0.95;1 1.03;1 1.03;1 0.95', 1.7, '0;0.12;0.45;0.62;1'),
208    done: a.move('translate', '0 0;0 -1.9;0 0;0 -0.7;0 0;0 0', { dur: 1.5, keyTimes: '0;0.22;0.44;0.58;0.72;1' }),
209    ask: a.move('translate', '0 0;0 -0.4;0 0', { dur: 1.2 }),
210    write: a.move('translate', '0 0;0 0.3;0 0', { dur: 0.3, ease: false }),
211    read: a.move('rotate', '-1.4 8.5 11;1.4 8.5 11;-1.4 8.5 11', { dur: 4 }),
212    compact: pivotScale('1 1;1.05 0.84;1 1', 1.3),
213    stop: a.move('translate', '0 0;0 -1.2;0 0;0 0', { dur: 1.6, keyTimes: '0;0.08;0.2;1' }),
214    error: a.move('rotate', '-3 8.5 11;3 8.5 11;-3 8.5 11', { dur: 1.2 }),
215    cold: a.move('translate', '0 0;-0.3 0;0.3 0;0 0', { dur: 0.18, ease: false }),
216    sleep: pivotScale('1 1;1 1.03;1 1', 3),
217    wake: pivotScale('1 1;1 1.07;1 1.07;1 1', 2.4, '0;0.35;0.55;1'),
218  }
219  function pivotScale(values: string, dur: number, keyTimes?: string): string {
220    // Scaling pivots on the feet: shift the origin down, scale, shift back.
221    return opts.animate
222      ? `<animateTransform attributeName="transform" type="translate" values="8.5 11" dur="${dur}s" repeatCount="indefinite" additive="sum"/>` +
223        a.move('scale', values, { dur, keyTimes }) +
224        `<animateTransform attributeName="transform" type="translate" values="-8.5 -11" dur="${dur}s" repeatCount="indefinite" additive="sum"/>`
225      : ''
226  }
227
228  // Props in front of the body.
229  const sparkle = (d: string, begin: number) =>
230    `<path d="${d}" fill="${p.spark}">${a.attr('opacity', '0.2;1;0.2', { dur: 1.1, begin })}</path>`
231  const sparks = sparkle('M1.6 1.4L2 2.5L3.1 2.9L2 3.3L1.6 4.4L1.2 3.3L0.1 2.9L1.2 2.5Z', 0) +
232    sparkle('M15.6 0L15.9 0.9L16.8 1.2L15.9 1.5L15.6 2.4L15.3 1.5L14.4 1.2L15.3 0.9Z', 0.35)
233  const dot = (cx: number, cy: number, r: number, begin: number) =>
234    `<circle cx="${cx}" cy="${cy}" r="${r}" fill="#6b6861">${a.attr('r', `${r * 0.55};${r};${r * 0.55}`, { dur: 1.5, begin })}${a.attr('opacity', '0.5;1;0.5', { dur: 1.5, begin })}</circle>`
235  const codeLine = (x: number, y: number, w: number, begin: number) =>
236    `<rect x="${x}" y="${y}" width="${w}" height="0.36" rx="0.18" fill="${p.ink}" opacity="${opts.animate ? 0 : 0.6}">` +
237    `${a.move('translate', '0 0;0 -3.4', { dur: 1.5, begin, ease: false })}${a.attr('opacity', '0;0.9;0', { dur: 1.5, begin, keyTimes: '0;0.25;1', ease: false })}</rect>`
238  const inkLine = (d: string, begin: number) =>
239    `<path d="${d}"${stroke(p.ink, 0.3)} stroke-dasharray="6" stroke-dashoffset="${opts.animate ? 6 : 0}">` +
240    `${a.attr('stroke-dashoffset', '6;0;0', { dur: 1.8, begin, keyTimes: '0;0.6;1', ease: false })}</path>`
241  const flake = (d: string, begin: number) =>
242    `<path d="${d}"${stroke(p.cold, 0.3)}>${a.move('translate', '0 -1;0 5', { dur: 2, begin, ease: false })}${a.attr('opacity', '0;1;0', { dur: 2, begin, keyTimes: '0;0.2;1', ease: false })}</path>`
243  const zz = (d: string, begin: number) =>
244    `<path d="${d}"${stroke(p.ink, 0.3)} opacity="${opts.animate ? 0 : 1}">${a.move('translate', '0 0;0.8 -2', { dur: 2.7, begin })}${a.attr('opacity', '0;1;0', { dur: 2.7, begin, keyTimes: '0;0.25;1' })}</path>`
245  const frontFor: Partial<Record<Pose, string>> = {
246    think: dot(14.4, 3.4, 0.38, 0) + dot(15.5, 2.2, 0.55, 0.2) + dot(16.9, 0.7, 0.8, 0.4),
247    type: rect(5.1, 7.5, 6.8, 3.5, p.laptop, ' rx="0.5"') + `<circle cx="8.5" cy="9.2" r="0.48" fill="${p.skin}"/>` +
248      rect(4.2, 10.75, 8.6, 0.6, p.laptop, ' rx="0.3"') + codeLine(14.6, 6, 1.7, 0) + codeLine(14.9, 7, 1.1, 0.5) + codeLine(14.4, 8, 1.4, 1),
249    done: sparks,
250    write: rect(4.4, 8.9, 8.2, 2.5, p.card, ` rx="0.3" stroke="${p.ink}" stroke-width="0.2"`) +
251      inkLine('M5.2 9.75h5.4', 0) + inkLine('M5.2 10.6h3.6', 0.9) +
252      `<g>${a.move('translate', '-4 0;0 0;-4 0', { dur: 1.8 })}<rect x="10.9" y="7.3" width="0.55" height="2.3" rx="0.2" fill="${p.spark}" transform="rotate(28 11.2 8.5)"/></g>`,
253    read: `<g>${a.move('translate', '-4 0;0.3 0;-4 0', { dur: 4.4 })}<circle cx="10.5" cy="6.1" r="1.75" fill="#ffffff" fill-opacity="0.35" stroke="${p.ink}" stroke-width="0.35"/>` +
254      `<path d="M11.8 7.4L13.5 9.1"${stroke(p.ink, 0.6)}/></g>`,
255    compact: `<g>${a.move('translate', '0 0;0 1.1;0 0', { dur: 1.3 })}<path d="M6 -1.2v2.1M5.25 0.25l.75.75.75-.75"${stroke(p.ink, 0.4)}/><path d="M11 -1.2v2.1M10.25 0.25l.75.75.75-.75"${stroke(p.ink, 0.4)}/></g>`,
256    stop: rect(8.15, -0.9, 0.75, 2.1, p.bad, ' rx="0.2"') + rect(8.15, 1.6, 0.75, 0.7, p.bad, ' rx="0.2"'),
257    error: `<g>${a.move('rotate', '0 8.5 2.6;360 8.5 2.6', { dur: 1.6, ease: false })}` +
258      ['M4.6 2.2', 'M12.4 3.1', 'M8.5 0.2'].map(m => `<path d="${m}l.25.6.6.25-.6.25-.25.6-.25-.6-.6-.25.6-.25Z" fill="${p.spark}"/>`).join('') + '</g>',
259    cold: flake('M2 1v1.4M1.3 1.7h1.4', 0) + flake('M15.4 0v1.4M14.7 0.7h1.4', 0.7) + flake('M12 -1v1.4M11.3 -0.3h1.4', 1.3),
260    sleep: zz('M13.6 2.6h1.1l-1.1 1.1h1.1', 0) + zz('M14.9 0.9h1.4l-1.4 1.4h1.4', 0.9) + zz('M16.4 -1h1.6l-1.6 1.6h1.6', 1.8),
261  }
262
263  const glow = pose === 'type'
264    ? `<rect x="4" y="6.4" width="9" height="1.1" fill="#ffffff" opacity="0.16">${a.attr('opacity', '0.08;0.22;0.08', { dur: 2.2 })}</rect>`
265    : ''
266  const frost = pose === 'cold' ? `<path d="M4 4H13V9H4Z" fill="${p.cold}" opacity="0.22"/>` : ''
267
268  const body = `<path d="${BODY}" fill="${p.skin}"/>` +
269    rect(4, 8.2, 9, 0.8, '#000000', ' opacity="0.13"') + rect(4.6, 4.4, 2.4, 0.45, '#ffffff', ' opacity="0.26"') +
270    rect(4.9, 7.25, 1.1, 0.5, p.blush, ' opacity="0.85"') + rect(11, 7.25, 1.1, 0.5, p.blush, ' opacity="0.85"')
271
272  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -1.6 18 14.2" role="img" aria-label="${ALT[pose]}">` +
273    `<title>${ALT[pose]}</title>` +
274    // A new pose fades in over the old one the band keeps beneath it, so a switch never cuts.
275    (opts.animate && opts.fadeIn !== false ? `<g opacity="0"><animate attributeName="opacity" from="0" to="1" begin="0s" dur="${FADE_IN_S}s" fill="freeze"/>` : '<g>') +
276    `<ellipse cx="8.5" cy="11.2" rx="5.2" ry="0.5" fill="${p.ground}" opacity="0.1"/>` +
277    `<g>${rigMotion[pose] ?? ''}${arms}${body}${glow}${frost}${eyes}${drop}${hold}</g>` +
278    (frontFor[pose] ?? '') + '</g></svg>'
279}
280
src/contracts.ts 95 lines
1// Shapes shared by the pure modules. Nothing here imports from 'claude-code':
2// register.tsx maps the engine's events and values onto these.
3
4/** Clawd's 14 moments, one pose each. */
5export type Pose =
6  | 'wake'
7  | 'idle'
8  | 'think'
9  | 'read'
10  | 'type'
11  | 'lift'
12  | 'ask'
13  | 'write'
14  | 'compact'
15  | 'done'
16  | 'stop'
17  | 'error'
18  | 'cold'
19  | 'sleep'
20
21/** How healthy the session looks; drives Clawd's eyes and sweat drop. */
22export type Mood = 'calm' | 'worried' | 'panic'
23
24/** Where the cache clock stands. */
25export type CacheState = 'unknown' | 'refreshing' | 'warm' | 'warning' | 'cold' | 'warming'
26
27/** The cache clock as the view model reads it. */
28export type CacheView = {
29  state: CacheState
30  /** Whole seconds until the cache lapses; null when unknown. */
31  secondsLeft: number | null
32  /** Lifetime in seconds the clock is counting against (3600 or 300). */
33  lifetime: number
34  /** True when the lifetime came from a measurement, not the plan default. */
35  measured: boolean
36  /** Tokens the next message would re-read at full price if the cache lapsed. */
37  tokensAtStake: number | null
38}
39
40/** A usage limit as `$.session.usage()` reports it. */
41export type Limit = { percentUsed: number; resetsAt: string | null }
42
43/** Token Optimizer's quality cache fields the band reads. */
44export type Quality = {
45  score: number
46  grade: string
47  drag: string | null
48  toolCalls: number | null
49  compactions: number
50  checkpointEpoch: number | null
51  sessionStartEpoch: number | null
52  /** Token Optimizer's own context fill, %; known right after a compact, before the next reply. */
53  fillPct?: number | null
54}
55
56/** What `measure.py status-bar` returns, reduced to what the band shows. */
57export type Savings = {
58  sessionTokens: number | null
59  last30Tokens: number | null
60  /** 30 entries, oldest first; today last. */
61  daily: number[]
62}
63
64/** Everything the view model needs for one frame. */
65export type Snapshot = {
66  now: number
67  working: boolean
68  quality: Quality | null
69  contextPercent: number | null
70  contextTokens: number | null
71  contextWindow: number | null
72  fiveHour: Limit | null
73  week: Limit | null
74  cache: CacheView
75  branch: string | null
76  savings: Savings | null
77  savingsLoading: boolean
78  busy: 'clean' | 'fresh-capture' | 'fresh-clear' | 'warming' | null
79  /** One-line outcome shown in place of "All clear." for a few seconds. */
80  note: string | null
81  /** A Start fresh hand-off waits for the first prompt. */
82  handoffPending: boolean
83  /** Start fresh is armed and waiting for its second click. */
84  freshArmed: boolean
85  /** When the session began (ms), from the engine: the row's session time before Token Optimizer reports one. */
86  startedAtMs?: number | null
87  /** Tool calls and compactions the band watched itself. */
88  toolCallsSeen?: number
89  compactionsSeen?: number
90  /** When this session last saved a checkpoint, any trigger (epoch seconds). */
91  checkpointEpoch?: number | null
92  /** An earlier session's checkpoint on this work, when this session has none of its own (epoch seconds). */
93  earlierCheckpoint?: { epoch: number; about: string | null } | null
94}
95
src/icons.ts 51 lines
1// 16x16 stroke icons from the design page, plus a standalone SVG builder for
2// the drawing layer's Svg element. Pure.
3
4export const ICONS = {
5  branch: '<circle cx="4" cy="3.5" r="1.6"></circle><circle cx="4" cy="12.5" r="1.6"></circle><circle cx="12" cy="5" r="1.6"></circle><path d="M4 5.1v5.8M12 6.6c0 2.6-4 2.2-6.6 4.2"></path>',
6  clock: '<circle cx="8" cy="8" r="5.8"></circle><path d="M8 4.8V8l2.2 1.4"></path>',
7  tool: '<path d="M10.2 2.2a3.2 3.2 0 0 0-3 4.3L2.6 11a1.4 1.4 0 0 0 2 2l4.6-4.6a3.2 3.2 0 0 0 4.2-3.7l-1.9 1.9-1.6-.4-.4-1.6 1.9-1.9a3.2 3.2 0 0 0-1.2-.5z"></path>',
8  compact: '<path d="M3 2.5h10M3 13.5h10M8 4.3v2.8M6.4 5.7 8 7.2l1.6-1.5M8 11.7V8.9M6.4 10.3 8 8.8l1.6 1.5"></path>',
9  bookmark: '<path d="M4.5 2.5h7v11L8 10.8l-3.5 2.7z"></path>',
10  cold: '<path d="M8 2v12M2.8 5l10.4 6M13.2 5 2.8 11"></path>',
11  hourglass: '<path d="M4.5 2.5h7M4.5 13.5h7M5 2.5c0 3 3 3.6 3 5.5s-3 2.5-3 5.5M11 2.5c0 3-3 3.6-3 5.5s3 2.5 3 5.5"></path>',
12  gauge: '<path d="M2.8 11.5a5.8 5.8 0 1 1 10.4 0M8 9l2.6-3.2"></path>',
13  check: '<circle cx="8" cy="8" r="5.8"></circle><path d="M5.6 8.2 7.3 9.9l3.2-3.6"></path>',
14  slip: '<path d="M2.5 4.5 6.5 8.5l2.5-2.5 4.5 4.5M13.5 7v3.5H10"></path>',
15  ask: '<circle cx="8" cy="8" r="5.8"></circle><path d="M6.3 6.4a1.75 1.75 0 1 1 2.4 1.6c-.45.2-.7.55-.7 1v.35M8 11.3v.05"></path>',
16  saved: '<ellipse cx="8" cy="4.2" rx="4.8" ry="1.9"></ellipse><path d="M3.2 4.2v3.8c0 1 2.1 1.9 4.8 1.9s4.8-.9 4.8-1.9V4.2M3.2 8v3.8c0 1 2.1 1.9 4.8 1.9s4.8-.9 4.8-1.9V8"></path>',
17} as const
18
19export type IconName = keyof typeof ICONS
20
21/** Default alt text naming each icon, used when the caller passes none. */
22export const ICON_ALT: Record<IconName, string> = {
23  branch: 'Branch',
24  clock: 'Clock',
25  tool: 'Tool calls',
26  compact: 'Compaction',
27  bookmark: 'Checkpoint',
28  cold: 'Cold cache',
29  hourglass: 'Cache countdown',
30  gauge: 'Gauge',
31  check: 'All clear',
32  slip: 'Quality slipping',
33  ask: 'Question',
34  saved: 'Tokens saved',
35}
36
37function attr(value: string): string {
38  return value.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
39}
40
41/** A complete standalone SVG string for an icon stroked in `color`. */
42export function iconSvg(name: IconName, color: string, opts: { size?: number; alt?: string } = {}): string {
43  const size = opts.size ?? 16
44  const alt = opts.alt ?? ICON_ALT[name]
45  return (
46    `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 16 16" fill="none" ` +
47    `stroke="${attr(color)}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" role="img" aria-label="${attr(alt)}">` +
48    `<title>${attr(alt)}</title>${ICONS[name]}</svg>`
49  )
50}
51
src/ladder.ts 107 lines
1// The sentence ladder: the single most urgent thing to say, and
2// at most one button. Pure.
3import type { Limit, Mood, Snapshot } from './contracts.ts'
4import type { IconName } from './icons.ts'
5import { clock, gradeOf, renewalPhrase, tokens, type FormatOptions } from './format.ts'
6
7export type Tone = 'good' | 'caution' | 'bad' | 'cold'
8
9/** A run of text. Text is full ink; only `lose` (bold red) carries colour. `strong` is bold ink. */
10export type Run = { text: string; lose?: true; strong?: true }
11
12export type ActionId = 'clean' | 'clean-first' | 'fresh' | 'warm'
13export type Action = { id: ActionId; label: string }
14
15export type Sentence = { icon: IconName; tone: Tone; runs: Run[]; action: Action | null }
16
17/** Quality below grade B (score under 70) is worth a sentence. */
18export const QUALITY_FLOOR = 70
19export const LIMIT_WARN = 90
20export const COMPACT_HEAVY = 3
21
22const say = (icon: IconName, tone: Tone, runs: Run[], action: Action | null = null): Sentence => ({ icon, tone, runs, action })
23const plain = (text: string): Run => ({ text })
24
25/** The tokens the next message would re-read, as the one bold red run. */
26export function loseRun(n: number): Run {
27  return { text: `${tokens(n)} tokens`, lose: true }
28}
29
30function renews(limit: Limit, now: number, opts: FormatOptions): string {
31  const when = renewalPhrase(limit.resetsAt, now, opts)
32  return when ? ` Renews ${when}.` : ''
33}
34
35function limitSentence(name: string, limit: Limit, now: number, opts: FormatOptions): Sentence {
36  const head = limit.percentUsed >= 100 ? `${name} limit reached.` : `${name} limit ${Math.round(limit.percentUsed)}% used.`
37  return say('gauge', 'bad', [plain(head + renews(limit, now, opts))])
38}
39
40export function sentence(s: Snapshot, opts: FormatOptions = {}): Sentence {
41  // What the band is doing right now leads, with no button.
42  if (s.busy === 'clean') return say('compact', 'good', [plain('Cleaning up.')])
43  if (s.busy === 'fresh-capture') return say('bookmark', 'good', [plain('Saving checkpoint.')])
44  if (s.busy === 'fresh-clear') return say('bookmark', 'good', [plain('Clearing.')])
45  if (s.busy === 'warming') return say('hourglass', 'good', [plain('Keeping the cache warm.')])
46  if (s.freshArmed) {
47    return say('bookmark', 'caution', [plain('Start fresh clears this conversation after saving a checkpoint.')], {
48      id: 'fresh',
49      label: 'Click again to clear',
50    })
51  }
52  if (s.handoffPending) return say('bookmark', 'good', [plain('Checkpoint ready, it joins your first message.')])
53
54  // The ladder, first match wins. Null limits (API users) skip their rules.
55  const five = s.fiveHour
56  const week = s.week
57  if (five && five.percentUsed >= 100) return limitSentence('5-hour', five, s.now, opts)
58  if (week && week.percentUsed >= 100) return limitSentence('Weekly', week, s.now, opts)
59  if (five && five.percentUsed >= LIMIT_WARN) return limitSentence('5-hour', five, s.now, opts)
60  if (week && week.percentUsed >= LIMIT_WARN) return limitSentence('Weekly', week, s.now, opts)
61
62  const q = s.quality
63  if (q && q.compactions >= COMPACT_HEAVY) {
64    return say('compact', 'bad', [plain(`Compacted ${q.compactions} times. Early detail is mostly gone.`)], {
65      id: 'fresh',
66      label: 'Start fresh',
67    })
68  }
69  if (q && q.score < QUALITY_FLOOR) {
70    return say('slip', 'bad', [plain(`Quality slipped to ${gradeOf(q.score)}.`)], { id: 'clean', label: 'Clean up' })
71  }
72
73  // Cache rules hold still while a turn runs: every request refreshes the cache.
74  if (!s.working) {
75    const c = s.cache
76    if (c.state === 'cold') {
77      const runs: Run[] =
78        c.tokensAtStake != null
79          ? [plain('Cache is cold. Next message re-reads '), loseRun(c.tokensAtStake), plain(' at full price.')]
80          : [plain('Cache is cold. Next message re-reads the whole context at full price.')]
81      runs.push(plain(' Cleaning up first makes later messages cheaper.'))
82      return say('cold', 'cold', runs, { id: 'clean-first', label: 'Clean up first' })
83    }
84    if (c.state === 'warning') {
85      const head = c.secondsLeft != null ? `Cache drops in ${clock(c.secondsLeft)}.` : 'Cache drops soon.'
86      const runs: Run[] =
87        c.tokensAtStake != null
88          ? [plain(`${head} Then the next message re-reads `), loseRun(c.tokensAtStake), plain('.')]
89          : [plain(`${head} Then the next message re-reads the whole context.`)]
90      // Keep warm only on a measured lifetime.
91      return say('hourglass', 'caution', runs, c.measured ? { id: 'warm', label: 'Keep warm' } : null)
92    }
93  }
94
95  return say('check', 'good', [plain(s.note ?? 'All clear.')])
96}
97
98/** How healthy the session looks, for Clawd's face. */
99export function moodOf(s: Snapshot): Mood {
100  const limits = [s.fiveHour, s.week].filter((l): l is Limit => l !== null)
101  const q = s.quality
102  if (limits.some((l) => l.percentUsed >= LIMIT_WARN) || (q && q.compactions >= COMPACT_HEAVY)) return 'panic'
103  if (q && q.score < QUALITY_FLOOR) return 'worried'
104  if (!s.working && (s.cache.state === 'cold' || s.cache.state === 'warning')) return 'worried'
105  return 'calm'
106}
107
src/marks.ts 274 lines
1// The five marks, their hover cards and the detail row as plain
2// data the drawing layer maps to elements. Pure.
3import type { CacheView, Limit, Snapshot } from './contracts.ts'
4import type { IconName } from './icons.ts'
5import { ago, clock, duration, minutes, gradeOf, relative, renewal, tokens, type FormatOptions } from './format.ts'
6import { KEEP_WARM_MARGIN_MS } from './clock.ts'
7import { LIMIT_WARN, QUALITY_FLOOR, loseRun, type Action, type Run, type Tone } from './ladder.ts'
8
9const KEEP_WARM_MARGIN_S = KEEP_WARM_MARGIN_MS / 1000
10
11export type MarkId = 'quality' | 'context' | 'cache' | 'fiveHour' | 'week'
12/** 'none' means no data: the drawing layer shows the mark uncoloured. */
13export type MarkTone = Tone | 'none'
14
15export type Mark = {
16  id: MarkId
17  icon: IconName
18  /** The figure on the bar, "--" when unavailable. */
19  value: string
20  label: string
21  tone: MarkTone
22  /** 0..100 for ring marks (context, cache, limits). */
23  ringPercent?: number
24  /** The grade letter on the quality mark. */
25  badge?: string
26  /** Alt text naming the state. */
27  alt: string
28}
29
30export type Card = { id: MarkId; title: string; body: Run[]; actions: Action[] }
31
32export type Fact = { icon: IconName; runs: Run[] }
33
34export type SavingsBlock = {
35  state: 'ready' | 'loading' | 'unavailable'
36  sessionTokens: number | null
37  last30Tokens: number | null
38  sessionText: string
39  last30Text: string
40  /** Raw daily figures, oldest first, today last; empty when unavailable. */
41  daily: number[]
42  /** Bar heights in percent of the tallest day, at least 8 so a quiet day still shows. */
43  bars: number[]
44  /** One short reason when unavailable. */
45  reason: string | null
46}
47
48export type Row = { facts: Fact[]; savings: SavingsBlock }
49
50const clamp = (p: number) => Math.max(0, Math.min(100, Math.round(p)))
51const plain = (text: string): Run => ({ text })
52const strong = (text: string): Run => ({ text, strong: true })
53
54export function qualityTone(score: number): Tone {
55  return score >= 80 ? 'good' : score >= QUALITY_FLOOR ? 'caution' : 'bad'
56}
57export function fillTone(p: number): Tone {
58  return p >= 80 ? 'bad' : p >= 60 ? 'caution' : 'good'
59}
60export function limitTone(p: number): Tone {
61  return p >= LIMIT_WARN ? 'bad' : p >= 75 ? 'caution' : 'good'
62}
63
64function estimateNote(c: CacheView): string {
65  return c.measured ? 'an estimate' : 'an estimate, lifetime not measured yet'
66}
67
68function cacheMark(c: CacheView): Mark {
69  const base = { id: 'cache' as const, label: 'cache' }
70  const est = estimateNote(c)
71  switch (c.state) {
72    case 'warm':
73    case 'warning': {
74      const left = c.secondsLeft ?? 0
75      const tone: Tone = c.state === 'warm' ? 'good' : 'caution'
76      const word = c.state === 'warm' ? 'warm' : 'about to drop'
77      return { ...base, icon: 'hourglass', value: minutes(left), tone, ringPercent: clamp((left / c.lifetime) * 100), alt: `Cache ${word}, ${minutes(left)} left, ${est}` }
78    }
79    case 'cold':
80      return { ...base, icon: 'cold', value: 'cold', tone: 'cold', ringPercent: 0, alt: `Cache cold, ${est}` }
81    case 'refreshing':
82      return { ...base, icon: 'hourglass', value: minutes(c.lifetime), tone: 'good', ringPercent: 100, alt: 'Cache refreshing while the turn runs' }
83    case 'warming':
84      return {
85        ...base,
86        icon: 'hourglass',
87        value: c.secondsLeft != null ? minutes(c.secondsLeft) : '--',
88        tone: 'good',
89        ringPercent: c.secondsLeft != null ? clamp((c.secondsLeft / c.lifetime) * 100) : 0,
90        alt: 'Cache warm-up running',
91      }
92    default:
93      return { ...base, icon: 'hourglass', value: '--', tone: 'none', ringPercent: 0, alt: 'Cache clock starts after the first reply' }
94  }
95}
96
97function limitMark(id: 'fiveHour' | 'week', limit: Limit, now: number, opts: FormatOptions): Mark {
98  const p = Math.round(limit.percentUsed)
99  const name = id === 'fiveHour' ? '5-hour' : 'Weekly'
100  const when = renewal(limit.resetsAt, now, opts)
101  return {
102    id,
103    icon: 'clock',
104    value: `${p}%`,
105    label: id === 'fiveHour' ? '5 hours' : 'week',
106    tone: limitTone(limit.percentUsed),
107    ringPercent: clamp(limit.percentUsed),
108    alt: `${name} limit ${p}% used` + (when ? `, renews ${when}` : ''),
109  }
110}
111
112/** The marks under the sentence, in order. Limit marks are omitted when the limit is null. */
113export function marks(s: Snapshot, opts: FormatOptions = {}): Mark[] {
114  const q = s.quality
115  const quality: Mark = q
116    ? {
117        id: 'quality',
118        icon: q.score < QUALITY_FLOOR ? 'slip' : 'check',
119        value: String(Math.round(q.score)),
120        badge: gradeOf(q.score),
121        label: 'quality',
122        tone: qualityTone(q.score),
123        alt: `Quality grade ${gradeOf(q.score)}, score ${Math.round(q.score)} of 100`,
124      }
125    : { id: 'quality', icon: 'check', value: '--', badge: '--', label: 'quality', tone: 'none', alt: 'Quality not measured yet' }
126
127  const p = s.contextPercent
128  const context: Mark =
129    p != null
130      ? { id: 'context', icon: 'gauge', value: `${Math.round(p)}%`, label: 'context', tone: fillTone(p), ringPercent: clamp(p), alt: `Context ${Math.round(p)}% full` }
131      : { id: 'context', icon: 'gauge', value: '--', label: 'context', tone: 'none', ringPercent: 0, alt: 'Context fill not reported yet' }
132
133  const out = [quality, context, cacheMark(s.cache)]
134  if (s.fiveHour) out.push(limitMark('fiveHour', s.fiveHour, s.now, opts))
135  if (s.week) out.push(limitMark('week', s.week, s.now, opts))
136  return out
137}
138
139function cacheCard(c: CacheView): Card {
140  const est = ` The clock is ${estimateNote(c)}.`
141  // Offered only while a press can still run: not in the last seconds before the cache drops.
142  const warm: Action[] = c.measured && (c.secondsLeft ?? 0) > KEEP_WARM_MARGIN_S ? [{ id: 'warm', label: 'Keep warm' }] : []
143  const stake = c.tokensAtStake
144  switch (c.state) {
145    case 'warm':
146      return {
147        id: 'cache',
148        title: `Warm for ${minutes(c.secondsLeft ?? 0)}`,
149        body: [plain(stake != null ? `Messages re-read ${tokens(stake)} tokens at a tenth of the price.` : 'Messages re-read the context at a tenth of the price.'), plain(est)],
150        actions: warm,
151      }
152    case 'warning':
153      return {
154        id: 'cache',
155        title: c.secondsLeft != null ? `Drops in ${minutes(c.secondsLeft)}` : 'Drops soon',
156        body: [
157          ...(stake != null ? [plain('Then the next message re-reads '), loseRun(stake), plain(' at full price.')] : [plain('Then the next message re-reads the whole context at full price.')]),
158          plain(est),
159        ],
160        actions: warm,
161      }
162    case 'cold':
163      return {
164        id: 'cache',
165        title: 'Cold',
166        body: [
167          ...(stake != null ? [plain('Next message re-reads '), loseRun(stake), plain(' at full price.')] : [plain('Next message re-reads the whole context at full price.')]),
168          plain(' Cleaning up first re-reads it once and makes later messages cheaper.'),
169        ],
170        actions: [{ id: 'clean-first', label: 'Clean up first' }],
171      }
172    case 'refreshing':
173      return { id: 'cache', title: 'Refreshing', body: [plain('Every request in this turn keeps the cache warm.')], actions: [] }
174    case 'warming':
175      return { id: 'cache', title: 'Keeping warm', body: [plain('A one-line warm-up is running.')], actions: [] }
176    default:
177      return { id: 'cache', title: 'Not started', body: [plain('The clock starts after the first reply.')], actions: [] }
178  }
179}
180
181function limitCard(id: 'fiveHour' | 'week', limit: Limit, now: number, opts: FormatOptions): Card {
182  const when = renewal(limit.resetsAt, now, opts)
183  const at = limit.resetsAt ? Date.parse(limit.resetsAt) : NaN
184  if (!when || !Number.isFinite(at)) {
185    const name = id === 'fiveHour' ? '5-hour' : 'Weekly'
186    return { id, title: `${name} limit ${Math.round(limit.percentUsed)}% used`, body: [plain('Renewal time not reported.')], actions: [] }
187  }
188  return { id, title: `Renews ${when}`, body: [plain(`That is ${relative(at, now)}.`)], actions: [] }
189}
190
191/** Hover card bodies, one per mark, same order and omissions as `marks`. */
192export function cards(s: Snapshot, opts: FormatOptions = {}): Card[] {
193  const q = s.quality
194  const qualityActions: Action[] = [
195    { id: 'clean', label: 'Clean up' },
196    { id: 'fresh', label: s.freshArmed ? 'Click again to clear' : 'Start fresh' },
197  ]
198  const quality: Card = q
199    ? {
200        id: 'quality',
201        title: `Quality ${gradeOf(q.score)} ${Math.round(q.score)}`,
202        body: [plain(q.drag ? `Biggest drag: ${q.drag}.` : 'Nothing is dragging quality down.')],
203        actions: qualityActions,
204      }
205    : { id: 'quality', title: 'Quality --', body: [plain('The score appears after the first tool call.')], actions: qualityActions }
206
207  const context: Card =
208    s.contextTokens != null && s.contextWindow != null
209      ? { id: 'context', title: `${tokens(s.contextTokens)} of ${tokens(s.contextWindow)} tokens`, body: [plain('Quality holds best under half full.')], actions: [] }
210      : { id: 'context', title: 'Context --', body: [plain('The fill appears after the first reply.')], actions: [] }
211
212  const out = [quality, context, cacheCard(s.cache)]
213  if (s.fiveHour) out.push(limitCard('fiveHour', s.fiveHour, s.now, opts))
214  if (s.week) out.push(limitCard('week', s.week, s.now, opts))
215  return out
216}
217
218function savingsBlock(s: Snapshot): SavingsBlock {
219  const sv = s.savings
220  if (!sv) {
221    return {
222      state: s.savingsLoading ? 'loading' : 'unavailable',
223      sessionTokens: null,
224      last30Tokens: null,
225      sessionText: '--',
226      last30Text: '--',
227      daily: [],
228      bars: [],
229      reason: s.savingsLoading ? null : 'Savings appear once Token Optimizer has measured some.',
230    }
231  }
232  const top = Math.max(1, ...sv.daily)
233  return {
234    state: s.savingsLoading ? 'loading' : 'ready',
235    sessionTokens: sv.sessionTokens,
236    last30Tokens: sv.last30Tokens,
237    sessionText: sv.sessionTokens != null ? tokens(sv.sessionTokens) : '--',
238    last30Text: sv.last30Tokens != null ? tokens(sv.last30Tokens) : '--',
239    daily: sv.daily,
240    bars: sv.daily.map((v) => Math.max(8, Math.round((Math.max(0, v) / top) * 100))),
241    reason: null,
242  }
243}
244
245/**
246 * The detail row under Clawd: only what the bar does not show.
247 * `s.now` is epoch milliseconds; the quality cache's epochs are seconds.
248 */
249/** Longer branch names are cut so the unfolded row stays on one line. */
250const BRANCH_MAX = 20
251
252export function row(s: Snapshot, _opts: FormatOptions = {}): Row {
253  const q = s.quality
254  const facts: Fact[] = []
255  // One line in the band: every fact in its shortest plain form.
256  if (s.branch) facts.push({ icon: 'branch', runs: [strong(s.branch.length > BRANCH_MAX ? `${s.branch.slice(0, BRANCH_MAX - 1)}…` : s.branch)] })
257  // The band's own sightings fill in until Token Optimizer's quality file exists (a new session).
258  const startS = q?.sessionStartEpoch ?? (s.startedAtMs != null ? s.startedAtMs / 1000 : null)
259  if (startS != null) facts.push({ icon: 'clock', runs: [strong(duration(s.now / 1000 - startS))] })
260  const tools = Math.max(q?.toolCalls ?? 0, s.toolCallsSeen ?? 0)
261  if (q?.toolCalls != null || (s.toolCallsSeen ?? 0) > 0) facts.push({ icon: 'tool', runs: [strong(String(tools)), plain(tools === 1 ? ' tool' : ' tools')] })
262  const compacted = Math.max(q?.compactions ?? 0, s.compactionsSeen ?? 0)
263  if (compacted > 0) facts.push({ icon: 'compact', runs: [strong(`${compacted}×`), plain(' compacted')] })
264  facts.push({
265    icon: 'bookmark',
266    runs: (s.checkpointEpoch ?? q?.checkpointEpoch) != null
267      ? [plain('Checkpoint '), strong(ago((s.checkpointEpoch ?? q!.checkpointEpoch!) * 1000, s.now))]
268      : s.earlierCheckpoint
269        ? [plain('Earlier checkpoint '), strong(ago(s.earlierCheckpoint.epoch * 1000, s.now))]
270        : [plain('No checkpoint yet')],
271  })
272  return { facts, savings: savingsBlock(s) }
273}
274
src/pose.ts 229 lines
1// Clawd's pose as a pure reducer. A base pose derived from what is
2// going on, plus short transient poses with holds. No I/O, no 'claude-code':
3// register.tsx maps engine events onto PoseEvent and passes the time in.
4import type { Pose } from './contracts.ts'
5
6/** Working sub-pose changes wait this long to settle, so read/type bursts do not strobe. */
7export const DEBOUNCE_MS = 900
8/** A working sub-pose stays on screen at least this long before the next one replaces it. */
9export const MIN_DWELL_MS = 1600
10/** Idle this long (no events) and Clawd naps. Agent default (plan Assumptions). */
11export const NAP_AFTER_MS = 10 * 60_000
12/** How long each transient pose holds. */
13export const HOLD_MS = { wake: 2000, done: 2500, stop: 2500, error: 4000 } as const
14
15type Transient = keyof typeof HOLD_MS
16type SubPose = 'think' | 'read' | 'type' | 'write' | 'lift'
17
18export type PoseEvent =
19  | { type: 'session-start' }
20  | { type: 'turn-start' }
21  | { type: 'working-changed'; working: boolean }
22  | { type: 'thinking' }
23  | { type: 'text' }
24  | { type: 'tool-call'; tool: string; agentId?: string }
25  | { type: 'tool-done'; agentId?: string }
26  | { type: 'agent-spawn'; agentId: string; background: boolean }
27  | { type: 'agent-done'; agentId: string }
28  | { type: 'permission-open' }
29  | { type: 'permission-closed' }
30  | { type: 'question-open' }
31  | { type: 'question-closed' }
32  | { type: 'compact-start' }
33  | { type: 'compact-end' }
34  | { type: 'turn-complete'; reason: 'answer' | 'aborted' | 'refusal' | 'error'; agentId?: string }
35  | { type: 'cache-cold-changed'; cold: boolean }
36  | { type: 'tick'; now: number }
37
38export type PoseState = {
39  /** The pose on screen. */
40  pose: Pose
41  /** When the pose on screen last changed (ms). */
42  since: number
43  /** Last time the reducer saw (ms). */
44  now: number
45  working: boolean
46  /** Latest main-thread working signal, before the debounce. */
47  rawSub: SubPose | null
48  /** When rawSub last changed. */
49  rawSince: number
50  /** The working sub-pose that has settled and may show. */
51  sub: SubPose | null
52  /** Running subagents by id; true = background (outlives the main turn). */
53  agents: Readonly<Record<string, boolean>>
54  permissions: number
55  questions: number
56  compacting: boolean
57  cold: boolean
58  /** Last non-tick, non-cache event (ms); napping counts from here. */
59  lastActivity: number
60  /** Transient hold deadlines (ms); 0 = not playing. */
61  until: Readonly<Record<Transient, number>>
62}
63
64const READ_TOOLS = new Set(['Read', 'Grep', 'Glob', 'WebFetch', 'WebSearch', 'NotebookRead', 'LS'])
65const LIFT_TOOLS = new Set(['Task', 'Agent'])
66
67/** Which pose a main-thread tool call shows. Unknown tools type. */
68export function toolPose(tool: string): 'read' | 'type' | 'lift' {
69  if (READ_TOOLS.has(tool)) return 'read'
70  if (LIFT_TOOLS.has(tool)) return 'lift'
71  return 'type'
72}
73
74const NO_TRANSIENTS: Record<Transient, number> = { wake: 0, done: 0, stop: 0, error: 0 }
75
76export function initialPose(now: number): PoseState {
77  return {
78    pose: 'idle',
79    since: now,
80    now,
81    working: false,
82    rawSub: null,
83    rawSince: now,
84    sub: null,
85    agents: {},
86    permissions: 0,
87    questions: 0,
88    compacting: false,
89    cold: false,
90    lastActivity: now,
91    until: NO_TRANSIENTS,
92  }
93}
94
95/**
96 * One step. `now` defaults to the tick's own time, or the last time seen for
97 * other events; pass it explicitly when the caller knows when the event landed.
98 */
99export function reducePose(state: PoseState, event: PoseEvent, now?: number): PoseState {
100  const t = now ?? (event.type === 'tick' ? event.now : state.now)
101  let s: PoseState = { ...state, now: t }
102  if (event.type !== 'tick' && event.type !== 'cache-cold-changed') s.lastActivity = t
103
104  const setRaw = (sub: SubPose | null): void => {
105    if (s.rawSub === sub) return
106    s.rawSub = sub
107    s.rawSince = t
108  }
109  const play = (tr: Transient): void => {
110    // Dropped, not queued, while Clawd is asking for you.
111    if (needsYou(s)) return
112    s.until = { ...s.until, [tr]: t + HOLD_MS[tr] }
113  }
114  const dropAgent = (id: string): void => {
115    if (!(id in s.agents)) return
116    const rest = { ...s.agents }
117    delete rest[id]
118    s.agents = rest
119  }
120
121  switch (event.type) {
122    case 'session-start':
123      s = { ...initialPose(t), pose: s.pose, since: s.since, cold: s.cold }
124      play('wake')
125      break
126    case 'turn-start':
127      setRaw('think')
128      break
129    case 'working-changed':
130      s.working = event.working
131      if (event.working && s.rawSub === null) setRaw('think')
132      if (!event.working) {
133        setRaw(null)
134        s.sub = null
135      }
136      break
137    case 'thinking':
138      setRaw('think')
139      break
140    case 'text':
141      setRaw('write')
142      break
143    case 'tool-call':
144      // Subagent events only feed heavy lifting, through spawn/done.
145      if (event.agentId === undefined) setRaw(toolPose(event.tool))
146      break
147    case 'tool-done':
148      if (event.agentId === undefined && s.rawSub === 'lift') setRaw('think')
149      break
150    case 'agent-spawn':
151      s.agents = { ...s.agents, [event.agentId]: event.background }
152      break
153    case 'agent-done':
154      dropAgent(event.agentId)
155      break
156    case 'permission-open':
157      s.permissions += 1
158      break
159    case 'permission-closed':
160      s.permissions = Math.max(0, s.permissions - 1)
161      break
162    case 'question-open':
163      s.questions += 1
164      break
165    case 'question-closed':
166      s.questions = Math.max(0, s.questions - 1)
167      break
168    case 'compact-start':
169      s.compacting = true
170      break
171    case 'compact-end':
172      s.compacting = false
173      break
174    case 'turn-complete':
175      if (event.agentId !== undefined) {
176        dropAgent(event.agentId)
177        break
178      }
179      // Foreground agents end with the main turn; background ones keep lifting.
180      s.agents = Object.fromEntries(Object.entries(s.agents).filter(([, bg]) => bg))
181      setRaw(null)
182      s.sub = null
183      play(event.reason === 'error' ? 'error' : event.reason === 'aborted' ? 'stop' : 'done')
184      break
185    case 'cache-cold-changed':
186      s.cold = event.cold
187      break
188    case 'tick':
189      break
190  }
191
192  settle(s, t)
193  const pose = derive(s, t)
194  if (pose !== s.pose) {
195    s.pose = pose
196    s.since = t
197  }
198  return s
199}
200
201function needsYou(s: PoseState): boolean {
202  return s.permissions > 0 || s.questions > 0
203}
204
205/** Debounce: the first sub-pose of a turn shows at once; later changes wait to settle. */
206function settle(s: PoseState, t: number): void {
207  if (s.rawSub === s.sub) return
208  if (s.sub === null || (t - s.rawSince >= DEBOUNCE_MS && t - s.since >= MIN_DWELL_MS)) s.sub = s.rawSub
209}
210
211function playing(s: PoseState, tr: Transient, t: number): boolean {
212  return s.until[tr] > t
213}
214
215/** Precedence: needs-you > dizzy > stopped > compacting > heavy lifting > working > done > wake > cold > napping > watching. */
216function derive(s: PoseState, t: number): Pose {
217  if (needsYou(s)) return 'ask'
218  if (playing(s, 'error', t)) return 'error'
219  if (playing(s, 'stop', t)) return 'stop'
220  if (s.compacting) return 'compact'
221  if (Object.keys(s.agents).length > 0) return 'lift'
222  if (s.working) return s.sub ?? 'think'
223  if (playing(s, 'done', t)) return 'done'
224  if (playing(s, 'wake', t)) return 'wake'
225  if (s.cold) return 'cold'
226  if (t - s.lastActivity >= NAP_AFTER_MS) return 'sleep'
227  return 'idle'
228}
229
hooks/data.ts 474 lines
1// Data gathering and re-keying for the band.
2//
3// The engine refuses `$` passed across an import ("$ is followed only into a
4// function declared in this same file"), and a plugin has exactly one hooks
5// module. So this file never sees `$`: it works over `DataIo`, a port that
6// register.tsx builds from `$` in one top-level function (`dataIo($)`), each
7// member spelled `$.noun.method(...)` there. That keeps every noun call where
8// the engine's scan looks for it, and lets this logic run under plain Node.
9//
10// Every lookup is best effort: a figure that cannot be read is left null and
11// the rest still fill. Nothing here registers a hook; register.tsx wires the
12// events and the cadence below.
13import type { TokenOptimizerDesktopSession } from '../types/index.d.ts'
14import type { Limit, Quality } from '../src/contracts.ts'
15import {
16  parseQualityCache,
17  parseStatusBar,
18  parseUsage,
19  resolveTokenOptimizerRoot,
20  type StatusBar,
21  type TokenOptimizerRoot,
22} from '../src/parse.ts'
23
24/** Redraw once a second, but only inside the cache warning window. */
25export const TICK_WARNING_MS = 1_000
26/** Redraw otherwise, beside the event-driven redraws. */
27export const TICK_IDLE_MS = 60_000
28/** Re-read the quality cache this often, and after each turn. */
29export const QUALITY_REFRESH_MS = 60_000
30/** Run the status command this long after a turn ends. */
31export const STATUS_AFTER_TURN_MS = 5_000
32/** The status command answers from its cache in well under a second warm. */
33export const STATUS_TIMEOUT_MS = 4_000
34/** `git branch --show-current` is local and instant; anything slower is skipped. */
35export const GIT_TIMEOUT_MS = 1_000
36
37/** Shown as the savings reason when no Token Optimizer install is found. */
38export const NOT_FOUND = 'Token Optimizer not found'
39/** No Python 3 launcher starts: Token Optimizer's scripts cannot run. */
40export const NO_PYTHON = 'Token Optimizer needs Python 3 to measure savings.'
41/** The installed Token Optimizer predates the status command this band reads. */
42export const OUTDATED = 'Update Token Optimizer to see savings.'
43
44/**
45 * Python launchers in the order tried. The mod cannot see the host's OS, so a
46 * launcher that cannot start (or Windows' Store stub, exit 9009) moves on to
47 * the next: python3 on macOS and Linux, python or the `py -3` launcher on
48 * Windows. The first that starts is remembered for the module's life.
49 */
50export const PYTHON_LAUNCHERS: readonly (readonly string[])[] = [['python3'], ['python'], ['py', '-3']]
51
52/** Windows' "app execution alias" stub for a missing python exits with this. */
53const WINDOWS_NOT_FOUND = 9009
54
55let launcherIndex = 0
56
57/** Forgets which launcher worked (tests, and nothing else). */
58export function resetLauncher(): void {
59  launcherIndex = 0
60}
61
62/**
63 * What the gatherer needs from the engine. register.tsx answers each member
64 * with the `$` call named beside it; any member may reject.
65 */
66export type DataIo = {
67  /** `$.clock.now()` (ms) */
68  now: () => Promise<number>
69  /** `$.session.id()` */
70  sessionId: () => Promise<string>
71  /** `$.session.cwd()` */
72  cwd: () => Promise<string>
73  /** `$.env.get('HOME')` (the validator wants a literal variable name) */
74  envHome: () => Promise<string | undefined>
75  /** `$.env.get('CLAUDE_CONFIG_DIR')`: a relocated Claude folder, as Token Optimizer honours it */
76  envConfigDir?: () => Promise<string | undefined>
77  /** `$.env.get('USERPROFILE')`, the Windows home */
78  envUserProfile: () => Promise<string | undefined>
79  /** `$.session.usage()` */
80  usage: () => Promise<unknown>
81  /** `$.fs.list(path)` */
82  list: (path: string) => Promise<readonly { name: string }[]>
83  /** `$.fs.stat(path)` */
84  stat: (path: string) => Promise<{ kind: string; mtimeMs: number }>
85  /** `$.fs.read(path)` */
86  read: (path: string) => Promise<string>
87  /** `$.plugin.root`: this plugin's own folder */
88  pluginRoot?: () => string
89  /** `$.process.run(argv, init)` */
90  run: (argv: string[], init: { cwd?: string; timeoutMs: number; stdin?: string }) => Promise<{ exitCode: number; stdout: string; stderr?: string }>
91  /** `$.ui.log(text, { to: 'debug' })`: a line in Claude Code's debug log */
92  log?: (text: string) => Promise<void>
93}
94
95async function attempt<T>(work: () => Promise<T>, fallback: T): Promise<T> {
96  try {
97    return await work()
98  } catch {
99    return fallback
100  }
101}
102
103/** Session ids become file names; keep only what Token Optimizer's own sanitizer keeps. */
104export function cleanId(id: string): string {
105  const clean = id.replace(/[^a-zA-Z0-9_-]/g, '')
106  // Token Optimizer files an id shorter than 6 under "unknown": read nothing for it either.
107  return clean.length >= 6 ? clean : ''
108}
109
110/** True when the stored atom belongs to another session (or to none) and must be reset. */
111export function shouldReset(stored: TokenOptimizerDesktopSession | null, liveSessionId: string): boolean {
112  return stored === null || stored.sessionId !== liveSessionId
113}
114
115/** The user's home, as the engine's process sees it. */
116export async function readHome(io: DataIo): Promise<string> {
117  return (await attempt(() => io.envHome(), undefined)) || (await attempt(() => io.envUserProfile(), undefined)) || ''
118}
119
120/** The Claude folder: CLAUDE_CONFIG_DIR when set (as Token Optimizer's claude_home()), else ~/.claude. */
121export async function claudeDir(io: DataIo, home: string): Promise<string> {
122  const set = io.envConfigDir ? await attempt(() => io.envConfigDir!(), undefined) : undefined
123  const dir = set ? set.trim().replace(/[\\/]+$/, '') : ''
124  // Absolute only, as Token Optimizer requires: a relative one would point the band elsewhere.
125  const absolute = dir.startsWith('/') || /^[A-Za-z]:[\\/]/.test(dir)
126  return absolute ? dir : home ? `${home}/.claude` : ''
127}
128
129/**
130 * The freshest `quality-cache-<sid>.json` across Token Optimizer's storage
131 * directories: each plugin install's data dir, then the legacy
132 * `~/.claude/token-optimizer`. Newest modification time wins.
133 */
134export async function readQuality(io: DataIo, home: string, sid: string): Promise<Quality | null> {
135  if (!home || !sid) {
136    return null
137  }
138
139  const claude = await claudeDir(io, home)
140  const dataRoot = `${claude}/plugins/data`
141  const entries = await attempt(() => io.list(dataRoot), [])
142  const dirs = entries
143    .filter(entry => entry.name.includes('token-optimizer'))
144    .map(entry => `${dataRoot}/${entry.name}/token-optimizer`)
145  dirs.push(`${claude}/token-optimizer`)
146
147  let freshest: { path: string; mtimeMs: number } | null = null
148
149  for (const dir of dirs) {
150    const path = `${dir}/quality-cache-${sid}.json`
151    const stat = await attempt(() => io.stat(path), null)
152
153    if (stat && stat.kind === 'file' && (!freshest || stat.mtimeMs > freshest.mtimeMs)) {
154      freshest = { path, mtimeMs: stat.mtimeMs }
155    }
156  }
157
158  if (!freshest) {
159    return null
160  }
161
162  const { path } = freshest
163  const raw = await attempt(() => io.read(path), null)
164  const nowMs = await attempt(() => io.now(), Date.now())
165
166  return raw === null ? null : parseQualityCache(raw, nowMs / 1000)
167}
168
169/**
170 * Token Optimizer's scripts: the installed-plugins registry first, then
171 * the skill install; the first whose measure.py exists. null when neither does.
172 */
173export async function findTokenOptimizerRoot(io: DataIo, home: string): Promise<TokenOptimizerRoot | null> {
174  const claude = await claudeDir(io, home)
175  const registry = claude ? await attempt(() => io.read(`${claude}/plugins/installed_plugins.json`), null) : null
176  // Shipped inside Token Optimizer, the plugin root is Token Optimizer itself; loaded on its
177  // own from a checkout (desktop/<this plugin>), the scripts two folders up are the matching version.
178  const root = io.pluginRoot ? await attempt(async () => io.pluginRoot!(), '') : ''
179  const sibling = [root.replace(/[\\/]+$/, ''), trimTwo(root)]
180    .filter(dir => dir !== '')
181    .map(dir => ({ scriptsDir: `${dir}/skills/token-optimizer/scripts`, runner: `${dir}/hooks/module_runner.py` }))
182
183  // Only scripts inside the Claude folder run (or beside this plugin in a checkout): a
184  // tampered or stale registry entry pointing elsewhere is skipped, never executed.
185  // Compared with one separator and one case of drive letter, so a Windows home
186  // (C:\\Users\\me) and a registry path (C:\\Users\\me\\.claude\\...) agree.
187  const norm = (p: string) => p.replace(/\\/g, '/').replace(/^([a-zA-Z]):/, (_m: string, d: string) => `${d.toLowerCase()}:`)
188  // Windows paths compare without case, as the filesystem does.
189  const fold = (p: string) => (/^[a-z]:\//.test(norm(claude)) ? norm(p).toLowerCase() : norm(p))
190  const base = fold(claude)
191  const inside = (p: string) => base !== '' && (fold(p) === base || fold(p).startsWith(`${base}/`))
192  const listed = resolveTokenOptimizerRoot(registry, claude).filter(r => inside(r.scriptsDir))
193  for (const root of [...sibling, ...listed]) {
194    if (!(await attempt(() => io.stat(`${root.scriptsDir}/measure.py`), null))) {
195      continue
196    }
197
198    const { runner } = root
199    const hasRunner = runner !== null && (await attempt(() => io.stat(runner), null)) !== null
200
201    return { scriptsDir: root.scriptsDir, runner: hasRunner ? runner : null }
202  }
203
204  return null
205}
206
207function newer(a: number | null, b: number | null): number | null {
208  return a === null ? b : b === null ? a : Math.max(a, b)
209}
210
211/** A path two folders up, or '' when it has fewer. */
212function trimTwo(path: string): string {
213  const parts = path.replace(/[\\/]+$/, '').split(/[\\/]/)
214  return parts.length > 2 ? parts.slice(0, -2).join('/') : ''
215}
216
217/** The argv for `measure.py <args>`, through module_runner when the install has it (bytecode reuse). */
218export function measureArgv(root: TokenOptimizerRoot, args: readonly string[], python: readonly string[] = PYTHON_LAUNCHERS[0] ?? ['python3']): string[] {
219  const launch = root.runner ? [...python, root.runner, root.scriptsDir, 'measure'] : [...python, `${root.scriptsDir}/measure.py`]
220
221  return [...launch, ...args]
222}
223
224/** The arguments of `measure.py status-bar` for one session. */
225function statusBarArgs(sid: string, transcript?: string): string[] {
226  return ['status-bar', '--session', sid, '--json', ...(transcript ? ['--transcript', transcript] : [])]
227}
228
229function isTimeout(error: unknown): boolean {
230  const name = error instanceof Error ? error.name : ''
231  return name === 'TimeoutError' || /timed? ?out|deadline/i.test(error instanceof Error ? error.message : String(error))
232}
233
234/** No Python launcher could start at all (every one was tried). */
235function noLauncher(error: unknown): boolean {
236  return /no python launcher|python launcher not found|ENOENT|not found/i.test(error instanceof Error ? error.message : String(error))
237}
238
239/**
240 * Runs `measure.py <args>` with the first Python launcher that starts. A
241 * timeout is the command's own answer and is never retried, so a slow read
242 * costs one timeout, not three. Rejects as the last attempt did.
243 */
244export async function runMeasure(
245  io: DataIo,
246  root: TokenOptimizerRoot,
247  args: readonly string[],
248  init: { timeoutMs: number; stdin?: string },
249): Promise<{ exitCode: number; stdout: string; stderr?: string }> {
250  let lastError: unknown = new Error('no python launcher')
251
252  for (let i = launcherIndex; i < PYTHON_LAUNCHERS.length; i++) {
253    try {
254      const result = await io.run(measureArgv(root, args, PYTHON_LAUNCHERS[i]), init)
255
256      if (result.exitCode === WINDOWS_NOT_FOUND && i < PYTHON_LAUNCHERS.length - 1) {
257        lastError = new Error('python launcher not found')
258        continue
259      }
260
261      launcherIndex = i
262      return result
263    } catch (error) {
264      if (isTimeout(error)) {
265        throw error
266      }
267
268      lastError = error
269    }
270  }
271
272  throw lastError
273}
274
275/**
276 * `measure.py status-bar --json`; 'outdated' when the install has no
277 * such command (it prints usage, not JSON), null when it fails, times out or
278 * prints something else.
279 */
280export async function readStatusBar(
281  io: DataIo,
282  root: TokenOptimizerRoot,
283  sid: string,
284  transcript?: string,
285): Promise<StatusBar | 'outdated' | 'nopython' | null> {
286  const args = statusBarArgs(sid, transcript)
287  let result: { exitCode: number; stdout: string; stderr?: string } | 'nopython' | null = null
288  try {
289    result = await runMeasure(io, root, args, { timeoutMs: STATUS_TIMEOUT_MS })
290  } catch (error) {
291    result = noLauncher(error) ? 'nopython' : null
292  }
293
294  if (result === 'nopython') return 'nopython'
295  if (!result) return null
296  if (result.exitCode !== 0 && io.log) {
297    // Why the status read failed, where `claude --debug` shows it.
298    void attempt(() => io.log!(`token-optimizer status bar: status-bar exited ${result.exitCode}: ${(result.stderr ?? '').trim().slice(0, 300)}`), undefined)
299  }
300  // The JSON is the last line that is one (a stray line printed before it is not a reason to fail).
301  const json = result.stdout.split(/\r?\n/).map(l => l.trim()).filter(l => l.startsWith('{')).pop()
302  // An install without the command prints its usage instead of JSON (or exits 2).
303  // Usage text instead of JSON is an older install; empty output is just a failed read.
304  if (!json) return (result.exitCode === 0 || result.exitCode === 2) && result.stdout.trim() !== '' ? 'outdated' : null
305  return result.exitCode === 0 ? parseStatusBar(json) : null
306}
307
308/** The current git branch; null outside a repository, on a detached HEAD, or without git. */
309export async function readBranch(io: DataIo, cwd: string): Promise<string | null> {
310  const init = cwd ? { cwd, timeoutMs: GIT_TIMEOUT_MS } : { timeoutMs: GIT_TIMEOUT_MS }
311  const result = await attempt(() => io.run(['git', 'branch', '--show-current'], init), null)
312  const branch = result && result.exitCode === 0 ? result.stdout.trim() : ''
313
314  return branch === '' ? null : branch
315}
316
317export type GatherOptions = {
318  /** The live session id when the caller knows it better than `$.session.id()` (a classic SessionStart's `session_id`). */
319  sessionId?: string
320  /** Start over even when the id matches (a clear). */
321  reset?: boolean
322  /** Run the status command (start, 5 s after a turn, row opened). */
323  savings?: boolean
324  /** The session's transcript, so the status command need not look it up. */
325  transcript?: string
326}
327
328/**
329 * The per-session part of a Snapshot. Starts from `previous` only when it
330 * belongs to this session, so nothing carries over a clear. Savings and
331 * the clock facts come from the status command when asked for; when it is not
332 * asked, fails or times out, the last known values stay.
333 */
334export async function gather(
335  io: DataIo,
336  previous: TokenOptimizerDesktopSession | null,
337  options: GatherOptions = {},
338): Promise<TokenOptimizerDesktopSession> {
339  const now = await attempt(() => io.now(), Date.now())
340  const sid = cleanId(options.sessionId ?? (await attempt(() => io.sessionId(), '')))
341  const base = options.reset || shouldReset(previous, sid) ? null : previous
342  const cwd = await attempt(() => io.cwd(), '')
343  const home = await readHome(io)
344  const reported = parseUsage(await attempt(() => io.usage(), null))
345  // A limit does not vanish mid-session: a refresh that comes back without one (right
346  // after a compact, say) keeps the last known value; once that window has renewed, 0%.
347  const keep = (fresh: Limit | null, last: Limit | null | undefined): Limit | null => {
348    if (fresh) return fresh
349    // Kept only until its own renewal: after that the old figure is wrong, and an unreadable
350    // renewal time is no renewal time (either could otherwise stay on screen for good).
351    if (!last || last.resetsAt === null) return null
352    const renews = Date.parse(last.resetsAt)
353    return Number.isFinite(renews) && renews > now ? last : null
354  }
355  const usage = { ...reported, fiveHour: keep(reported.fiveHour, base?.fiveHour), week: keep(reported.week, base?.week) }
356  const sawLimits = Boolean(base?.sawLimits || reported.fiveHour || reported.week)
357  const branch = await readBranch(io, cwd)
358  const quality = await readQuality(io, home, sid)
359
360  let status: StatusBar | 'outdated' | 'nopython' | null = null
361  let notFound = false
362
363  if (options.savings && sid) {
364    const root = await findTokenOptimizerRoot(io, home)
365    notFound = root === null
366    status = root ? await readStatusBar(io, root, sid, options.transcript) : null
367  }
368
369  const kept = {
370    savings: base?.savings ?? null,
371    savingsState: base?.savingsState ?? ('unavailable' as const),
372    savingsReason: base?.savingsReason ?? null,
373    lastRequestEpoch: base?.lastRequestEpoch ?? null,
374    cacheLifetime: base?.cacheLifetime ?? null,
375    checkpointEpoch: base?.checkpointEpoch ?? null,
376    earlierCheckpoint: base?.earlierCheckpoint ?? null,
377    compactions: base?.compactions ?? null,
378  }
379  const seen = { toolCallsSeen: base?.toolCallsSeen ?? 0, compactionsSeen: base?.compactionsSeen ?? 0 }
380
381  const facts = notFound
382    ? { ...kept, savings: null, savingsState: 'unavailable' as const, savingsReason: NOT_FOUND, checkpointEpoch: null }
383    : status === 'outdated'
384      ? { ...kept, savings: null, savingsState: 'unavailable' as const, savingsReason: OUTDATED }
385    : status === 'nopython'
386      ? { ...kept, savings: null, savingsState: 'unavailable' as const, savingsReason: NO_PYTHON }
387    : status
388      ? {
389          // While savings load, the last known figures stay on show.
390          savings: status.savings ?? (status.savingsState === 'loading' ? kept.savings : null),
391          savingsState: status.savingsState,
392          savingsReason: status.savings ? null : status.savingsReason,
393          lastRequestEpoch: status.lastRequestEpoch ?? kept.lastRequestEpoch,
394          cacheLifetime: status.cacheLifetime ?? kept.cacheLifetime,
395          checkpointEpoch: status.checkpointEpoch,
396          earlierCheckpoint: status.earlierCheckpoint,
397          compactions: status.compactions ?? kept.compactions,
398        }
399      : kept
400
401  // The higher count wins: the quality cache can lag a compaction that just landed.
402  // Never lower than the band already knew this session (it may have seen the compaction itself).
403  const known = Math.max(
404    'compactions' in facts && facts.compactions != null ? facts.compactions : 0,
405    base?.quality?.compactions ?? 0,
406  )
407  const counted = quality && known > quality.compactions ? { ...quality, compactions: known } : quality
408  // A status answer has already checked the checkpoint file is still on disk, so it wins
409  // over the quality cache, which keeps naming a save after retention removes it.
410  const answered = status !== null && typeof status === 'object'
411  const checkpointEpoch = notFound ? null : answered ? facts.checkpointEpoch : newer(quality?.checkpointEpoch ?? null, facts.checkpointEpoch)
412  const merged = counted && answered ? { ...counted, checkpointEpoch } : counted
413
414  return {
415    sessionId: sid,
416    gatheredAt: now,
417    quality: merged,
418    ...usage,
419    branch,
420    ...facts,
421    // Without a status answer, the newer of the two: the quality cache knows quality saves
422    // the moment they land, the status command also knows stop and compaction saves.
423    ...seen,
424    sawLimits,
425    checkpointEpoch,
426    sheetOpen: base?.sheetOpen ?? false,
427  }
428}
429
430/**
431 * What to store when `fresh` lands on top of `current` (read inside
432 * `update`): keeps a row toggle made while gathering, never across sessions
433 * or a forced reset.
434 */
435export function mergeStored(
436  current: TokenOptimizerDesktopSession | null,
437  fresh: TokenOptimizerDesktopSession,
438  reset = false,
439  savingsRead = true,
440): TokenOptimizerDesktopSession {
441  const sameSession = !reset && current !== null && current.sessionId === fresh.sessionId
442
443  // A refresh that began before a compaction landed must not write its count back down.
444  const counted = sameSession ? Math.max(current.quality?.compactions ?? 0, fresh.quality?.compactions ?? 0) : null
445  const quality = fresh.quality && counted !== null && counted > fresh.quality.compactions ? { ...fresh.quality, compactions: counted } : fresh.quality
446
447  // The band's own counts only grow; a refresh that read them earlier cannot undo a later one.
448  const seen = sameSession
449    ? {
450        toolCallsSeen: Math.max(current.toolCallsSeen ?? 0, fresh.toolCallsSeen ?? 0),
451        compactionsSeen: Math.max(current.compactionsSeen ?? 0, fresh.compactionsSeen ?? 0),
452      }
453    : {}
454
455  // A refresh that did not run the status command only carried its older copy of what that
456  // command reports: a slower savings refresh that landed meanwhile keeps its figures.
457  const statusFacts =
458    sameSession && !savingsRead
459      ? {
460          savings: current.savings,
461          savingsState: current.savingsState,
462          savingsReason: current.savingsReason,
463          earlierCheckpoint: current.earlierCheckpoint,
464          cacheLifetime: current.cacheLifetime ?? fresh.cacheLifetime,
465          lastRequestEpoch: newer(current.lastRequestEpoch, fresh.lastRequestEpoch),
466          checkpointEpoch: newer(current.checkpointEpoch, fresh.checkpointEpoch),
467          compactions:
468            current.compactions == null ? fresh.compactions : fresh.compactions == null ? current.compactions : Math.max(current.compactions, fresh.compactions),
469        }
470      : {}
471
472  return { ...fresh, ...statusFacts, ...seen, quality, sheetOpen: sameSession ? current.sheetOpen : fresh.sheetOpen }
473}
474
src/format.ts 116 lines
1// Pure formatting for the band: clocks, token counts, relative and local times,
2// grades. No I/O and nothing from 'claude-code'.
3
4/** Injectable zone and locale so tests do not depend on the machine's clock settings. */
5export type FormatOptions = { timeZone?: string; locale?: string }
6
7const MINUTE = 60_000
8const DAY_MIN = 24 * 60
9
10/** Seconds as m:ss, clamped to 0:00..60:00 (the longest cache lifetime is an hour). */
11export function clock(sec: number): string {
12  const s = Math.min(3600, Math.max(0, Math.floor(Number.isFinite(sec) ? sec : 0)))
13  return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
14}
15
16/** Minutes left as 52m; a countdown that only needs to move once a minute. */
17export function minutes(sec: number): string {
18  const s = Math.min(3600, Math.max(0, Number.isFinite(sec) ? sec : 0))
19  return `${Math.ceil(s / 60)}m`
20}
21
22/** Token counts as 940, 340k, 1.2M, 50M. */
23export function tokens(n: number): string {
24  if (!Number.isFinite(n) || n < 0) return '--'
25  if (n < 1000) return String(Math.round(n))
26  if (n < 999_500) return `${Math.round(n / 1000)}k`
27  const scale = n < 999_500_000 ? { d: 1e6, u: 'M' } : { d: 1e9, u: 'B' }
28  const v = n / scale.d
29  const text = v < 9.95 ? v.toFixed(1).replace(/\.0$/, '') : String(Math.round(v))
30  return text + scale.u
31}
32
33/** Time until `targetMs` from `nowMs`: "in 48 minutes", "in 2h 14m", "in 6 days". Past targets read as `ago`. */
34export function relative(targetMs: number, nowMs: number): string {
35  const diff = targetMs - nowMs
36  if (diff < 0) return ago(targetMs, nowMs)
37  const mins = Math.round(diff / MINUTE)
38  if (mins < 1) return 'in under a minute'
39  if (mins < 60) return mins === 1 ? 'in 1 minute' : `in ${mins} minutes`
40  if (mins < DAY_MIN) {
41    const m = mins % 60
42    return m === 0 ? `in ${Math.floor(mins / 60)}h` : `in ${Math.floor(mins / 60)}h ${String(m).padStart(2, '0')}m`
43  }
44  const days = Math.floor(mins / DAY_MIN)
45  if (days < 2) {
46    const h = Math.floor((mins % DAY_MIN) / 60)
47    return h === 0 ? 'in 1 day' : `in 1 day ${h}h`
48  }
49  return `in ${days} days`
50}
51
52/** Time since `thenMs`: "just now", "3 min ago", "2h 5m ago", "3 days ago". */
53export function ago(thenMs: number, nowMs: number): string {
54  const mins = Math.floor(Math.max(0, nowMs - thenMs) / MINUTE)
55  if (mins < 1) return 'just now'
56  if (mins < 60) return `${mins}m ago`
57  if (mins < DAY_MIN) {
58    const m = mins % 60
59    return m === 0 ? `${Math.floor(mins / 60)}h ago` : `${Math.floor(mins / 60)}h ${m}m ago`
60  }
61  const days = Math.floor(mins / DAY_MIN)
62  return days === 1 ? '1 day ago' : `${days} days ago`
63}
64
65/** A span in seconds: "48m", "1h 5m", "under 1m". */
66export function duration(sec: number): string {
67  const s = Math.max(0, Math.floor(sec))
68  if (s < 60) return 'under 1m'
69  const mins = Math.floor(s / 60)
70  if (mins < 60) return `${mins}m`
71  return `${Math.floor(mins / 60)}h ${mins % 60}m`
72}
73
74function clean(text: string): string {
75  // Newer ICU puts a narrow no-break space before AM/PM.
76  return text.replace(/[  ]/g, ' ')
77}
78
79function dayNumber(ms: number, timeZone: string | undefined): number {
80  const parts = new Intl.DateTimeFormat('en-US', { timeZone, year: 'numeric', month: 'numeric', day: 'numeric' }).formatToParts(ms)
81  const get = (t: string) => Number(parts.find((p) => p.type === t)?.value)
82  return Date.UTC(get('year'), get('month') - 1, get('day')) / 86_400_000
83}
84
85/** Local renewal time from an ISO reset: "today at 3:20 PM", "tomorrow at 9:00 AM", "Thursday, Oct 8 at 9:00 AM". Null when unparseable. */
86export function renewal(iso: string | null, nowMs: number, opts: FormatOptions = {}): string | null {
87  if (!iso) return null
88  const at = Date.parse(iso)
89  if (!Number.isFinite(at)) return null
90  const { timeZone, locale } = opts
91  const time = clean(new Intl.DateTimeFormat(locale, { timeZone, hour: 'numeric', minute: '2-digit' }).format(at))
92  const diff = dayNumber(at, timeZone) - dayNumber(nowMs, timeZone)
93  if (diff === 0) return `today at ${time}`
94  if (diff === 1) return `tomorrow at ${time}`
95  const weekday = new Intl.DateTimeFormat(locale, { timeZone, weekday: 'long' }).format(at)
96  const month = new Intl.DateTimeFormat(locale, { timeZone, month: 'short' }).format(at)
97  const day = new Intl.DateTimeFormat(locale, { timeZone, day: 'numeric' }).format(at)
98  return `${weekday}, ${month} ${day} at ${time}`
99}
100
101/** Renewal for use after "Renews": "at 3:20 PM" today, otherwise as `renewal`. */
102export function renewalPhrase(iso: string | null, nowMs: number, opts: FormatOptions = {}): string | null {
103  const r = renewal(iso, nowMs, opts)
104  return r && r.startsWith('today ') ? r.slice('today '.length) : r
105}
106
107/** Letter grade with the same boundaries as score_to_grade in measure.py. */
108export function gradeOf(score: number): 'S' | 'A' | 'B' | 'C' | 'D' | 'F' {
109  if (score >= 90) return 'S'
110  if (score >= 80) return 'A'
111  if (score >= 70) return 'B'
112  if (score >= 55) return 'C'
113  if (score >= 40) return 'D'
114  return 'F'
115}
116