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

A Claude Code mod with three jobs:
/clear, and puts the brief in the prompt box. A handoff you never used is offered in later sessions in the same project.◆ Relay ctx ▰▰▰▰▰▱▱▱ 58% · 231k cache ● warm · 41m left · warming · next ping 12m [ Handoff ]
warm: more than 10 minutes leftcooling: 10 minutes or less leftcold: past the hourunknown: no reply yet, or just after /clear or compaction· warming · next ping 12m while a window is armed. Once warming stops it shows · warming stopped: <reason> until the next reply.The row changes for these situations:
| Situation | Row |
|---|---|
| Context at 80% or 300k tokens | Context 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 cold | Cache is cold — writing the handoff re-writes ~240k tokens (≈ $2.40). [ Write it anyway ] [ Cancel ] |
| A typed prompt held by the cold guard | Cache 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… |
Start one with the Handoff button, "Handoff now", or /handoff.
What Relay does:
/clear, and fills the prompt box. Nothing is sent until you press Enter.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:
/keepwarm offThe 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.
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:
! bash input@ mentions, or context added by a hookYour choices once a prompt is held:
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.
| Command | Does |
|---|---|
/relay | Status card: context, cache, handoff threshold, warming (window, next ping, last ping cost, overnight setting), and waiting handoffs. |
/relay on / /relay off | Global 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 / off | The overnight handoff (default on). |
/handoff | Write a handoff now. |
/handoff list | The last 20 handoffs, this project's first, with path and whether each was used. |
/keepwarm 90m / 2h / 2h30m | Warm for that window, from now (max 12h). |
/keepwarm off | Stop warming for this session. Replies no longer arm a window until /keepwarm <duration>. |
/keepwarm or /keepwarm status | The warming line. |
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):
| Key | Holds |
|---|---|
relay.settings.enabled | The global on/off switch. |
relay.settings.overnightHandoff | The overnight handoff setting. |
relay.handoffs | The handoff index, newest first, capped at 50. |
relay.fillAfterClear | What 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.
claude plugin uninstall relay@roberts-mods
Handoff files in ~/.claude/handoffs stay until you delete them.
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).
hooks/register.tsx 1428 lines1import { 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 })
1200hooks/core.ts 764 lines1import 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.'
764hooks/warming.ts 195 lines1import 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}
195types/index.d.ts 150 lines1/** 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