SLOPSHOPPER

relay

Context and prompt-cache meter above the prompt, one-click handoffs to a fresh session, and cache warming with a cold-cache guard.

newpanebandcommandpromptmodel
v0.1.0MITupdated 2026-10-14hearsawho25/roberts-mods/relay
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · relay
│ ┃ Handoff ✕ › fix the failing auth test and add an audit log call │ ┃ OK │ ┃ Saved to ⏺ Read(src/auth.ts) │ ┃ /Users/dev/.claude/handoffs/-work-app/2025-1 ⎿ Read 6 lines │ ┃ 0-09-085320-ok.md ⏺ Update(src/auth.ts) │ ┃ [ Clear & continue ] [ Copy ] [ Cancel ] ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ OK ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /relay │ ⎿ relay: Relay is on. │ ⎿ relay: Context: 49% · 97k of a 200k window │ ⎿ relay: Prompt cache: warm · 59m left │ ⎿ relay: Handoff: offered at 80% or 300k tokens │ ⎿ relay: Warming: on, window ends in 1h 59m · next ping in 49m · o │ ⎿ relay: Waiting handoffs: none │ │ ⟨Claude Code's own drawing⟩ ◆ Relay ctx ▰▰▰▰▱▱▱▱ 49% · 97k cache ● warm · 59m left · warming · next ping 49m[ Handoff ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ ◆ Relay ctx ▰▰▰▰▱▱▱▱ 49% · 97k cache ● warm · 59m left · warming · next ping 49m[ Handoff ]
Pane · Handoff
OK Saved to /Users/dev/.claude/handoffs/-work-app/2025-10-09-085320-ok.m d [ Clear & continue ] [ Copy ] [ Cancel ] OK
README

Relay

A Claude Code mod with three jobs:

  1. A meter row above the prompt, showing how full the context is, whether the prompt cache is warm, and a Handoff button.
  2. Handoffs. Relay writes a brief for a fresh session, runs /clear, and puts the brief in the prompt box. A handoff you never used is offered in later sessions in the same project.
  3. Cache warming and a cold-cache guard. These replace the cache-tax mod.

The row

◆ Relay   ctx ▰▰▰▰▰▱▱▱ 58% · 231k   cache ● warm · 41m left · warming · next ping 12m   [ Handoff ]
  • Cache state is counted from the last main-thread request (the cache lasts one hour):
  • warm: more than 10 minutes left
  • cooling: 10 minutes or less left
  • cold: past the hour
  • unknown: no reply yet, or just after /clear or compaction
  • Warming adds · warming · next ping 12m while a window is armed. Once warming stops it shows · warming stopped: <reason> until the next reply.
  • On a narrow terminal the meter bar goes first, then the time left, then the warming text. The button always stays.

The row changes for these situations:

SituationRow
Context at 80% or 300k tokensContext is at 81% (322k). Hand off and start fresh? [ Handoff now ] [ Not yet ]. "Not yet" waits until 90% or 400k.
Handoff asked for while the cache is coldCache is cold — writing the handoff re-writes ~240k tokens (≈ $2.40). [ Write it anyway ] [ Cancel ]
A typed prompt held by the cold guardCache went cold 9h ago. Sending this re-writes ~240k tokens (≈ $4.80). [ Handoff & start fresh ] [ Send anyway ]
A handoff written earlier and never used (within 7 days)An extra line above the row: Handoff waiting: "<title>" · from 10h ago [ Load ] [ Dismiss ]
A handoff being written◆ Relay Writing handoff…

Handoffs

Start one with the Handoff button, "Handoff now", or /handoff.

What Relay does:

  1. Gathers git facts with read-only commands: branch, HEAD, ahead/behind, and uncommitted files.
  2. Asks the session itself, through a fork, for a brief with these parts: title, Where things stand, Read first, What's next, Hard rules, How to run it.
  3. Saves the brief and opens a pane with three buttons:
  4. Clear & continue copies the brief, runs /clear, and fills the prompt box. Nothing is sent until you press Enter.
  5. Copy puts the brief on the clipboard.
  6. Cancel closes the pane. The handoff stays saved and is offered later.

Warming and the overnight handoff

When a window is armed. After each reply, a session with at least 50k tokens of context gets a 2-hour warming window.

Pings. After 50 idle minutes Relay sends one tiny fork ("Reply with the single word: warm"). This reads the cache, which keeps it alive for another hour. Pings repeat every 50 idle minutes until the window ends. A new reply moves the next ping, and nothing is sent while a turn runs.

Warming stops in any of these cases:

  • a ping read nothing
  • a ping wrote 10% or more of what it read
  • a ping failed
  • the window ended
  • /keepwarm off
  • Relay is switched off

The overnight handoff. When the next ping would fall after the window ends, the last ping writes a handoff instead. It is saved unused, with no pane and no clear. Relay toasts "Relay saved a handoff before the cache cooled: <title>" and then lets the cache go cold. The handoff is skipped if one was already saved since the last reply. A /keepwarm window shorter than the first ping gets its handoff when the window ends, provided the cache is still warm. Turn this off with /relay overnight off.

The cold-cache guard

What it holds. When the cache is cold and context is at least 50k tokens, the guard holds the first prompt you type, once per cold spell, and shows the cost estimate on the row.

What it never holds:

  • slash commands
  • ! bash input
  • prompts typed while a turn runs
  • prompts with attachments, @ mentions, or context added by a hook
  • prompts from plugins, peers, or schedules

Your choices once a prompt is held:

  • Send anyway sends the held text as your own words.
  • Handoff & start fresh writes the handoff without asking about the cold cost. The held text goes below the handoff after the clear.
  • Send it again yourself. Typing and sending a second time also lets it through.

Held text is never lost. If the handoff fails or is cancelled, a reply arrives, you /clear, or Relay is switched off, the held text goes back into the prompt box with "Your held message is back in the prompt box."

The cost estimate uses the model's 1-hour cache-write price. For a resumed session it uses the engine's own estimated_cache_write_usd. When the model's price is unknown it shows tokens only.

Commands

CommandDoes
/relayStatus card: context, cache, handoff threshold, warming (window, next ping, last ping cost, overnight setting), and waiting handoffs.
/relay on / /relay offGlobal switch (default on). Off means no row, no warming and no guard. Other sessions pick it up at their next turn or ping.
/relay overnight on / offThe overnight handoff (default on).
/handoffWrite a handoff now.
/handoff listThe last 20 handoffs, this project's first, with path and whether each was used.
/keepwarm 90m / 2h / 2h30mWarm for that window, from now (max 12h).
/keepwarm offStop warming for this session. Replies no longer arm a window until /keepwarm <duration>.
/keepwarm or /keepwarm statusThe warming line.

Where things are kept

Handoff files: ~/.claude/handoffs/<project-key>/<YYYY-MM-DD-HHmmss>-<slug>.md. The project key is the session root with :, \, / and spaces turned into -.

Store keys (in the mod's own store):

KeyHolds
relay.settings.enabledThe global on/off switch.
relay.settings.overnightHandoffThe overnight handoff setting.
relay.handoffsThe handoff index, newest first, capped at 50.
relay.fillAfterClearWhat to put in the prompt box once /clear has run. Kept for 10 minutes.
relay.warming.<session id>This session's warming window.
relay.heldText.<session id>A prompt the guard is holding.

At each session start, Relay removes warming records whose window has passed, /keepwarm off flags older than 3 days, and held prompts older than 7 days.

Uninstall

claude plugin uninstall relay@roberts-mods

Handoff files in ~/.claude/handoffs stay until you delete them.

Credits

The warming loop, its stop rules, the hold-once cold guard, the duration parser and the price table are adapted from the cache-tax mod (MIT License, Copyright (c) 2026 Karan Bansal).

Source 4 files
hooks/register.tsx 1428 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PromptSubmitInput, Register, RenderElement, SessionContextUsage, Timer, UiPressArgument } from 'claude-code'
3
4import type { RelayColdGuard, RelayContext, RelayDraft, RelayFillMarker, RelayHandoff, RelayOffer, RelayWarming } from '../types'
5import {
6  CACHE_TTL_MS,
7  FILL_MAX_AGE_MS,
8  HANDOFF_BUTTON_WIDTH,
9  HANDOFF_NOW_WIDTH,
10  KEEPWARM_HELP,
11  MIN_WARM_TOKENS,
12  NOT_YET_WIDTH,
13  PING_IDLE_MS,
14  PING_PROMPT,
15  ROW_TITLE,
16  addHandoff,
17  buildHandoffPrompt,
18  cacheState,
19  coldDropText,
20  coldGuardText,
21  coldHandoffText,
22  contextPercent,
23  estimateRewriteUsd,
24  extractTitle,
25  fillText,
26  fit,
27  forkFailureText,
28  forkStopReason,
29  formatGitFacts,
30  formatTokens,
31  handoffDir,
32  handoffFileName,
33  handoffListText,
34  joinPath,
35  markConsumed,
36  meterRow,
37  meterRowText,
38  modelPrice,
39  offerText,
40  parseDuration,
41  parseGitFacts,
42  pendingFor,
43  pendingLine,
44  pingProblem,
45  pingUsd,
46  projectKey,
47  readIndex,
48  shouldOfferHandoff,
49  slugify,
50  statusCard,
51  thresholdText,
52  warmingLine,
53  warmingSuffix,
54} from './core'
55import type { GitOutputs } from './core'
56import {
57  HELD_KEY_PREFIX,
58  NO_WARMING,
59  WARMING_KEY_PREFIX,
60  afterReply,
61  dueAction,
62  dueAt,
63  guardVerdict,
64  isGuardable,
65  isStale,
66  isWorthKeeping,
67  keepwarmWindow,
68  readHeld,
69  readWarming,
70  restoredWarming,
71  stopped,
72} from './warming'
73
74/**
75 * Relay: a meter row above the prompt (context fill, prompt-cache state, a
76 * Handoff button), the handoff threshold prompt, the handoff flow, the `/relay`
77 * switch, and the wiring for warming and the cold-cache guard (warming.ts).
78 * Pure logic lives in core.ts and warming.ts; this file holds every hook, every
79 * function that takes `$`, every atom and draws the band (the validator follows `$`
80 * and atoms only within the file that holds the hooks).
81 */
82
83type Engine = EngineInterface
84
85/** Robert's Toolbox palette. */
86const COLORS = {
87  gold: '#F2C46D',
88  muted: '#7D8590',
89  green: '#3DD68C',
90  text: '#E6EDF3',
91  line: '#262B33',
92} as const
93
94const ENABLED_KEY = 'relay.settings.enabled'
95/** Whether the window's last ping writes a handoff instead (default true). */
96const OVERNIGHT_KEY = 'relay.settings.overnightHandoff'
97/** The handoff index: RelayHandoff[], newest first, capped at 50. */
98const HANDOFFS_KEY = 'relay.handoffs'
99/** RelayFillMarker: what to put in the prompt box once /clear has run (survives a module reset). */
100const FILL_KEY = 'relay.fillAfterClear'
101
102const enabledAtom = atom({ plugin: 'relay', key: 'enabled' } as const, true)
103const snoozedAtom = atom({ plugin: 'relay', key: 'handoffSnoozed' } as const, false)
104const lastResponseAtom = atom({ plugin: 'relay', key: 'lastResponseAt' } as const, null)
105const contextAtom = atom({ plugin: 'relay', key: 'context' } as const, null)
106const tickAtom = atom({ plugin: 'relay', key: 'tick' } as const, 0)
107const busyAtom = atom({ plugin: 'relay', key: 'handoffBusy' } as const, false)
108const draftAtom = atom({ plugin: 'relay', key: 'handoffDraft' } as const, null)
109const coldAtom = atom({ plugin: 'relay', key: 'coldConfirm' } as const, null)
110const offerAtom = atom({ plugin: 'relay', key: 'offer' } as const, null)
111const warmingAtom = atom({ plugin: 'relay', key: 'warming' } as const, null)
112const coldGuardAtom = atom({ plugin: 'relay', key: 'coldGuard' } as const, null)
113const guardEpisodeAtom = atom({ plugin: 'relay', key: 'guardEpisode' } as const, null)
114const resumeEstimateAtom = atom({ plugin: 'relay', key: 'resumeEstimate' } as const, null)
115const turnRunningAtom = atom({ plugin: 'relay', key: 'turnRunning' } as const, null)
116
117const TICK_MS = 30 * 1000
118const PANE = 'relay-handoff'
119const RELAY_HELP = 'Type /relay for the status card, /relay on or /relay off to switch Relay, /relay overnight on|off for the overnight handoff.'
120const HANDOFF_HELP = 'Type /handoff to write a handoff, /handoff list to see the saved ones.'
121const BUSY_TEXT = 'Already writing a handoff'
122const LOADED_TEXT = 'Handoff loaded — review it and press Enter.'
123const COLD_WRITE_WIDTH = '[ Write it anyway ]'.length
124const CANCEL_WIDTH = '[ Cancel ]'.length
125const LOAD_WIDTH = '[ Load ]'.length
126const DISMISS_WIDTH = '[ Dismiss ]'.length
127const GUARD_HANDOFF_WIDTH = '[ Handoff & start fresh ]'.length
128const SEND_ANYWAY_WIDTH = '[ Send anyway ]'.length
129const HELD_BACK_TEXT = 'Your held message is back in the prompt box.'
130/** A turn.start with no turn.complete for this long no longer counts as running. */
131const TURN_STALE_MS = 6 * 60 * 60 * 1000
132
133/** What the module keeps between events; a hot reload starts it over (everything drawn is in $.state). */
134const runtime = {
135  ticker: null as Timer | null,
136  /** Clear & continue is running (a second press is ignored). */
137  isClearing: false,
138  /** A fill from the after-clear marker is running (SessionStart and after-run never fill at once). */
139  isFilling: false,
140  /** The last after-clear fill: the marker's `at` and whether the box took it. */
141  lastFill: null as { at: number; isFilled: boolean } | null,
142  /** The warming timer (next ping or the window's end) and when it fires. */
143  warmTimer: null as Timer | null,
144  warmDue: 0,
145  /** The session id the store keys use, read once per conversation; null when the engine cannot say (nothing is persisted then). */
146  sid: undefined as string | null | undefined,
147  /** A ping (or the overnight handoff) is running. */
148  isPinging: false,
149}
150
151function sameContext(a: RelayContext | null, b: RelayContext | null): boolean {
152  if (a === null || b === null) return a === b
153  return a.tokens === b.tokens && a.percent === b.percent && a.window === b.window
154}
155
156/** Keeps the latest context figures (only when they moved, so the row redraws only then). */
157async function setContext($: Engine, context: SessionContextUsage | RelayContext | null) {
158  const next: RelayContext | null =
159    context === null ? null : { tokens: context.tokens, percent: context.percent, window: context.window }
160  const current = await read($, contextAtom)
161  if (!sameContext(current, next)) await update($, contextAtom, () => next)
162}
163
164/** Reads the figures straight from the engine (session start); a failed read leaves them as they were. */
165async function readUsage($: Engine) {
166  try {
167    const usage = await $.session.usage()
168    await setContext($, usage.context)
169  } catch {
170    // No reading: the row shows "—" until the first session.measure.
171  }
172}
173
174/** A fresh window: cache state unknown, no figures, no snooze (after /clear or compaction). */
175async function resetSession($: Engine) {
176  await update($, lastResponseAtom, () => null)
177  await update($, contextAtom, () => null)
178  await update($, snoozedAtom, () => false)
179}
180
181/** Bumps the tick only while the row shows a running cache clock (Relay on, a reply seen). */
182async function tick($: Engine) {
183  const [enabled, lastResponseAt] = await Promise.all([read($, enabledAtom), read($, lastResponseAtom)])
184  if (enabled && lastResponseAt !== null) await update($, tickAtom, n => (n + 1) % 1_000_000)
185}
186
187/**
188 * Runs the 30s tick that redraws the row's "left" time. A hot reload drops the timer and
189 * `runtime`, so every caller (session start, a reply, each band draw) starts it again lazily.
190 */
191function ensureTicker($: Engine) {
192  if (runtime.ticker !== null) return
193  runtime.ticker = $.clock.every(TICK_MS, () => {
194    void tick($).catch(() => undefined)
195  })
196}
197
198async function setEnabled($: Engine, isOn: boolean) {
199  await update($, enabledAtom, () => isOn)
200  await $.store.set(ENABLED_KEY, isOn)
201  // Off = no row, no warming, no guard; on again, the next reply arms warming.
202  if (!isOn) await dropForOff($)
203}
204
205/** Relay off: no window, and a held prompt goes back in the prompt box. */
206async function dropForOff($: Engine) {
207  await releaseHeld($)
208  await setWarming($, w => ({ ...w, windowEnd: null, nextPingAt: undefined }))
209}
210
211/**
212 * Picks up `/relay on|off` typed in another session (the store is shared, $.state is
213 * not): read at each turn start and each warming timer.
214 */
215async function syncEnabled($: Engine) {
216  let saved: unknown
217  try {
218    saved = await $.store.get(ENABLED_KEY)
219  } catch {
220    return
221  }
222  const isOn = saved !== false
223  if ((await read($, enabledAtom)) === isOn) return
224  await update($, enabledAtom, () => isOn)
225  if (!isOn) await dropForOff($)
226}
227
228/** The session root, or undefined when the engine cannot say. */
229async function sessionRoot($: Engine): Promise<string | undefined> {
230  try {
231    return await $.session.root()
232  } catch {
233    return undefined
234  }
235}
236
237/** The handoff index from the store; an unreadable store reads as empty. */
238async function loadIndex($: Engine): Promise<RelayHandoff[]> {
239  try {
240    return readIndex(await $.store.get(HANDOFFS_KEY))
241  } catch {
242    return []
243  }
244}
245
246/** Marks one handoff consumed in the store's index. */
247async function consume($: Engine, id: string) {
248  const now = await $.clock.now()
249  await $.store.set(HANDOFFS_KEY, markConsumed(await loadIndex($), id, now))
250}
251
252function errorText(err: unknown): string {
253  return err instanceof Error ? err.message : String(err)
254}
255
256/** One read-only git command's stdout, or undefined when it failed or could not start. */
257async function git($: Engine, root: string, args: readonly string[]): Promise<string | undefined> {
258  try {
259    const result = await $.process.run(['git', ...args], { cwd: root, timeoutMs: 15_000 })
260    return result.exitCode === 0 ? result.stdout : undefined
261  } catch {
262    return undefined
263  }
264}
265
266/** The git facts for the brief, from read-only commands only; never a write. Not a repo: no facts. */
267async function gitFactLines($: Engine, root: string): Promise<string[]> {
268  const [branch, symbolicRef] = await Promise.all([
269    git($, root, ['rev-parse', '--abbrev-ref', 'HEAD']),
270    git($, root, ['symbolic-ref', '--short', 'HEAD']),
271  ])
272  const outputs: GitOutputs = { branch, symbolicRef }
273  if ((branch ?? '').trim() !== '' || (symbolicRef ?? '').trim() !== '') {
274    const [log, aheadBehind, porcelain] = await Promise.all([
275      git($, root, ['log', '-1', '--format=%h %s']),
276      git($, root, ['rev-list', '--left-right', '--count', '@{u}...HEAD']),
277      git($, root, ['--no-optional-locks', 'status', '--porcelain']),
278    ])
279    Object.assign(outputs, { log, aheadBehind, porcelain })
280  }
281  return formatGitFacts(parseGitFacts(outputs))
282}
283
284async function homeDir($: Engine): Promise<string | undefined> {
285  try {
286    const home = (await $.env.get('USERPROFILE')) || (await $.env.get('HOME'))
287    return home === undefined || home === '' ? undefined : home
288  } catch {
289    return undefined
290  }
291}
292
293/** Runs a button's work; a rejection becomes a toast instead of an unhandled error. */
294async function safely($: Engine, work: () => Promise<unknown>) {
295  try {
296    await work()
297  } catch (err) {
298    $.ui.toast(`Relay: ${errorText(err)}`)
299  }
300}
301
302/**
303 * The handoff, from every way in: the Handoff button, "Handoff now", `/handoff`, the
304 * cold confirmation's "Write it anyway" (and later the cold guard). Called directly, not
305 * through `$.command.run({ command: 'handoff' })`: a plugin's own `$.command.run` from its
306 * press handler skips the plugin's own `command.run` hooks (re-entry). Resolves the line
307 * `/handoff` answers with.
308 */
309async function startHandoff($: Engine, opts: { coldConfirmed?: boolean; heldText?: string } = {}): Promise<string> {
310  if (await read($, busyAtom)) {
311    $.ui.toast(BUSY_TEXT)
312    if (opts.heldText !== undefined) await putBack($, opts.heldText)
313    return `${BUSY_TEXT}.`
314  }
315  // The cold check comes before the busy flag, so asking never flickers "Writing handoff…".
316  if (opts.coldConfirmed !== true) {
317    const [context, lastResponseAt, now] = await Promise.all([read($, contextAtom), read($, lastResponseAtom), $.clock.now()])
318    const tokens = context?.tokens
319    if (cacheState(lastResponseAt ?? undefined, now).state === 'cold' && typeof tokens === 'number' && tokens >= MIN_WARM_TOKENS) {
320      let model: string | undefined
321      try {
322        model = await $.session.model()
323      } catch {
324        model = undefined
325      }
326      const usd = estimateRewriteUsd(tokens, model)
327      await update($, coldAtom, () => ({
328        tokens,
329        ...(usd !== undefined ? { usd } : {}),
330        ...(opts.heldText !== undefined ? { heldText: opts.heldText } : {}),
331      }))
332      return `${coldHandoffText(tokens, usd)} Confirm with "Write it anyway" on the Relay row above the prompt.`
333    }
334  }
335
336  let wasBusy = false
337  await update($, busyAtom, busy => {
338    wasBusy = busy
339    return true
340  })
341  if (wasBusy) {
342    $.ui.toast(BUSY_TEXT)
343    if (opts.heldText !== undefined) await putBack($, opts.heldText)
344    return `${BUSY_TEXT}.`
345  }
346  try {
347    await update($, coldAtom, () => null)
348    return await writeHandoff($, opts.heldText)
349  } catch (err) {
350    if (opts.heldText !== undefined) await putBack($, opts.heldText)
351    throw err
352  } finally {
353    await update($, busyAtom, () => false)
354  }
355}
356
357/** A brief forked and saved, or why not: `text` for the person, `reason` in a few words for the row. */
358type SavedHandoff =
359  | { isSaved: true; id: string; key: string; title: string; text: string; path: string }
360  | { isSaved: false; text: string; reason: string }
361
362/**
363 * Forks the brief and saves it (file + index), with no pane and no clear: the shared
364 * half of the handoff flow and the overnight handoff. A file or index that cannot be
365 * written is toasted and the handoff still counts as saved (the index keeps it).
366 */
367async function forkAndSave($: Engine): Promise<SavedHandoff> {
368  const root = await sessionRoot($)
369  if (root === undefined) {
370    return { isSaved: false, text: 'The handoff could not be written: no session root.', reason: 'no session root' }
371  }
372  const facts = await gitFactLines($, root)
373
374  let result: Awaited<ReturnType<Engine['model']['fork']>>
375  try {
376    result = await $.model.fork({ prompt: buildHandoffPrompt(facts.join('\n')) })
377  } catch (err) {
378    return { isSaved: false, text: `The handoff could not be written: ${errorText(err)}`, reason: errorText(err) }
379  }
380  if (!result.isAnswered) {
381    const status = result.reason === 'api-error' ? (result.status ?? undefined) : undefined
382    return { isSaved: false, text: forkFailureText(result.reason, status), reason: forkStopReason(result.reason, status) }
383  }
384
385  const text = result.text.trim()
386  const title = extractTitle(text)
387  const now = await $.clock.now()
388  const key = projectKey(root)
389  const home = await homeDir($)
390  let path = home === undefined ? '' : joinPath(handoffDir(home, key), handoffFileName(new Date(now), title))
391  if (path === '') {
392    $.ui.toast('The handoff was not saved to a file: no home folder (USERPROFILE or HOME).')
393  } else {
394    try {
395      await $.fs.write(path, `${text}\n`)
396    } catch (err) {
397      $.ui.toast(`The handoff file could not be saved: ${errorText(err)}`)
398      path = ''
399    }
400  }
401
402  const id = `${now}-${slugify(title)}`
403  const record: RelayHandoff = { id, projectKey: key, root, title, path, createdAt: now }
404  try {
405    await $.store.set(HANDOFFS_KEY, addHandoff(await loadIndex($), record))
406  } catch (err) {
407    $.ui.toast(`The handoff could not be indexed: ${errorText(err)}`)
408  }
409  return { isSaved: true, id, key, title, text, path }
410}
411
412/**
413 * The overnight handoff (warming.ts): fork + save as an unconsumed handoff, no pane,
414 * no clear. Skipped (not saved) while another handoff is being written.
415 */
416async function saveHandoffSilently($: Engine): Promise<{ isSaved: true; title: string } | { isSaved: false; reason: string }> {
417  let wasBusy = false
418  await update($, busyAtom, busy => {
419    wasBusy = busy
420    return true
421  })
422  if (wasBusy) return { isSaved: false, reason: 'a handoff was already being written' }
423  try {
424    const saved = await forkAndSave($)
425    return saved.isSaved ? { isSaved: true, title: saved.title } : { isSaved: false, reason: saved.reason }
426  } finally {
427    await update($, busyAtom, () => false)
428  }
429}
430
431/** Forks the brief, saves it (file + index) and opens the preview pane. */
432async function writeHandoff($: Engine, heldText: string | undefined): Promise<string> {
433  const written = await forkAndSave($)
434  if (!written.isSaved) {
435    $.ui.toast(written.text)
436    if (heldText !== undefined) await putBack($, heldText)
437    return written.text
438  }
439  const { id, key, title, text, path } = written
440  const draft: RelayDraft = { id, projectKey: key, title, text, path, ...(heldText !== undefined ? { heldText } : {}) }
441  await update($, draftAtom, () => draft)
442  const saved = path === '' ? 'it is not saved to a file' : `saved to ${path}`
443  try {
444    const opened = await $.ui.open({ id: PANE, title: 'Handoff' })
445    if (!opened.isPlaced) $.ui.toast(`Widen the window to see the Handoff pane — ${saved}.`)
446  } catch {
447    $.ui.toast(`The Handoff pane could not open — ${saved}.`)
448  }
449  return `Handoff written: "${title}".${path === '' ? '' : ` Saved to ${path}.`} Review it in the Handoff pane.`
450}
451
452/** "Write it anyway" on the cold confirmation; while a handoff is already being written the row stays. */
453async function confirmCold($: Engine) {
454  if (await read($, busyAtom)) {
455    $.ui.toast(BUSY_TEXT)
456    return
457  }
458  const cold = await read($, coldAtom)
459  await update($, coldAtom, () => null)
460  await startHandoff($, { coldConfirmed: true, ...(cold?.heldText !== undefined ? { heldText: cold.heldText } : {}) })
461}
462
463/**
464 * "Handoff & start fresh" on the cold guard's row: the handoff flow with the cold cost
465 * already accepted, the held prompt riding along to land below the handoff after the clear.
466 */
467async function handoffFresh($: Engine) {
468  if (await read($, busyAtom)) {
469    $.ui.toast(BUSY_TEXT)
470    return
471  }
472  const guard = await takeHeld($)
473  if (guard === null) return
474  await startHandoff($, { coldConfirmed: true, heldText: guard.heldText })
475}
476
477async function cancelCold($: Engine) {
478  const cold = await read($, coldAtom)
479  await update($, coldAtom, () => null)
480  if (cold?.heldText !== undefined) await putBack($, cold.heldText)
481}
482
483async function closePane($: Engine) {
484  await update($, draftAtom, () => null)
485  try {
486    await $.ui.close({ id: PANE })
487  } catch {
488    // Already closed.
489  }
490}
491
492/** Where the handoff can still be found when the prompt box or the clipboard failed. */
493function fallbackText(marker: Pick<RelayFillMarker, 'isCopied' | 'path'>): string {
494  if (marker.isCopied === true) return 'it is on the clipboard'
495  return marker.path ? `it is saved at ${marker.path}` : 'copy it from the Handoff pane next time'
496}
497
498/**
499 * Clear & continue: clipboard, consumed, pane closed, a marker in the store (it outlives
500 * the clear even if module state does not), then /clear. The prompt box is filled on the
501 * following SessionStart (source clear), or right after the clear resolves if that never
502 * came; then the box is read back and filled once more if the clear emptied it. Never sent.
503 */
504async function clearAndContinue($: Engine, press: UiPressArgument) {
505  if (runtime.isClearing) return
506  runtime.isClearing = true
507  try {
508    const draft = await read($, draftAtom)
509    if (draft === null) return
510    let isCopied = false
511    try {
512      isCopied = (await $.ui.copy({ text: draft.text, surface: press.surface })).isCopied
513    } catch {
514      isCopied = false
515    }
516    await consume($, draft.id)
517    await closePane($)
518    const marker: RelayFillMarker = {
519      text: draft.text,
520      projectKey: draft.projectKey,
521      path: draft.path,
522      isCopied,
523      at: await $.clock.now(),
524      ...(draft.heldText !== undefined ? { heldText: draft.heldText } : {}),
525    }
526    await $.store.set(FILL_KEY, marker)
527    try {
528      await $.command.run({ command: 'clear' })
529    } catch (err) {
530      await $.store.delete(FILL_KEY)
531      $.ui.toast(`/clear could not run (${errorText(err)}) — ${fallbackText(marker)}.`)
532      if (marker.heldText !== undefined) await putBack($, marker.heldText)
533      return
534    }
535    await fillAfterClear($, 'after-run')
536    await verifyFill($, marker)
537  } finally {
538    runtime.isClearing = false
539  }
540}
541
542/** After the clear resolved: when the box does not hold the handoff (the clear emptied it), fill it once more. */
543async function verifyFill($: Engine, marker: RelayFillMarker) {
544  const last = runtime.lastFill
545  if (last === null || last.at !== marker.at || !last.isFilled) return
546  const expected = fillText(marker.text, marker.heldText)
547  let box: string | undefined
548  try {
549    box = (await $.prompt.read()).text
550  } catch {
551    return
552  }
553  if (box.trim() === expected.trim()) return
554  const again = await $.prompt.fill({ text: expected, mode: 'replace' }).catch(() => undefined)
555  if (again?.isFilled !== true) $.ui.toast(`The handoff could not go in the prompt box — ${fallbackText(marker)}.`)
556}
557
558function readMarker(value: unknown): RelayFillMarker | undefined {
559  if (typeof value !== 'object' || value === null) return undefined
560  const marker = value as RelayFillMarker
561  return typeof marker.text === 'string' && typeof marker.at === 'number' ? marker : undefined
562}
563
564/**
565 * Fills the prompt box from the store's marker, once, for this session's project only.
566 * `session-start` (SessionStart, source clear) fills and deletes it; `after-run` (the
567 * clear's `$.command.run` resolved and the SessionStart never filled) fills and leaves it
568 * marked filled, so a late SessionStart only deletes it. A synchronous in-module flag keeps
569 * the two from filling at once. Resolves true when a fresh marker for this project was
570 * there (so no pending offer is shown).
571 */
572async function fillAfterClear($: Engine, from: 'session-start' | 'after-run'): Promise<boolean> {
573  // Set before the first await: a SessionStart arriving mid-fill neither fills nor offers.
574  if (runtime.isFilling) return true
575  runtime.isFilling = true
576  try {
577    let marker: RelayFillMarker | undefined
578    try {
579      marker = readMarker(await $.store.get(FILL_KEY))
580    } catch {
581      return false
582    }
583    if (marker === undefined) return false
584    const now = await $.clock.now()
585    if (now - marker.at > FILL_MAX_AGE_MS) {
586      await $.store.delete(FILL_KEY)
587      return false
588    }
589    const root = await sessionRoot($)
590    if (marker.projectKey !== undefined && (root === undefined || projectKey(root) !== marker.projectKey)) return false
591    if (marker.filledAt === undefined) {
592      let isFilled = false
593      try {
594        isFilled = (await $.prompt.fill({ text: fillText(marker.text, marker.heldText), mode: 'replace' })).isFilled
595      } catch {
596        isFilled = false
597      }
598      runtime.lastFill = { at: marker.at, isFilled }
599      $.ui.toast(isFilled ? LOADED_TEXT : `The handoff could not go in the prompt box — ${fallbackText(marker)}.`)
600    }
601    if (from === 'session-start') await $.store.delete(FILL_KEY)
602    else await $.store.set(FILL_KEY, { ...marker, filledAt: now })
603    return true
604  } finally {
605    runtime.isFilling = false
606  }
607}
608
609async function copyDraft($: Engine, press: UiPressArgument) {
610  const draft = await read($, draftAtom)
611  if (draft === null) return
612  const saved = draft.path === '' ? 'it is not saved to a file' : `it is saved at ${draft.path}`
613  try {
614    const copied = await $.ui.copy({ text: draft.text, surface: press.surface })
615    $.ui.toast(copied.isCopied ? 'Handoff copied' : `Could not copy the handoff (${copied.reason}) — ${saved}.`)
616  } catch (err) {
617    $.ui.toast(`Could not copy the handoff (${errorText(err)}) — ${saved}.`)
618  }
619}
620
621/** Cancel: the pane closes; the handoff stays saved and unconsumed, so a later session offers it. */
622async function cancelDraft($: Engine) {
623  const draft = await read($, draftAtom)
624  await closePane($)
625  if (draft?.heldText !== undefined) await putBack($, draft.heldText)
626}
627
628/** Offers this project's newest unconsumed handoff (within 7 days) on the band. */
629async function offerPending($: Engine) {
630  const root = await sessionRoot($)
631  if (root === undefined) return
632  const [index, now] = await Promise.all([loadIndex($), $.clock.now()])
633  const one = pendingFor(index, projectKey(root), now)
634  const offer: RelayOffer | null = one === undefined ? null : { id: one.id, title: one.title, path: one.path, createdAt: one.createdAt }
635  await update($, offerAtom, () => offer)
636}
637
638/** Load: the saved file into the prompt box (not sent); consumed and gone only once the box took it. */
639async function loadOffer($: Engine) {
640  const offer = await read($, offerAtom)
641  if (offer === null) return
642  let text: string | undefined
643  try {
644    const content = offer.path === '' ? undefined : await $.fs.read(offer.path)
645    text = typeof content === 'string' ? content : undefined
646  } catch {
647    text = undefined
648  }
649  if (text === undefined) {
650    $.ui.toast(`The handoff file is missing${offer.path === '' ? '' : ` (${offer.path})`}. Dismiss to stop seeing it.`)
651    return
652  }
653  let isFilled = false
654  let why = 'the box did not take it'
655  try {
656    const filled = await $.prompt.fill({ text: text.trimEnd(), mode: 'replace' })
657    isFilled = filled.isFilled
658    if (filled.refusal === 'dialog') why = 'a dialog holds the keys'
659    else if (filled.refusal === 'no_composer') why = 'this session has no prompt box'
660  } catch (err) {
661    why = errorText(err)
662  }
663  if (!isFilled) {
664    $.ui.toast(`The handoff could not go in the prompt box (${why}). It is saved at ${offer.path}; try Load again.`)
665    return
666  }
667  await consume($, offer.id)
668  await update($, offerAtom, () => null)
669  $.ui.toast(LOADED_TEXT)
670}
671
672async function dismissOffer($: Engine) {
673  const offer = await read($, offerAtom)
674  if (offer === null) return
675  await consume($, offer.id)
676  await update($, offerAtom, () => null)
677}
678
679async function listHandoffs($: Engine): Promise<string> {
680  const [root, index, now] = await Promise.all([sessionRoot($), loadIndex($), $.clock.now()])
681  return handoffListText(index, root === undefined ? '' : projectKey(root), now)
682}
683
684async function snooze($: Engine) {
685  await update($, snoozedAtom, () => true)
686}
687
688// ---------------------------------------------------------------------------
689// Warming and the cold-cache guard: the `$` side of warming.ts's decisions.
690// Adapted from the cache-tax mod (MIT License, Copyright (c) 2026 Karan Bansal).
691// ---------------------------------------------------------------------------
692
693function cancelWarmTimer() {
694  runtime.warmTimer?.cancel()
695  runtime.warmTimer = null
696}
697
698/** The session id, read once per conversation (a /clear starts a new one); undefined when the engine cannot say. */
699async function sessionId($: Engine): Promise<string | undefined> {
700  if (runtime.sid === undefined) {
701    try {
702      const id = await $.session.id()
703      runtime.sid = typeof id === 'string' && id !== '' ? id : null
704    } catch {
705      runtime.sid = null
706    }
707  }
708  return runtime.sid ?? undefined
709}
710
711/** `relay.warming.<session id>`; undefined with no session id (warming then lives in $.state only). */
712async function warmingKey($: Engine): Promise<string | undefined> {
713  const sid = await sessionId($)
714  return sid === undefined ? undefined : `${WARMING_KEY_PREFIX}${sid}`
715}
716
717/** `relay.heldText.<session id>`; undefined with no session id. */
718async function heldKey($: Engine): Promise<string | undefined> {
719  const sid = await sessionId($)
720  return sid === undefined ? undefined : `${HELD_KEY_PREFIX}${sid}`
721}
722
723async function storeHeld($: Engine, text: string | null) {
724  try {
725    const key = await heldKey($)
726    if (key === undefined) return
727    if (text === null) await $.store.delete(key)
728    else await $.store.set(key, { text, at: await $.clock.now() })
729  } catch {
730    // The row still holds it.
731  }
732}
733
734/**
735 * Puts a held prompt back in the prompt box (appended, never sent) so it is never
736 * lost: after a failed or cancelled Handoff & start fresh, a failed Send anyway, a
737 * reply that arrived meanwhile, a /clear, or Relay switched off.
738 */
739async function putBack($: Engine, text: string) {
740  let isFilled = false
741  try {
742    isFilled = (await $.prompt.fill({ text, mode: 'append' })).isFilled
743  } catch {
744    isFilled = false
745  }
746  if (isFilled) {
747    await storeHeld($, null)
748    $.ui.toast(HELD_BACK_TEXT)
749  } else {
750    $.ui.toast(`Your held message could not go back in the prompt box: ${fit(text, 300)}`)
751  }
752}
753
754/** A held prompt on the row, if any, goes back in the prompt box. */
755async function releaseHeld($: Engine) {
756  const guard = await read($, coldGuardAtom)
757  await update($, coldGuardAtom, () => null)
758  if (guard !== null) await putBack($, guard.heldText)
759}
760
761/** Session start: a held prompt the store kept (the process restarted while it was held) goes back in the box. */
762async function restoreHeld($: Engine) {
763  if ((await read($, coldGuardAtom)) !== null) return
764  try {
765    const key = await heldKey($)
766    if (key === undefined) return
767    const held = readHeld(await $.store.get(key))
768    if (held !== undefined) await putBack($, held.text)
769  } catch {
770    // Nothing kept.
771  }
772}
773
774/** Session start: drops other sessions' dead warming windows, stale off flags and old held prompts. */
775async function pruneStore($: Engine) {
776  try {
777    const now = await $.clock.now()
778    for (const key of await $.store.keys()) {
779      if (!key.startsWith(WARMING_KEY_PREFIX) && !key.startsWith(HELD_KEY_PREFIX)) continue
780      if (isStale(key, await $.store.get(key), now)) await $.store.delete(key)
781    }
782  } catch {
783    // A store with no key listing: nothing pruned.
784  }
785}
786
787/** Whether a main-thread turn is running (a turn.start with no turn.complete, younger than 6 hours). */
788async function isTurnRunning($: Engine): Promise<boolean> {
789  const [startedAt, now] = await Promise.all([read($, turnRunningAtom), $.clock.now()])
790  return startedAt !== null && now - startedAt < TURN_STALE_MS
791}
792
793/** Changes this session's warming, mirrors it in the store, and re-arms the timer. */
794async function setWarming($: Engine, change: (w: RelayWarming) => RelayWarming) {
795  const next = change((await read($, warmingAtom)) ?? NO_WARMING)
796  await update($, warmingAtom, () => next)
797  try {
798    const key = await warmingKey($)
799    if (key !== undefined) {
800      if (isWorthKeeping(next)) await $.store.set(key, next)
801      else await $.store.delete(key)
802    }
803  } catch {
804    // The atom stands; only a reload in a new process would miss it.
805  }
806  await armWarmTimer($)
807}
808
809/**
810 * Starts the one timer for the next ping or the window's end, unless it is already
811 * pending for that time. A hot reload drops it, so session start, each reply and each
812 * band draw call this again.
813 */
814async function armWarmTimer($: Engine) {
815  const [w, now, isRunning] = await Promise.all([read($, warmingAtom), $.clock.now(), isTurnRunning($)])
816  const due = dueAt(w)
817  // Never mid-turn: the turn's end arms it again.
818  if (due === undefined || isRunning) {
819    cancelWarmTimer()
820    return
821  }
822  // Checked after the awaits, so two callers at once still start one timer.
823  if (runtime.warmTimer !== null && runtime.warmDue === due) return
824  cancelWarmTimer()
825  runtime.warmDue = due
826  runtime.warmTimer = $.clock.after(Math.max(0, due - now), () => {
827    runtime.warmTimer = null
828    void onWarmDue($).catch(() => setWarming($, x => stopped(x, 'error')).catch(() => undefined))
829  })
830}
831
832/** Session start (and so a reload or a resume): the window from the store when $.state has none, then the timer. */
833async function restoreWarming($: Engine) {
834  if ((await read($, warmingAtom)) === null) {
835    try {
836      const key = await warmingKey($)
837      const saved = key === undefined ? undefined : readWarming(await $.store.get(key))
838      if (saved !== undefined) {
839        const w = restoredWarming(saved, await $.clock.now())
840        await update($, warmingAtom, () => w)
841      }
842    } catch {
843      // Nothing saved for this session.
844    }
845  }
846  await armWarmTimer($)
847}
848
849/** A main-thread reply: the guard's row goes, and the window is armed or extended (warming.ts afterReply). */
850async function warmAfterReply($: Engine, now: number) {
851  await releaseHeld($)
852  await update($, resumeEstimateAtom, () => null)
853  const [enabled, context] = await Promise.all([read($, enabledAtom), read($, contextAtom)])
854  await setWarming($, w => afterReply(w, { now, enabled, tokens: context?.tokens }))
855}
856
857/** /clear or compaction: no window, no held prompt; `/keepwarm off` stays for the session. */
858async function resetWarming($: Engine, isClear: boolean) {
859  await releaseHeld($)
860  await update($, guardEpisodeAtom, () => null)
861  await update($, resumeEstimateAtom, () => null)
862  if (isClear) {
863    // The conversation goes on under a new session id: drop the old key, read the id again.
864    try {
865      const key = await warmingKey($)
866      if (key !== undefined) await $.store.delete(key)
867    } catch {
868      // Nothing to drop.
869    }
870    runtime.sid = undefined
871  }
872  await setWarming($, w => ({ windowEnd: null, off: w.off }))
873}
874
875async function sessionModel($: Engine): Promise<string | undefined> {
876  try {
877    return await $.session.model()
878  } catch {
879    return undefined
880  }
881}
882
883async function isOvernightOn($: Engine): Promise<boolean> {
884  try {
885    return (await $.store.get(OVERNIGHT_KEY)) !== false
886  } catch {
887    return true
888  }
889}
890
891/** Whether this project already has a handoff saved at or after `at` (the last reply): it is still current. */
892async function hasHandoffSince($: Engine, at: number | undefined): Promise<boolean> {
893  if (at === undefined) return false
894  const root = await sessionRoot($)
895  if (root === undefined) return false
896  const key = projectKey(root)
897  return (await loadIndex($)).some(one => one.projectKey === key && one.createdAt >= at)
898}
899
900async function onWarmDue($: Engine) {
901  if (runtime.isPinging) return
902  runtime.isPinging = true
903  try {
904    await pingOrHandoff($)
905  } catch {
906    await setWarming($, x => stopped(x, 'error'))
907  } finally {
908    runtime.isPinging = false
909  }
910}
911
912/**
913 * The timer fired: end the window, or ping, or (when the next ping would fall at or
914 * after the window's end and the overnight handoff is on) save a handoff in the last
915 * ping's place, unless one was already saved since the last reply or one is being written.
916 */
917async function pingOrHandoff($: Engine) {
918  await syncEnabled($)
919  const [w, enabled, lastResponseAt, context, now, isRunning] = await Promise.all([
920    read($, warmingAtom),
921    read($, enabledAtom),
922    read($, lastResponseAtom),
923    read($, contextAtom),
924    $.clock.now(),
925    isTurnRunning($),
926  ])
927  const action = dueAction({ w, enabled, lastResponseAt, tokens: context?.tokens, now, isTurnRunning: isRunning })
928  switch (action.kind) {
929    case 'none':
930      return
931    case 'drop':
932      return setWarming($, x => ({ ...x, windowEnd: null, nextPingAt: undefined }))
933    case 'stop':
934      return setWarming($, x => stopped(x, action.reason))
935    case 'rearm':
936      return armWarmTimer($)
937    case 'end':
938      // A window with no ping left in it (one shorter than the first ping, say): the
939      // overnight handoff runs at its end while the cache is still warm.
940      if ((await isOvernightOn($)) && !(await read($, busyAtom)) && !(await hasHandoffSince($, w?.lastTurnAt))) {
941        return overnightHandoff($)
942      }
943      return setWarming($, x => stopped(x, action.reason))
944    case 'ping':
945      if (action.isLast && (await isOvernightOn($)) && !(await read($, busyAtom)) && !(await hasHandoffSince($, w?.lastTurnAt))) {
946        return overnightHandoff($)
947      }
948      return warmPing($)
949  }
950}
951
952async function warmPing($: Engine) {
953  let result: Awaited<ReturnType<Engine['model']['fork']>>
954  try {
955    result = await $.model.fork({ prompt: PING_PROMPT })
956  } catch (err) {
957    return setWarming($, x => stopped(x, `the ping failed: ${errorText(err)}`))
958  }
959  if (!result.isAnswered) {
960    const reason = forkStopReason(result.reason, result.reason === 'api-error' ? (result.status ?? undefined) : undefined)
961    return setWarming($, x => stopped(x, reason))
962  }
963  const usage = result.usage
964  const at = await $.clock.now()
965  const price = modelPrice(await sessionModel($))
966  const readTokens = usage.cache_read_input_tokens
967  const writeTokens = usage.cache_creation_input_tokens
968  const last = { at, read: readTokens, write: writeTokens, ...(price !== undefined ? { usd: pingUsd(usage, price) } : {}) }
969  const problem = pingProblem(readTokens, writeTokens)
970  if (problem !== undefined) return setWarming($, x => stopped(x, problem, { last }))
971  // A ping that read the cache refreshed it: it counts as a response for the cache clock.
972  await update($, lastResponseAtom, () => at)
973  await setWarming($, x => ({ ...x, last, nextPingAt: at + PING_IDLE_MS }))
974}
975
976/** The window's last ping, replaced: fork + save a handoff (no pane, no clear), then let the cache go cold. */
977async function overnightHandoff($: Engine) {
978  let saved: Awaited<ReturnType<typeof saveHandoffSilently>>
979  try {
980    saved = await saveHandoffSilently($)
981  } catch (err) {
982    return setWarming($, x => stopped(x, `no overnight handoff: ${errorText(err)}`))
983  }
984  if (!saved.isSaved) {
985    const reason = `no overnight handoff: ${saved.reason}`
986    return setWarming($, x => stopped(x, reason))
987  }
988  // The handoff's fork read the cache too, so the clock moves; after it the cache is let go cold.
989  const at = await $.clock.now()
990  await update($, lastResponseAtom, () => at)
991  $.ui.toast(`Relay saved a handoff before the cache cooled: ${saved.title}`)
992  await setWarming($, x => stopped(x, 'handoff saved'))
993}
994
995/** `/keepwarm <duration>|off|status` (bare = status). */
996async function keepwarmCommand($: Engine, args: string): Promise<string> {
997  const word = args.trim().toLowerCase()
998  if (word === '' || word === 'status') return `Warming: ${await warmingStatus($)}`
999  if (word === 'off') {
1000    const at = await $.clock.now()
1001    await setWarming($, w => stopped({ ...w, off: true, offAt: at }, 'turned off'))
1002    return 'Warming is off for this session. /keepwarm 2h turns it on again.'
1003  }
1004  const ms = parseDuration(word)
1005  if (ms === undefined) return KEEPWARM_HELP
1006  const [enabled, context, lastResponseAt, now] = await Promise.all([
1007    read($, enabledAtom),
1008    read($, contextAtom),
1009    read($, lastResponseAtom),
1010    $.clock.now(),
1011  ])
1012  if (!enabled) return 'Relay is off. Type /relay on first.'
1013  const tokens = context?.tokens
1014  if (!(typeof tokens === 'number' && tokens >= MIN_WARM_TOKENS)) {
1015    const has = typeof tokens === 'number' ? `this one has ${formatTokens(tokens)}` : 'this one has no reading yet'
1016    return `Relay warms sessions from ${formatTokens(MIN_WARM_TOKENS)} tokens of context; ${has}.`
1017  }
1018  const plan = keepwarmWindow((await read($, warmingAtom)) ?? NO_WARMING, { now, ms, lastResponseAt })
1019  await setWarming($, () => plan.warming)
1020  return plan.text
1021}
1022
1023/** `/relay overnight on|off`. */
1024async function overnightCommand($: Engine, isOn: boolean): Promise<string> {
1025  await $.store.set(OVERNIGHT_KEY, isOn)
1026  return isOn
1027    ? 'Overnight handoff is on: the last ping of a warming window writes a handoff instead, then the cache is let go cold.'
1028    : 'Overnight handoff is off: the last ping of a warming window is a plain ping.'
1029}
1030
1031/** The status card's (and `/keepwarm status`'s) warming line. */
1032async function warmingStatus($: Engine): Promise<string> {
1033  const [w, now, overnight] = await Promise.all([read($, warmingAtom), $.clock.now(), isOvernightOn($)])
1034  return warmingLine(w, now, overnight)
1035}
1036
1037/** The rewrite estimate: the resume's own figure while its clock stands, else tokens times the model's write price. */
1038async function rewriteEstimate($: Engine, tokens: number, lastResponseAt: number): Promise<number | undefined> {
1039  const resumed = await read($, resumeEstimateAtom)
1040  if (resumed !== null && resumed.at === lastResponseAt) return resumed.usd
1041  return estimateRewriteUsd(tokens, await sessionModel($))
1042}
1043
1044/**
1045 * The cold-cache guard at `prompt.submit`: the drop reason when the prompt is held,
1046 * undefined to let it through. Holds a typed prompt into a cold cache (context 50k+,
1047 * Relay on) once per cold episode, keeping its text on the row; the next typed send in
1048 * that episode goes through, as cache-tax does. See warming.ts isGuardable/guardVerdict.
1049 */
1050async function holdColdPrompt($: Engine, e: PromptSubmitInput): Promise<string | undefined> {
1051  if (!isGuardable(e)) return undefined
1052  const [enabled, context, lastResponseAt, episode, now] = await Promise.all([
1053    read($, enabledAtom),
1054    read($, contextAtom),
1055    read($, lastResponseAtom),
1056    read($, guardEpisodeAtom),
1057    $.clock.now(),
1058  ])
1059  const tokens = context?.tokens
1060  const verdict = guardVerdict({ enabled, tokens, lastResponseAt, episode, now })
1061  if (verdict === 'pass') return undefined
1062  if (verdict === 'release') {
1063    await update($, coldGuardAtom, () => null)
1064    await storeHeld($, null)
1065    return undefined
1066  }
1067  if (lastResponseAt === null || tokens === undefined) return undefined
1068  const usd = await rewriteEstimate($, tokens, lastResponseAt)
1069  const guard: RelayColdGuard = { heldText: e.text, tokens, coldSince: lastResponseAt + CACHE_TTL_MS, ...(usd !== undefined ? { usd } : {}) }
1070  await update($, guardEpisodeAtom, () => lastResponseAt)
1071  await update($, coldGuardAtom, () => guard)
1072  await storeHeld($, e.text)
1073  return coldDropText(coldGuardText(now - guard.coldSince, tokens, usd))
1074}
1075
1076/** Takes the held prompt off the row (and out of the store: whoever takes it now owns it). */
1077async function takeHeld($: Engine): Promise<RelayColdGuard | null> {
1078  const guard = await read($, coldGuardAtom)
1079  await update($, coldGuardAtom, () => null)
1080  if (guard !== null) await storeHeld($, null)
1081  return guard
1082}
1083
1084/**
1085 * Send anyway: submits the held text as the person's own words. Its origin is
1086 * `{ kind: 'plugin', name: 'relay', asUser: true }`, which the guard never holds.
1087 */
1088async function sendAnyway($: Engine) {
1089  const guard = await takeHeld($)
1090  if (guard === null) return
1091  let result: Awaited<ReturnType<Engine['prompt']['submit']>> | undefined
1092  try {
1093    result = await $.prompt.submit({ text: guard.heldText, asUser: true })
1094  } catch {
1095    result = undefined
1096  }
1097  if (result === undefined || result.drop !== undefined) await putBack($, guard.heldText)
1098}
1099
1100async function buildStatus($: Engine): Promise<string> {
1101  const [enabled, context, lastResponseAt, snoozed, now, root, index, warming] = await Promise.all([
1102    read($, enabledAtom),
1103    read($, contextAtom),
1104    read($, lastResponseAtom),
1105    read($, snoozedAtom),
1106    $.clock.now(),
1107    sessionRoot($),
1108    loadIndex($),
1109    warmingStatus($),
1110  ])
1111  const pending = root === undefined ? 'unknown (no session root)' : pendingLine(index, projectKey(root), now)
1112  return statusCard({
1113    enabled,
1114    context,
1115    cache: cacheState(lastResponseAt ?? undefined, now),
1116    snoozed,
1117    pending,
1118    warming: enabled ? warming : 'off (Relay is off)',
1119  })
1120}
1121
1122export const register: Register = on => {
1123  on('session.start', async ($, e, next) => {
1124    // /relay first: Robert's Toolbox runs a mod's first registered command.
1125    await $.command.register({
1126      name: 'relay',
1127      description: 'Relay status: context, prompt cache, warming and handoffs; /relay on|off, /relay overnight on|off',
1128      argumentHint: '[on|off|overnight on|off]',
1129      immediate: true,
1130    })
1131    await $.command.register({
1132      name: 'handoff',
1133      description: 'Write a handoff for a fresh session and start over (Relay)',
1134    })
1135    await $.command.register({
1136      name: 'keepwarm',
1137      description: 'Keep the prompt cache warm for a window such as 90m or 2h (max 12h); off; status (Relay)',
1138      argumentHint: '[90m|2h|off|status]',
1139      immediate: true,
1140    })
1141    const saved = await $.store.get(ENABLED_KEY)
1142    await update($, enabledAtom, () => saved !== false)
1143    await readUsage($)
1144    ensureTicker($)
1145    await update($, turnRunningAtom, () => null)
1146    await pruneStore($)
1147    await restoreWarming($)
1148    await restoreHeld($)
1149    return next(e)
1150  }).catch(($, e, next) => next(e))
1151
1152  on('command.run', { command: 'relay' }, async ($, e) => {
1153    const arg = e.args.trim().toLowerCase()
1154    if (arg === 'on' || arg === 'off') {
1155      await setEnabled($, arg === 'on')
1156      return { text: arg === 'on' ? 'Relay is on.' : 'Relay is off: no meter row, no warming, no cold-cache guard.' }
1157    }
1158    const overnight = /^overnight\s+(on|off)$/.exec(arg)
1159    if (overnight) return { text: await overnightCommand($, overnight[1] === 'on') }
1160    if (arg !== '') return { text: RELAY_HELP }
1161    return { text: await buildStatus($) }
1162  })
1163
1164  on('command.run', { command: 'keepwarm' }, async ($, e) => ({ text: await keepwarmCommand($, e.args) }))
1165
1166  // The cold-cache guard: a typed prompt sent into a cold cache is held once (dropped,
1167  // its text kept on the row). On any failure the prompt goes through.
1168  on('prompt.submit', async ($, e, next) => {
1169    const drop = await holdColdPrompt($, e)
1170    return drop === undefined ? next(e) : { drop }
1171  }).catch(($, e, next) => next(e))
1172
1173  on('command.run', { command: 'handoff' }, async ($, e) => {
1174    const arg = e.args.trim().toLowerCase()
1175    if (arg === '') return { text: await startHandoff($) }
1176    if (arg === 'list') return { text: await listHandoffs($) }
1177    return { text: HANDOFF_HELP }
1178  })
1179
1180  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
1181    const { Box, Text, Button, Markdown } = $.ui.resolve(e)
1182    const draft = await read($, draftAtom)
1183    if (draft === null) return <Text dimColor>No handoff is open. Type /handoff to write one.</Text>
1184    return (
1185      <Box flexDirection="column">
1186        <Text key="relay-pane-title" color={COLORS.gold} bold>{draft.title}</Text>
1187        <Text key="relay-pane-path" color={COLORS.muted}>{draft.path === '' ? 'Not saved to a file.' : `Saved to ${draft.path}`}</Text>
1188        <Box key="relay-pane-buttons" flexDirection="row">
1189          <Button key="relay-clear" label="Clear & continue" variant="primary" onPress={press => safely($, () => clearAndContinue($, press))} />
1190          <Text> </Text>
1191          <Button key="relay-copy" label="Copy" onPress={press => safely($, () => copyDraft($, press))} />
1192          <Text> </Text>
1193          <Button key="relay-cancel" label="Cancel" onPress={() => safely($, () => cancelDraft($))} />
1194        </Box>
1195        <Text> </Text>
1196        <Markdown key="relay-pane-text" text={draft.text} />
1197      </Box>
1198    )
1199  })
1200
hooks/core.ts 764 lines
1import type { RelayHandoff, RelayPingReceipt, RelayWarming } from '../types'
2
3/**
4 * Relay's pure logic: thresholds, the prompt-cache clock, durations, formatting,
5 * prices, paths, git-output parsing, the handoff prompt and the handoff index.
6 * Nothing here touches `$`, so every function is unit-tested directly.
7 */
8
9export type { RelayHandoff }
10
11// ---------------------------------------------------------------------------
12// Constants
13// ---------------------------------------------------------------------------
14
15export const MINUTE_MS = 60 * 1000
16export const HOUR_MS = 60 * MINUTE_MS
17export const DAY_MS = 24 * HOUR_MS
18
19/** Offer a handoff once context reaches this percent of the model window. */
20export const HANDOFF_PERCENT = 80
21/** ...or once the context reaches this many tokens, whichever comes first. */
22export const HANDOFF_TOKENS = 300_000
23/** After "Not yet", offer again from this percent. */
24export const SNOOZE_PERCENT = 90
25/** After "Not yet", offer again from this many tokens. */
26export const SNOOZE_TOKENS = 400_000
27
28/** The prompt cache's lifetime since the last main-thread response (1-hour cache). */
29export const CACHE_TTL_MS = 60 * MINUTE_MS
30/** With this much or less left, the cache reads as cooling. */
31export const COOLING_MS = 10 * MINUTE_MS
32/** Idle time before a warming ping. */
33export const PING_IDLE_MS = 50 * MINUTE_MS
34/** The idle window each completed turn arms (or extends) for warming. */
35export const WARM_WINDOW_MS = 2 * HOUR_MS
36/** The longest window `/keepwarm` accepts. */
37export const MAX_WINDOW_MS = 12 * HOUR_MS
38/** Sessions with less context than this are not warmed or guarded. */
39export const MIN_WARM_TOKENS = 50_000
40/** Unconsumed handoffs older than this are no longer offered. */
41export const HANDOFF_MAX_AGE_MS = 7 * DAY_MS
42/** The handoff index keeps at most this many records. */
43export const HANDOFF_INDEX_CAP = 50
44
45// ---------------------------------------------------------------------------
46// Handoff threshold
47// ---------------------------------------------------------------------------
48
49export type HandoffCheck = {
50  /** Context fill as a whole percent of the model window; absent before the first response. */
51  percent?: number
52  /** Context tokens; absent before the first response. */
53  tokens?: number
54  /** True once the person pressed "Not yet" this session. */
55  snoozed: boolean
56}
57
58/**
59 * Whether to offer a handoff: at or past 80% or 300k tokens, whichever first;
60 * once snoozed, at or past 90% or 400k tokens.
61 */
62export function shouldOfferHandoff({ percent, tokens, snoozed }: HandoffCheck): boolean {
63  const percentLimit = snoozed ? SNOOZE_PERCENT : HANDOFF_PERCENT
64  const tokenLimit = snoozed ? SNOOZE_TOKENS : HANDOFF_TOKENS
65  return (typeof percent === 'number' && percent >= percentLimit) || (typeof tokens === 'number' && tokens >= tokenLimit)
66}
67
68// ---------------------------------------------------------------------------
69// Prompt-cache clock
70// ---------------------------------------------------------------------------
71
72export type CacheStateName = 'warm' | 'cooling' | 'cold' | 'unknown'
73
74export type CacheState = {
75  state: CacheStateName
76  /** Time until the cache expires (warm and cooling). */
77  msLeft?: number
78  /** Time since the cache expired (cold). */
79  coldForMs?: number
80}
81
82/**
83 * The cache's state from the time of the last main-thread response:
84 * `unknown` with no response yet (or right after /clear or compaction, when the
85 * caller passes undefined); `warm` with more than 10 minutes left; `cooling`
86 * with 10 minutes or less; `cold` from the full hour on. A timestamp in the
87 * future counts as just now.
88 */
89export function cacheState(lastResponseAt: number | undefined, now: number): CacheState {
90  if (lastResponseAt === undefined || !Number.isFinite(lastResponseAt)) return { state: 'unknown' }
91  const elapsed = Math.max(0, now - lastResponseAt)
92  if (elapsed >= CACHE_TTL_MS) return { state: 'cold', coldForMs: elapsed - CACHE_TTL_MS }
93  const msLeft = CACHE_TTL_MS - elapsed
94  return { state: msLeft <= COOLING_MS ? 'cooling' : 'warm', msLeft }
95}
96
97// ---------------------------------------------------------------------------
98// Durations
99// ---------------------------------------------------------------------------
100
101/**
102 * Parses `/keepwarm` durations: `90m`, `2h`, `2h30m`, `6h` (case and spaces
103 * ignored). Returns milliseconds, or undefined for anything else, for zero, and
104 * for anything over MAX_WINDOW_MS (12h): over the cap is REJECTED, not clamped,
105 * so the command can say "max 12h" rather than silently shortening the window.
106 * Pattern adapted from cache-tax's parseDuration (MIT, (c) 2026 Karan Bansal).
107 */
108export function parseDuration(text: string): number | undefined {
109  const match = /^(?:(\d+)h)?(?:(\d+)m)?$/.exec(text.trim().toLowerCase().replace(/\s+/g, ''))
110  if (!match || (match[1] === undefined && match[2] === undefined)) return undefined
111  const ms = (Number(match[1] ?? 0) * 60 + Number(match[2] ?? 0)) * MINUTE_MS
112  if (ms <= 0 || ms > MAX_WINDOW_MS) return undefined
113  return ms
114}
115
116/** A span for "41m left" or "next ping 12m": `<1m`, `41m`, `2h 5m`, `3h`, `2d 4h` (48h and up). */
117export function formatSpan(ms: number): string {
118  const minutes = Math.floor(Math.max(0, ms) / MINUTE_MS)
119  if (minutes < 1) return '<1m'
120  if (minutes < 60) return `${minutes}m`
121  const hours = Math.floor(minutes / 60)
122  if (hours >= 48) {
123    const rest = hours % 24
124    return rest > 0 ? `${Math.floor(hours / 24)}d ${rest}h` : `${Math.floor(hours / 24)}d`
125  }
126  const rest = minutes % 60
127  return rest > 0 ? `${hours}h ${rest}m` : `${hours}h`
128}
129
130/** How long ago, for "from 10h ago" or "went cold 9h ago": `just now`, `9m ago`, `10h ago`, `3d ago`. */
131export function formatAgo(ms: number): string {
132  const minutes = Math.floor(Math.max(0, ms) / MINUTE_MS)
133  if (minutes < 1) return 'just now'
134  if (minutes < 60) return `${minutes}m ago`
135  const hours = Math.floor(minutes / 60)
136  if (hours < 48) return `${hours}h ago`
137  return `${Math.floor(hours / 24)}d ago`
138}
139
140// ---------------------------------------------------------------------------
141// Numbers and the meter
142// ---------------------------------------------------------------------------
143
144/** A token count in short form: `950`, `231k`, `1.2M`, `2M`. */
145export function formatTokens(n: number): string {
146  const value = Math.max(0, Math.round(Number.isFinite(n) ? n : 0))
147  if (value < 1000) return String(value)
148  const thousands = Math.round(value / 1000)
149  if (thousands < 1000) return `${thousands}k`
150  return `${(value / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`
151}
152
153/** Dollars for the row: `$4.80`, `$0.03`, `$120`. */
154export function formatUsd(usd: number): string {
155  return usd >= 100 ? `$${usd.toFixed(0)}` : `$${usd.toFixed(2)}`
156}
157
158/**
159 * A meter of `cells` cells (default 8): `▰▰▰▰▰▱▱▱` at 58%, the same block meter the
160 * skins mod draws for context and rate limits. Percent is clamped to 0..100.
161 */
162export function meterBar(percent: number, cells = 8): string {
163  const safe = Number.isFinite(percent) ? Math.min(100, Math.max(0, percent)) : 0
164  const count = Math.max(0, Math.floor(cells))
165  const filled = Math.min(count, Math.round((safe / 100) * count))
166  return '▰'.repeat(filled) + '▱'.repeat(count - filled)
167}
168
169// ---------------------------------------------------------------------------
170// The meter row and the status card
171// ---------------------------------------------------------------------------
172
173/**
174 * The context figures Relay keeps, as `SessionContextUsage` reports them:
175 * `tokens` and `percent` absent before the first response of the live window.
176 * `percent` is of the model's full window (`window`), not the auto-compact window.
177 */
178export type ContextFigures = { tokens?: number; percent?: number; window?: number }
179
180/** The fill as a whole percent: the engine's own, else tokens over window; undefined with no reading. */
181export function contextPercent(context: ContextFigures | null | undefined): number | undefined {
182  if (!context) return undefined
183  if (typeof context.percent === 'number' && Number.isFinite(context.percent)) return Math.round(context.percent)
184  if (typeof context.tokens === 'number' && typeof context.window === 'number' && context.window > 0) {
185    return Math.round((context.tokens / context.window) * 100)
186  }
187  return undefined
188}
189
190/** `58% · 231k`, `231k` with tokens alone, `—` before the first response. */
191export function contextText(context: ContextFigures | null | undefined): string {
192  const percent = contextPercent(context)
193  const tokens = typeof context?.tokens === 'number' ? formatTokens(context.tokens) : undefined
194  if (percent !== undefined && tokens !== undefined) return `${percent}% · ${tokens}`
195  if (percent !== undefined) return `${percent}%`
196  return tokens ?? '—'
197}
198
199export const ROW_TITLE = '◆ Relay'
200/** The Handoff button as the terminal draws it, `[ Handoff ]`. */
201export const HANDOFF_BUTTON_WIDTH = '[ Handoff ]'.length
202export const HANDOFF_NOW_WIDTH = '[ Handoff now ]'.length
203export const NOT_YET_WIDTH = '[ Not yet ]'.length
204
205/** The meter row's parts at one width; `bar` and `left` are dropped (in that order) when the row would not fit. */
206export type MeterRow = {
207  /** The 10-cell meter, or undefined when dropped or with no reading. */
208  bar?: string
209  figures: string
210  cache: CacheStateName
211  /** `41m left` for warm and cooling, undefined when dropped or for cold/unknown. */
212  left?: string
213}
214
215/** The row's text as drawn, for measuring: `◆ Relay   ctx ▰▰▰▰▰▱▱▱ 58% · 231k   cache ● warm · 41m left`. */
216export function meterRowText(row: MeterRow): string {
217  const bar = row.bar !== undefined ? `${row.bar} ` : ''
218  const left = row.left !== undefined ? ` · ${row.left}` : ''
219  return `${ROW_TITLE}   ctx ${bar}${row.figures}   cache ● ${row.cache}${left}`
220}
221
222/**
223 * Picks the meter row for `columns` cells, keeping two cells of gap and the
224 * Handoff button: the full row, else without the bar, else without the time left.
225 */
226export function meterRow(context: ContextFigures | null | undefined, cache: CacheState, columns: number): MeterRow {
227  const percent = contextPercent(context)
228  const base: MeterRow = { figures: contextText(context), cache: cache.state }
229  const left = cache.msLeft !== undefined && (cache.state === 'warm' || cache.state === 'cooling') ? `${formatSpan(cache.msLeft)} left` : undefined
230  const room = columns - HANDOFF_BUTTON_WIDTH - 2
231  const full: MeterRow = { ...base, ...(percent !== undefined ? { bar: meterBar(percent) } : {}), ...(left ? { left } : {}) }
232  if (meterRowText(full).length <= room) return full
233  const noBar: MeterRow = { ...base, ...(left ? { left } : {}) }
234  if (meterRowText(noBar).length <= room) return noBar
235  return base
236}
237
238/** The threshold prompt: `Context is at 81% (322k). Hand off and start fresh?` */
239export function thresholdText(context: ContextFigures | null | undefined): string {
240  const percent = contextPercent(context)
241  const tokens = typeof context?.tokens === 'number' ? formatTokens(context.tokens) : undefined
242  const at =
243    percent !== undefined && tokens !== undefined ? `${percent}% (${tokens})` : percent !== undefined ? `${percent}%` : `${tokens ?? '?'} tokens`
244  return `Context is at ${at}. Hand off and start fresh?`
245}
246
247/** Cuts text to `width` cells with an ellipsis. */
248export function fit(text: string, width: number): string {
249  if (width <= 0) return ''
250  return text.length <= width ? text : `${text.slice(0, Math.max(0, width - 1))}…`
251}
252
253/** What the `/relay` status card is built from. Later build steps fill `warming` and `pending`. */
254export type StatusCardState = {
255  enabled: boolean
256  context: ContextFigures | null
257  cache: CacheState
258  snoozed: boolean
259  /** One line on the warming window; undefined until warming exists. */
260  warming?: string
261  /** One line on handoffs waiting for this project; undefined until handoffs exist. */
262  pending?: string
263}
264
265/** The `/relay` status card, one fact per line. */
266export function statusCard(state: StatusCardState): string {
267  const percent = contextPercent(state.context)
268  const tokens = typeof state.context?.tokens === 'number' ? state.context.tokens : undefined
269  const window = state.context?.window
270  const ctx =
271    percent === undefined && tokens === undefined
272      ? 'no reading yet (it appears after the first reply)'
273      : `${contextText(state.context)}${typeof window === 'number' && window > 0 ? ` of a ${formatTokens(window)} window` : ''}`
274
275  const { cache } = state
276  const cacheLine =
277    cache.state === 'unknown'
278      ? 'unknown (no reply since the session started, was cleared or compacted)'
279      : cache.state === 'cold'
280        ? `cold for ${formatSpan(cache.coldForMs ?? 0)}`
281        : `${cache.state} · ${formatSpan(cache.msLeft ?? 0)} left`
282
283  const offered = shouldOfferHandoff({ percent, tokens, snoozed: state.snoozed })
284  const handoff = offered
285    ? 'offered now (context is past the handoff point)'
286    : state.snoozed
287      ? `snoozed until ${SNOOZE_PERCENT}% or ${formatTokens(SNOOZE_TOKENS)} tokens`
288      : `offered at ${HANDOFF_PERCENT}% or ${formatTokens(HANDOFF_TOKENS)} tokens`
289
290  return [
291    `Relay is ${state.enabled ? 'on' : 'off'}.`,
292    `Context: ${ctx}`,
293    `Prompt cache: ${cacheLine}`,
294    `Handoff: ${handoff}`,
295    `Warming: ${state.warming ?? 'not available yet'}`,
296    `Waiting handoffs: ${state.pending ?? 'not available yet'}`,
297    `Commands: /relay on, /relay off, /relay overnight on|off, /handoff, /handoff list, /keepwarm 2h|off|status`,
298  ].join('\n')
299}
300
301// ---------------------------------------------------------------------------
302// Prices
303// ---------------------------------------------------------------------------
304
305export type ModelPrice = {
306  /** $ per million cache-read tokens. */
307  read: number
308  /** $ per million 1-hour cache-write tokens. */
309  write: number
310  /** $ per million output tokens. */
311  output: number
312}
313
314/**
315 * $ per million tokens by model family, list prices September 2026. Longer
316 * family names first: a model id matches the first row it contains.
317 * Adapted from cache-tax's PRICES table (MIT License, Copyright (c) 2026 Karan Bansal).
318 */
319export const PRICES: ReadonlyArray<readonly [family: string, price: ModelPrice]> = [
320  ['fable-5-1', { read: 0.25, write: 20, output: 50 }],
321  ['fable-5', { read: 1, write: 20, output: 50 }],
322  ['opus-5', { read: 0.5, write: 10, output: 25 }],
323  ['opus-4', { read: 0.5, write: 10, output: 25 }],
324  ['sonnet-5', { read: 0.2, write: 4, output: 10 }],
325  ['sonnet', { read: 0.3, write: 6, output: 15 }],
326  ['haiku', { read: 0.1, write: 2, output: 5 }],
327]
328
329/** The price row for a model id (`claude-opus-5-5`, `Opus 5.5`, ...), or undefined when unknown. */
330export function modelPrice(modelId: string | undefined | null): ModelPrice | undefined {
331  const id = (modelId ?? '').toLowerCase().replace(/[\s.]+/g, '-')
332  if (id === '') return undefined
333  for (const [family, price] of PRICES) if (id.includes(family)) return price
334  return undefined
335}
336
337/** The 1-hour cache-write price in $ per million tokens, or undefined for an unknown model. */
338export function cacheWritePricePerMTok(modelId: string | undefined | null): number | undefined {
339  return modelPrice(modelId)?.write
340}
341
342/** What re-writing `tokens` of context into the cache costs, in $; undefined for an unknown model. */
343export function estimateRewriteUsd(tokens: number, modelId: string | undefined | null): number | undefined {
344  const write = cacheWritePricePerMTok(modelId)
345  if (write === undefined || !Number.isFinite(tokens)) return undefined
346  return (Math.max(0, tokens) * write) / 1_000_000
347}
348
349// ---------------------------------------------------------------------------
350// Paths and names
351// ---------------------------------------------------------------------------
352
353/**
354 * The folder name for a project: the session root with `:`, `\`, `/` and spaces
355 * turned into `-`, a trailing separator dropped and a leading drive letter
356 * upper-cased, so `d:\x`, `D:\x` and `D:/x` share one key.
357 */
358export function projectKey(root: string): string {
359  const trimmed = root.length > 1 ? root.replace(/[\\/]+$/, '') : root
360  const drive = trimmed.replace(/^([a-z]):/, (_, letter: string) => `${letter.toUpperCase()}:`)
361  return drive.replace(/[:\\/ ]/g, '-')
362}
363
364/** A short file-safe slug: lowercase letters and digits joined by `-`, at most 50 chars; `handoff` when empty. */
365export function slugify(title: string): string {
366  const slug = title
367    .toLowerCase()
368    .replace(/[^a-z0-9]+/g, '-')
369    .replace(/^-+|-+$/g, '')
370    .slice(0, 50)
371    .replace(/-+$/, '')
372  return slug === '' ? 'handoff' : slug
373}
374
375function pad(n: number): string {
376  return String(n).padStart(2, '0')
377}
378
379/** `<YYYY-MM-DD-HHmmss>-<slug>.md` in the local time of `date` (seconds, so two handoffs in one minute never collide). */
380export function handoffFileName(date: Date, title: string): string {
381  const stamp =
382    `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}-` +
383    `${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`
384  return `${stamp}-${slugify(title)}.md`
385}
386
387/** `<home>/.claude/handoffs/<project-key>`, using the separator `home` uses. */
388export function handoffDir(home: string, key: string): string {
389  const sep = home.includes('\\') ? '\\' : '/'
390  const base = home.replace(/[\\/]+$/, '')
391  return [base, '.claude', 'handoffs', key].join(sep)
392}
393
394// ---------------------------------------------------------------------------
395// Git facts
396// ---------------------------------------------------------------------------
397
398/** Raw output of the read-only git commands; undefined where the command failed. */
399export type GitOutputs = {
400  /** `git rev-parse --abbrev-ref HEAD`; undefined when it failed (not a repo, or a repo with no commits). */
401  branch?: string
402  /** `git symbolic-ref --short HEAD`: the branch when `branch` failed in a repo with no commits. Both undefined means not a repo. */
403  symbolicRef?: string
404  /** `git log -1 --format=%h %s`. */
405  log?: string
406  /** `git rev-list --left-right --count @{u}...HEAD`; undefined when there is no upstream. */
407  aheadBehind?: string
408  /** `git status --porcelain`. */
409  porcelain?: string
410}
411
412export type GitChange = { status: string; path: string }
413
414export type GitFacts =
415  | { isRepo: false }
416  | {
417      isRepo: true
418      /** Branch name, or undefined when HEAD is detached. */
419      branch?: string
420      head?: { sha: string; subject: string }
421      /** Undefined when there is no upstream (or the count could not be read). */
422      upstream?: { ahead: number; behind: number }
423      changedCount: number
424      /** The first 10 changes. */
425      changes: GitChange[]
426    }
427
428/** Most uncommitted files listed by name. */
429export const GIT_CHANGES_SHOWN = 10
430
431/**
432 * One `git status --porcelain` line as status and path. Tolerates a line whose
433 * leading space was trimmed away (` M x` read as `M x`); a rename
434 * (`R  old -> new`) keeps the new path.
435 */
436export function parsePorcelainLine(line: string): GitChange {
437  const match = /^\s*(\S{1,2})\s+(.*)$/.exec(line)
438  if (!match) return { status: '?', path: line.trim() }
439  const path = (match[2] ?? '').trim()
440  const arrow = path.lastIndexOf(' -> ')
441  return { status: match[1] ?? '?', path: arrow >= 0 ? path.slice(arrow + 4).trim() : path }
442}
443
444/**
445 * Turns the git commands' output into facts. Tolerates missing upstream,
446 * not-a-repo, and a repo with no commits (where rev-parse fails but
447 * `symbolicRef` still names the branch; `head` is then undefined).
448 */
449export function parseGitFacts({ branch, symbolicRef, log, aheadBehind, porcelain }: GitOutputs): GitFacts {
450  const name = branch?.trim() || symbolicRef?.trim()
451  if (name === undefined || name === '') return { isRepo: false }
452
453  const logLine = log?.trim().split(/\r?\n/)[0] ?? ''
454  const logMatch = /^(\S+)(?:\s+(.*))?$/.exec(logLine)
455  const head = logMatch?.[1] ? { sha: logMatch[1], subject: (logMatch[2] ?? '').trim() } : undefined
456
457  const counts = /^(\d+)\s+(\d+)$/.exec(aheadBehind?.trim() ?? '')
458  const upstream = counts ? { behind: Number(counts[1]), ahead: Number(counts[2]) } : undefined
459
460  const lines = (porcelain ?? '').split(/\r?\n/).filter(line => line.trim() !== '')
461  const changes = lines.slice(0, GIT_CHANGES_SHOWN).map(parsePorcelainLine)
462
463  return {
464    isRepo: true,
465    branch: name === 'HEAD' ? undefined : name,
466    head,
467    upstream,
468    changedCount: lines.length,
469    changes,
470  }
471}
472
473/** Markdown bullet lines for "Where things stand"; empty for not-a-repo. */
474export function formatGitFacts(facts: GitFacts): string[] {
475  if (!facts.isRepo) return []
476  const lines: string[] = []
477  lines.push(facts.branch ? `- Branch: \`${facts.branch}\`` : '- Branch: detached HEAD')
478  lines.push(facts.head ? `- HEAD: \`${facts.head.sha}\` ${facts.head.subject}`.trimEnd() : '- HEAD: no commits yet')
479  lines.push(
480    facts.upstream
481      ? `- Upstream: ${facts.upstream.ahead} ahead, ${facts.upstream.behind} behind`
482      : '- Upstream: none set',
483  )
484  if (facts.changedCount === 0) {
485    lines.push('- Uncommitted changes: none')
486  } else {
487    lines.push(`- Uncommitted changes: ${facts.changedCount} file${facts.changedCount === 1 ? '' : 's'}`)
488    for (const change of facts.changes) lines.push(`  - \`${change.status}\` ${change.path}`)
489    const more = facts.changedCount - facts.changes.length
490    if (more > 0) lines.push(`  - ...and ${more} more`)
491  }
492  return lines
493}
494
495// ---------------------------------------------------------------------------
496// Handoff prompt
497// ---------------------------------------------------------------------------
498
499/** The instruction the handoff fork runs with, with the git facts (markdown lines joined) appended. */
500export function buildHandoffPrompt(factsMarkdown: string): string {
501  const facts = factsMarkdown.trim() === '' ? '(none: the session root is not a git repository)' : factsMarkdown.trim()
502  return [
503    'Write a handoff brief so a fresh Claude Code session, which will not see this conversation, can pick up the work exactly where it stands.',
504    'Reply with the brief only: no preamble, no closing remarks, no code fence around it.',
505    '',
506    'Layout, in this order:',
507    '1. First line: a one-line title for the work, plain text, no heading marks, under 70 characters.',
508    '2. `## Where things stand`: what is done, what is half done, and the decisions made and why. Include the git facts below verbatim.',
509    '3. `## Read first`: the files, docs and memory files the next session should read before acting, as full paths.',
510    "4. `## What's next`: the next concrete steps, in order.",
511    '5. `## Hard rules`: every constraint the user stated in this conversation, copied verbatim and in quotes. Leave none out and do not paraphrase.',
512    '6. `## How to run it`: the method (for example subagent-driven development, one task per subagent), where to stop and check in with the user, and the checks to run.',
513    '',
514    'Git facts (copy these lines verbatim under Where things stand):',
515    facts,
516  ].join('\n')
517}
518
519const SECTION_HEADING = /^#{1,6}\s*(where things stand|read first|what's next|hard rules|how to run it)\b/i
520
521/** The handoff's title: its first non-empty line without heading marks, bold, quotes or a `Title:` label; `Handoff` when none. */
522export function extractTitle(handoffText: string): string {
523  const first = handoffText.split(/\r?\n/).find(line => line.trim() !== '' && !line.trim().startsWith('```')) ?? ''
524  if (SECTION_HEADING.test(first.trim())) return 'Handoff'
525  const title = first
526    .trim()
527    .replace(/^#+\s*/, '')
528    .replace(/^title:\s*/i, '')
529    .replace(/^\*\*(.*)\*\*$/, '$1')
530    .replace(/^title:\s*/i, '')
531    .replace(/^["'`](.*)["'`]$/, '$1')
532    .trim()
533  if (title === '') return 'Handoff'
534  return title.length > 80 ? `${title.slice(0, 79).trimEnd()}…` : title
535}
536
537// ---------------------------------------------------------------------------
538// Handoff index (plain arrays; the caller keeps it in the store)
539// ---------------------------------------------------------------------------
540
541/** Adds a record (replacing one with the same id), newest first, capped at HANDOFF_INDEX_CAP. */
542export function addHandoff(index: readonly RelayHandoff[], rec: RelayHandoff): RelayHandoff[] {
543  return [rec, ...index.filter(one => one.id !== rec.id)]
544    .sort((a, b) => b.createdAt - a.createdAt)
545    .slice(0, HANDOFF_INDEX_CAP)
546}
547
548/** The newest unconsumed handoff for this project made within the last 7 days, if any. */
549export function pendingFor(index: readonly RelayHandoff[], key: string, now: number): RelayHandoff | undefined {
550  let best: RelayHandoff | undefined
551  for (const one of index) {
552    if (one.projectKey !== key || one.consumedAt !== undefined) continue
553    if (now - one.createdAt >= HANDOFF_MAX_AGE_MS) continue
554    if (best === undefined || one.createdAt > best.createdAt) best = one
555  }
556  return best
557}
558
559/** Marks a handoff consumed at `now`; an already consumed one keeps its first time. */
560export function markConsumed(index: readonly RelayHandoff[], id: string, now: number): RelayHandoff[] {
561  return index.map(one => (one.id === id && one.consumedAt === undefined ? { ...one, consumedAt: now } : one))
562}
563
564/** The index as kept in the store, with anything that is not a well-formed record dropped. */
565export function readIndex(value: unknown): RelayHandoff[] {
566  if (!Array.isArray(value)) return []
567  return value.filter(
568    (one): one is RelayHandoff =>
569      typeof one === 'object' &&
570      one !== null &&
571      typeof (one as RelayHandoff).id === 'string' &&
572      typeof (one as RelayHandoff).projectKey === 'string' &&
573      typeof (one as RelayHandoff).title === 'string' &&
574      typeof (one as RelayHandoff).path === 'string' &&
575      typeof (one as RelayHandoff).createdAt === 'number',
576  )
577}
578
579/** How many handoffs `/handoff list` shows. */
580export const HANDOFF_LIST_COUNT = 20
581
582/** `/handoff list`: the last 20 handoffs, this project's first, newest first within each group. */
583export function handoffListText(index: readonly RelayHandoff[], key: string, now: number): string {
584  if (index.length === 0) return 'No handoffs yet. Type /handoff to write one.'
585  const newest = [...index].sort((a, b) => b.createdAt - a.createdAt)
586  const ordered = [...newest.filter(one => one.projectKey === key), ...newest.filter(one => one.projectKey !== key)]
587  const lines = ordered.slice(0, HANDOFF_LIST_COUNT).map(one => {
588    const where = one.path === '' ? '(no file)' : one.path
589    const state = one.consumedAt === undefined ? 'waiting' : 'used'
590    return `• ${one.title} — ${formatAgo(now - one.createdAt)} — ${where} — ${state}`
591  })
592  return ['Handoffs (this project first):', ...lines].join('\n')
593}
594
595/** The pending offer's text: `Handoff waiting: "<title>" · from 10h ago`. */
596export function offerText(one: Pick<RelayHandoff, 'title' | 'createdAt'>, now: number): string {
597  return `Handoff waiting: "${one.title}" · from ${formatAgo(now - one.createdAt)}`
598}
599
600/** The status card's "Waiting handoffs" line for this project: `"<title>" · from 10h ago`, or `none`. */
601export function pendingLine(index: readonly RelayHandoff[], key: string, now: number): string {
602  const one = pendingFor(index, key, now)
603  return one === undefined ? 'none' : `"${one.title}" · from ${formatAgo(now - one.createdAt)}`
604}
605
606// ---------------------------------------------------------------------------
607// Handoff flow texts
608// ---------------------------------------------------------------------------
609
610/** How long a "fill after clear" marker stays good. */
611export const FILL_MAX_AGE_MS = 10 * MINUTE_MS
612
613/**
614 * The cold-cache confirmation: `Cache is cold — writing the handoff re-writes ~240k tokens (≈ $4.80).`,
615 * tokens only when the model's price is unknown.
616 */
617export function coldHandoffText(tokens: number, usd: number | undefined): string {
618  const cost = usd === undefined ? '' : ` (≈ ${formatUsd(usd)})`
619  return `Cache is cold — writing the handoff re-writes ~${formatTokens(tokens)} tokens${cost}.`
620}
621
622/** Why the handoff fork gave no text, in plain words. */
623export function forkFailureText(reason: string, status?: number): string {
624  switch (reason) {
625    case 'nothing-to-fork':
626      return 'Nothing to hand off yet — no replies in this session.'
627    case 'api-error':
628      return `The handoff could not be written: the API answered with an error${typeof status === 'number' ? ` (${status})` : ''}.`
629    case 'empty-reply':
630      return 'The handoff came back empty. Try again.'
631    case 'aborted':
632      return 'Writing the handoff was interrupted.'
633    default:
634      return `The handoff could not be written (${reason}).`
635  }
636}
637
638/** A file in a folder, joined with the separator the folder uses. */
639export function joinPath(dir: string, name: string): string {
640  const sep = dir.includes('\\') ? '\\' : '/'
641  return `${dir.replace(/[\\/]+$/, '')}${sep}${name}`
642}
643
644/** What goes in the prompt box after a clear: the handoff, then a held prompt below a rule when there is one. */
645export function fillText(text: string, heldText?: string): string {
646  return heldText !== undefined && heldText.trim() !== '' ? `${text}\n\n---\n${heldText}` : text
647}
648
649// ---------------------------------------------------------------------------
650// Warming and the cold-cache guard
651// ---------------------------------------------------------------------------
652
653/** The warming ping: as short as a fork can be asked (a fork takes no token cap). */
654export const PING_PROMPT = 'Reply with the single word: warm'
655
656/** The four token counts a fork's usage reports. */
657export type ForkUsage = {
658  input_tokens: number
659  output_tokens: number
660  cache_read_input_tokens: number
661  cache_creation_input_tokens: number
662}
663
664/**
665 * What a ping billed: the cache read, any cache write, uncached input at the base
666 * rate (half the 1-hour write rate) and the output.
667 * Adapted from cache-tax's pingUsd (MIT License, Copyright (c) 2026 Karan Bansal).
668 */
669export function pingUsd(usage: ForkUsage, price: ModelPrice): number {
670  return (
671    (usage.cache_read_input_tokens * price.read +
672      usage.cache_creation_input_tokens * price.write +
673      (usage.input_tokens * price.write) / 2 +
674      usage.output_tokens * price.output) /
675    1_000_000
676  )
677}
678
679/**
680 * Why a ping's receipt means warming should stop, or undefined when it read the cache:
681 * nothing read (the cache was gone), or a write of a tenth of the read or more (the
682 * prefix was not reused). The 10% rule is cache-tax's (MIT, (c) 2026 Karan Bansal).
683 */
684export function pingProblem(read: number, write: number): string | undefined {
685  if (read === 0) return 'the ping read no cache'
686  if (write >= 0.1 * read) return `the ping re-wrote ${formatTokens(write)} tokens`
687  return undefined
688}
689
690/** A failed ping (or the overnight handoff's fork) in a few words for the row. */
691export function forkStopReason(reason: string, status?: number): string {
692  switch (reason) {
693    case 'nothing-to-fork':
694      return 'no conversation to warm'
695    case 'api-error':
696      return `API error${typeof status === 'number' ? ` (${status})` : ''}`
697    case 'empty-reply':
698      return 'an empty reply'
699    case 'aborted':
700      return 'interrupted'
701    default:
702      return reason
703  }
704}
705
706/** Whether a window is armed at `now`. */
707export function isWarming(w: RelayWarming | null | undefined, now: number): boolean {
708  return w !== null && w !== undefined && w.windowEnd !== null && now < w.windowEnd
709}
710
711/**
712 * What the meter row appends: ` · warming · next ping 12m` while armed (` · warming · ends 12m`
713 * once the window has no room for another ping), ` · warming stopped: <reason>` after a stop
714 * until the next reply, else nothing.
715 */
716export function warmingSuffix(w: RelayWarming | null | undefined, now: number): string | undefined {
717  if (w === null || w === undefined) return undefined
718  if (isWarming(w, now) && w.windowEnd !== null) {
719    if (w.nextPingAt !== undefined && w.nextPingAt < w.windowEnd) return ` · warming · next ping ${formatSpan(w.nextPingAt - now)}`
720    return ` · warming · ends ${formatSpan(w.windowEnd - now)}`
721  }
722  return w.stopped !== undefined ? ` · warming stopped: ${w.stopped}` : undefined
723}
724
725/** A receipt in words: `last ping 38m ago read 231k, wrote 120 ($0.12)`. */
726export function receiptText(r: RelayPingReceipt, now: number): string {
727  const usd = r.usd === undefined ? '' : ` (${formatUsd(r.usd)})`
728  return `last ping ${formatAgo(now - r.at)} read ${formatTokens(r.read)}, wrote ${formatTokens(r.write)}${usd}`
729}
730
731/** The status card's warming line: the window, the next ping, the last receipt and the overnight setting. */
732export function warmingLine(w: RelayWarming | null | undefined, now: number, overnight: boolean): string {
733  const parts: string[] = []
734  if (w?.off === true && !isWarming(w, now)) parts.push('off for this session (/keepwarm 2h warms again)')
735  else if (isWarming(w, now) && w && w.windowEnd !== null) {
736    parts.push(`on, window ends in ${formatSpan(w.windowEnd - now)}`)
737    parts.push(
738      w.nextPingAt === undefined
739        ? 'next ping 50m after the next reply'
740        : w.nextPingAt < w.windowEnd
741          ? `next ping in ${formatSpan(w.nextPingAt - now)}`
742          : 'no more pings in this window',
743    )
744  } else if (w?.stopped !== undefined) parts.push(`stopped: ${w.stopped}`)
745  else parts.push(`idle (each reply arms ${formatSpan(WARM_WINDOW_MS)} once context is ${formatTokens(MIN_WARM_TOKENS)}+)`)
746  if (w?.last !== undefined) parts.push(receiptText(w.last, now))
747  parts.push(`overnight handoff ${overnight ? 'on' : 'off'}`)
748  return parts.join(' · ')
749}
750
751/** The guard's row: `Cache went cold 9h ago. Sending this re-writes ~240k tokens (≈ $4.80).`, tokens only for an unknown price. */
752export function coldGuardText(coldForMs: number, tokens: number, usd: number | undefined): string {
753  const cost = usd === undefined ? '' : ` (≈ ${formatUsd(usd)})`
754  return `Cache went cold ${formatAgo(coldForMs)}. Sending this re-writes ~${formatTokens(tokens)} tokens${cost}.`
755}
756
757/** What the person sees where the held prompt would have gone (the drop reason). */
758export function coldDropText(row: string): string {
759  return `Relay held this prompt: ${row.charAt(0).toLowerCase()}${row.slice(1)} Choose Handoff & start fresh or Send anyway on the Relay row above the prompt, or send it again to pay it.`
760}
761
762/** `/keepwarm`'s help line. */
763export const KEEPWARM_HELP = 'keepwarm takes a window such as 90m, 2h or 2h30m (max 12h), or off, or status.'
764
hooks/warming.ts 195 lines
1import type { PromptSubmitInput } from 'claude-code'
2
3import type { RelayWarming } from '../types'
4import { MIN_WARM_TOKENS, PING_IDLE_MS, WARM_WINDOW_MS, cacheState, formatSpan, formatTokens } from './core'
5
6/**
7 * Relay's cache warming and cold-cache guard, the decisions: what a reply does to
8 * the window, what the timer does when it fires, what `/keepwarm` sets, and whether
9 * the guard holds a prompt. Nothing here touches `$` (the validator follows `$` only
10 * into functions of the file that holds the hook), so register.tsx does the calls
11 * and this file is tested directly. The ping loop, its stop rules and the guard's
12 * hold-once-then-let-through are adapted from the cache-tax mod
13 * (MIT License, Copyright (c) 2026 Karan Bansal).
14 */
15
16/** No window, warming not switched off. */
17export const NO_WARMING: RelayWarming = { windowEnd: null, off: false }
18
19/** A stored RelayWarming (`relay.warming.<session id>`), or undefined when the value is not one. */
20export function readWarming(value: unknown): RelayWarming | undefined {
21  if (typeof value !== 'object' || value === null) return undefined
22  const w = value as RelayWarming
23  if (!(w.windowEnd === null || typeof w.windowEnd === 'number') || typeof w.off !== 'boolean') return undefined
24  return w
25}
26
27/** A restored window that already ended is dropped (its `off` and receipt stay). */
28export function restoredWarming(w: RelayWarming, now: number): RelayWarming {
29  return w.windowEnd !== null && w.windowEnd <= now ? { ...w, windowEnd: null, nextPingAt: undefined } : w
30}
31
32/** When the timer should fire: the next ping, or the window's end when no ping is due before it. */
33export function dueAt(w: RelayWarming | null | undefined): number | undefined {
34  if (w === null || w === undefined || w.windowEnd === null) return undefined
35  return w.nextPingAt !== undefined ? Math.min(w.nextPingAt, w.windowEnd) : w.windowEnd
36}
37
38/** Whether the store should keep this warming (a window, or `/keepwarm off`); else its key is deleted. */
39export function isWorthKeeping(w: RelayWarming): boolean {
40  return w.windowEnd !== null || w.off
41}
42
43/** The window stopped, with a reason the row shows until the next reply. */
44export function stopped(w: RelayWarming, reason: string, extra: Partial<RelayWarming> = {}): RelayWarming {
45  return { ...w, ...extra, windowEnd: null, nextPingAt: undefined, stopped: reason }
46}
47
48/**
49 * A main-thread reply landed at `now`: the stop reason clears, and (Relay on, not
50 * `/keepwarm off`, context ≥ 50k) the window is armed or extended to two hours from
51 * now with the next ping 50 idle minutes out. A longer window `/keepwarm` set stays.
52 */
53export function afterReply(w: RelayWarming, facts: { now: number; enabled: boolean; tokens: number | undefined }): RelayWarming {
54  const base: RelayWarming = { ...w, lastTurnAt: facts.now, stopped: undefined }
55  const canWarm = typeof facts.tokens === 'number' && facts.tokens >= MIN_WARM_TOKENS
56  if (!facts.enabled || w.off || !canWarm) return { ...base, windowEnd: null, nextPingAt: undefined }
57  return { ...base, windowEnd: Math.max(w.windowEnd ?? 0, facts.now + WARM_WINDOW_MS), nextPingAt: facts.now + PING_IDLE_MS }
58}
59
60/** What the timer does when it fires. */
61export type DueAction =
62  /** Nothing armed. */
63  | { kind: 'none' }
64  /** Relay was switched off: drop the window quietly. */
65  | { kind: 'drop' }
66  /** Stop, with the reason for the row. */
67  | { kind: 'stop'; reason: string }
68  /**
69   * A reply moved the ping since the timer was set, or a main-thread turn is running:
70   * set it again (the timer is not armed while a turn runs; the turn's end arms it).
71   */
72  | { kind: 'rearm' }
73  /** Ping; `isLast` when the next one would fall at or after the window's end. */
74  | { kind: 'ping'; isLast: boolean }
75  /**
76   * The window ended with no ping left in it while the cache is still warm (a
77   * `/keepwarm` window shorter than the first ping, or overnight handoff off): the
78   * overnight handoff may run now; otherwise it stops with `reason`.
79   */
80  | { kind: 'end'; reason: string }
81
82export function dueAction(facts: {
83  w: RelayWarming | null
84  enabled: boolean
85  lastResponseAt: number | null
86  tokens: number | undefined
87  now: number
88  /** A main-thread turn is running: no ping and no handoff mid-turn. */
89  isTurnRunning?: boolean
90}): DueAction {
91  const { w, now } = facts
92  if (w === null || w.windowEnd === null) return { kind: 'none' }
93  if (!facts.enabled) return { kind: 'drop' }
94  if (facts.isTurnRunning === true) return { kind: 'rearm' }
95  const cache = cacheState(facts.lastResponseAt ?? undefined, now)
96  const isLive = cache.state === 'warm' || cache.state === 'cooling'
97  const isBig = typeof facts.tokens === 'number' && facts.tokens >= MIN_WARM_TOKENS
98  if (now >= w.windowEnd) {
99    const hadNoPingLeft = w.nextPingAt !== undefined && w.nextPingAt >= w.windowEnd
100    return hadNoPingLeft && isLive && isBig ? { kind: 'end', reason: 'the window ended' } : { kind: 'stop', reason: 'the window ended' }
101  }
102  if (w.nextPingAt === undefined || now < w.nextPingAt - 1000) return { kind: 'rearm' }
103  // Never pay a write to "warm" a cache that is already gone.
104  if (!isLive) return { kind: 'stop', reason: 'the cache is already cold' }
105  if (!isBig) return { kind: 'stop', reason: `context under ${formatTokens(MIN_WARM_TOKENS)}` }
106  return { kind: 'ping', isLast: now + PING_IDLE_MS >= w.windowEnd }
107}
108
109/** What `/keepwarm <duration>` sets: the window from now, the first ping 50 minutes after the last reply (or after the next one). */
110export function keepwarmWindow(
111  w: RelayWarming,
112  facts: { now: number; ms: number; lastResponseAt: number | null },
113): { warming: RelayWarming; text: string } {
114  const { now, ms, lastResponseAt } = facts
115  const cache = cacheState(lastResponseAt ?? undefined, now)
116  const isLive = lastResponseAt !== null && (cache.state === 'warm' || cache.state === 'cooling')
117  const nextPingAt = isLive ? Math.max(now, lastResponseAt + PING_IDLE_MS) : undefined
118  const warming: RelayWarming = { ...w, off: false, stopped: undefined, windowEnd: now + ms, nextPingAt }
119  const text =
120    nextPingAt === undefined
121      ? `Relay keeps the cache warm for ${formatSpan(ms)}. The cache is ${cache.state} now, so the first ping comes ${formatSpan(PING_IDLE_MS)} after the next reply.`
122      : `Relay keeps the cache warm for ${formatSpan(ms)}, a ping after each ${formatSpan(PING_IDLE_MS)} idle. Next ping in ${formatSpan(nextPingAt - now)}.`
123  return { warming, text }
124}
125
126/**
127 * Whether the guard looks at a submission at all: the person's own typed prompt (the
128 * terminal's Enter or Remote Control), not a slash command, not bash mode (`!`), not
129 * one typed over a running turn, not empty. Not one with attachments, context a hook
130 * attached, or an `@` mention either: Send anyway resubmits bare text and could not
131 * carry them. A plugin's prompt (Relay's own Send anyway included) is never held.
132 */
133export function isGuardable(e: Pick<PromptSubmitInput, 'origin' | 'turnId' | 'attachments' | 'text' | 'context'>): boolean {
134  if (e.origin.kind !== 'composer' && e.origin.kind !== 'bridge') return false
135  if (e.turnId !== undefined || (e.attachments?.length ?? 0) > 0 || (e.context?.length ?? 0) > 0) return false
136  const text = e.text.trimStart()
137  if (text === '' || text.startsWith('/') || text.startsWith('!')) return false
138  return !/(^|\s)@\S/.test(e.text)
139}
140
141/** How long a `/keepwarm off` is kept in the store. */
142export const OFF_KEEP_MS = 3 * 24 * 60 * 60 * 1000
143/** How long a held prompt is kept in the store. */
144export const HELD_KEEP_MS = 7 * 24 * 60 * 60 * 1000
145export const WARMING_KEY_PREFIX = 'relay.warming.'
146export const HELD_KEY_PREFIX = 'relay.heldText.'
147
148/** A held prompt as kept in the store under `relay.heldText.<session id>`. */
149export type StoredHeld = { text: string; at: number }
150
151export function readHeld(value: unknown): StoredHeld | undefined {
152  if (typeof value !== 'object' || value === null) return undefined
153  const held = value as StoredHeld
154  return typeof held.text === 'string' && typeof held.at === 'number' ? held : undefined
155}
156
157/**
158 * Whether a session-start sweep deletes a store entry: a warming record whose window
159 * has passed (or a `/keepwarm off` older than 3 days, or one with no `offAt`), a held
160 * prompt older than 7 days, and anything under those prefixes that is not well formed.
161 * Other keys are never touched.
162 */
163export function isStale(key: string, value: unknown, now: number): boolean {
164  if (key.startsWith(WARMING_KEY_PREFIX)) {
165    const w = readWarming(value)
166    if (w === undefined) return true
167    if (w.windowEnd !== null && w.windowEnd > now) return false
168    if (w.off) return w.offAt === undefined || now - w.offAt >= OFF_KEEP_MS
169    return true
170  }
171  if (key.startsWith(HELD_KEY_PREFIX)) {
172    const held = readHeld(value)
173    return held === undefined || now - held.at >= HELD_KEEP_MS
174  }
175  return false
176}
177
178/**
179 * The guard's verdict on a guardable prompt: `pass` (Relay off, small or warm),
180 * `release` (held once already this cold episode: it goes through and the row goes),
181 * or `hold`. The episode is the `lastResponseAt` the cache went cold from.
182 */
183export function guardVerdict(facts: {
184  enabled: boolean
185  tokens: number | undefined
186  lastResponseAt: number | null
187  episode: number | null
188  now: number
189}): 'pass' | 'release' | 'hold' {
190  if (!facts.enabled || facts.lastResponseAt === null) return 'pass'
191  if (!(typeof facts.tokens === 'number' && facts.tokens >= MIN_WARM_TOKENS)) return 'pass'
192  if (cacheState(facts.lastResponseAt, facts.now).state !== 'cold') return 'pass'
193  return facts.episode === facts.lastResponseAt ? 'release' : 'hold'
194}
195
types/index.d.ts 150 lines
1/** One saved handoff, as kept in the store under `relay.handoffs` (newest first). */
2export type RelayHandoff = {
3  id: string
4  projectKey: string
5  root: string
6  title: string
7  path: string
8  createdAt: number
9  consumedAt?: number
10}
11
12/**
13 * The live context window's figures, as `session.measure` / `$.session.usage()`
14 * report them: `tokens` and `percent` (of the model's full `window`) are absent
15 * before the first response of the live window.
16 */
17export type RelayContext = {
18  tokens?: number
19  percent?: number
20  window?: number
21}
22
23/** The handoff open in the preview pane (`relay-handoff`), until Clear & continue, Cancel or a new one. */
24export type RelayDraft = {
25  /** The index record's id. */
26  id: string
27  /** The project the handoff was written in. */
28  projectKey: string
29  title: string
30  text: string
31  /** Where the file was saved; '' when the write failed. */
32  path: string
33  /** A prompt the cold guard held, to append below the handoff after the clear. */
34  heldText?: string
35}
36
37/** The cold-cache confirmation shown on the row before a handoff re-reads a cold cache. */
38export type RelayColdConfirm = {
39  tokens: number
40  /** The estimated cache re-write cost; absent for an unknown model. */
41  usd?: number
42  heldText?: string
43}
44
45/** The unconsumed handoff offered on the band at startup or after /clear. */
46export type RelayOffer = {
47  id: string
48  title: string
49  path: string
50  createdAt: number
51}
52
53/** The store's `relay.fillAfterClear`: what to put in the prompt box once /clear has run. */
54export type RelayFillMarker = {
55  text: string
56  /** The project the Clear & continue was pressed in; a session in another project leaves the marker alone. */
57  projectKey?: string
58  /** The saved file, named in toasts when the box or the clipboard failed. */
59  path?: string
60  /** Whether the text reached the clipboard. */
61  isCopied?: boolean
62  heldText?: string
63  /** When Clear & continue was pressed (`$.clock.now()` ms); ignored after 10 minutes. */
64  at: number
65  /** Set when the prompt was already filled after `$.command.run` resolved, so SessionStart does not fill twice. */
66  filledAt?: number
67}
68
69/** What one warming ping cost: the cache it read, the cache it wrote, and the $ when the model's price is known. */
70export type RelayPingReceipt = {
71  /** When the ping answered (`$.clock.now()` ms). */
72  at: number
73  read: number
74  write: number
75  usd?: number
76}
77
78/**
79 * This session's cache warming, mirrored in the store under `relay.warming.<session id>`
80 * so a hot reload (which drops the module's timer) can pick it up again.
81 */
82export type RelayWarming = {
83  /** When the warming window ends; null when no window is armed. */
84  windowEnd: number | null
85  /** When the next ping is due; absent while waiting for a reply (or once the window has no room for one). */
86  nextPingAt?: number
87  /** `/keepwarm off` for this session: replies no longer arm a window until `/keepwarm <duration>`. */
88  off: boolean
89  /** When `/keepwarm off` was typed; the store keeps an off flag 3 days. */
90  offAt?: number
91  /** Why warming last stopped, shown on the row until the next reply. */
92  stopped?: string
93  /** The last ping's receipt. */
94  last?: RelayPingReceipt
95  /** When the last main-thread reply landed (pings excluded): a handoff saved after it is still current. */
96  lastTurnAt?: number
97}
98
99/** A typed prompt the cold-cache guard held, shown on the row with Handoff & start fresh / Send anyway. */
100export type RelayColdGuard = {
101  heldText: string
102  tokens: number
103  /** The estimated cache re-write cost; absent for an unknown model. */
104  usd?: number
105  /** When the cache went cold (`lastResponseAt` + one hour). */
106  coldSince: number
107}
108
109/** A resumed session's own cost estimate (`estimated_cache_write_usd`), good while the cache clock is the resumed one. */
110export type RelayResumeEstimate = {
111  /** The `lastResponseAt` the resume set. */
112  at: number
113  usd: number
114}
115
116declare module 'claude-code' {
117  interface PluginState {
118    relay: {
119      /** Cache warming for this session; null before anything armed it. */
120      warming: RelayWarming | null
121      /** The prompt the cold guard holds; null when none. */
122      coldGuard: RelayColdGuard | null
123      /** The cold episode (its `lastResponseAt`) the guard already held a prompt in: the next send goes through. */
124      guardEpisode: number | null
125      /** The resume's cache-write estimate; null when not resumed (or the clock has moved on). */
126      resumeEstimate: RelayResumeEstimate | null
127      /** When the running main-thread turn started (`turn.start`); null when none runs. */
128      turnRunning: number | null
129      /** Mirrors the store's `relay.settings.enabled` (default true). */
130      enabled: boolean
131      /** "Not yet" was pressed this session: offer again from 90% / 400k. */
132      handoffSnoozed: boolean
133      /** When the main thread last answered (`$.clock.now()` ms); null = cache state unknown. */
134      lastResponseAt: number | null
135      /** Latest context figures; null before any reading and after /clear or compaction. */
136      context: RelayContext | null
137      /** Bumped every 30s (while on and a reply was seen) so the row's "left" time redraws. */
138      tick: number
139      /** True while a handoff is being written (the fork is running). */
140      handoffBusy: boolean
141      /** The handoff the preview pane shows; null when none is open. */
142      handoffDraft: RelayDraft | null
143      /** The cold-cache confirmation on the row; null when not asked. */
144      coldConfirm: RelayColdConfirm | null
145      /** The waiting handoff offered on the band; null when none. */
146      offer: RelayOffer | null
147    }
148  }
149}
150