Refresh Claude Code's prompt cache while the session is idle.

<h1 align="center">Claude Cache Warm</h1>
Keep Claude Code's prompt cache warm between turns.
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="License: MIT"></a> <img src="https://img.shields.io/badge/Claude_Code-Mod-D97757" alt="Claude Code mod"> <a href="https://github.com/ZhenHuangLab/claude-cache-warm/releases"><img src="https://img.shields.io/github/v/release/ZhenHuangLab/claude-cache-warm" alt="Latest release"></a>
Cache Warm refreshes Claude Code's prompt cache while you take a break, so the next turn can reuse the cached conversation. It detects the cache lifetime from your session and sends a short keep-alive request before the cache expires. It uses your existing Claude Code connection and needs no separate API key.
Refreshes use tokens and count toward your account's usage or API bill.
Requires Claude Code 2.1.304 or later with mods enabled, on Linux, macOS, or WSL with the tail command available.
claude plugin marketplace add ZhenHuangLab/claude-cache-warm
claude plugin install zhenhuang@cache-warm
Start Claude Code and send a normal message. For a session that is already open, run /reload-plugins first. Keep the Claude Code process open while you want warming to continue.
To try the latest release for one session:
claude --plugin-url https://github.com/ZhenHuangLab/claude-cache-warm/releases/latest/download/cache-warm.zip
| Detected cache lifetime | Refresh interval |
|---|---|
| 5 minutes | 4 minutes 30 seconds |
| 1 hour | 54 minutes |
Each normal model request starts a new schedule. Successful refreshes extend it until an idle limit or the ping cap is reached. After a refresh, Claude Code shows a status such as cache warm (1h): 1 ping.
Each process warms its own main conversation, including teammates running in separate tmux panes. Embedded subagents and teammates within the same process are outside the scope of Claude Code's fork API.
By default, each idle period allows 30 successful refreshes, with a maximum idle duration of 30 hours. The first limit reached stops warming. Each normal model request resets both limits. With uninterrupted refreshes, the ping cap keeps a 5-minute cache available for about 2 hours 20 minutes, or a 1-hour cache for about 28 hours, from the last normal request.
Warming pauses when the cache lifetime is unknown or the cache has expired, and stops after a cache miss. Send another normal message to start a fresh schedule. Model switches and session changes also reset warming.
A refresh reads the cached prompt and generates a short reply. Its cost depends on the prompt size and model. Review prompt caching pricing and your account's usage before leaving long sessions open.
In Claude Code, run:
/plugin configure zhenhuang@cache-warm
Set maxIdleMinutes to the maximum idle duration in minutes. The default is 1800; 0 disables warming. The 30-refresh cap applies at every setting. For a session loaded with --plugin-url or --plugin-dir, use zhenhuang@inline as the plugin ID instead.
To disable or remove an installed copy:
claude plugin disable zhenhuang@cache-warm
claude plugin uninstall zhenhuang@cache-warm
Claude Code loads the TypeScript source directly. From the repository root:
claude plugin validate --strict .
claude plugin validate --strict .claude-plugin/marketplace.json
claude plugin test .
The tests use mocked model requests and a virtual clock.
MIT. See LICENSE.
Thanks to the LINUX DO community for sharing knowledge, support, and feedback, and to Anthropic for Claude Code and its mods API.
hooks/register.ts 552 lines1import type { EngineInterface, ModelForkResult, ModelUsage, Register, Timer, TurnStepInput } from 'claude-code'
2
3type Ttl = '5m' | '1h'
4type CacheMode = Ttl | 'off' | 'unknown'
5type Evidence = { mode: Ttl | 'off'; model: string; messageId: string; timestamp: number }
6type Detection = { mode: CacheMode; reason: string; source?: Evidence; detail?: string }
7type MainRequest = {
8 generation: number
9 startedAt: number
10 model: string
11 completed: boolean
12}
13type PingOutcome = {
14 sessionId: string
15 model: string
16 mode: Ttl
17 startedAt: number
18 endedAt: number | null
19 usage: ModelUsage | null
20 result: 'pending' | 'hit' | 'miss' | 'failed' | 'stale'
21 response: string | null
22}
23
24const TTL_MS: Record<Ttl, number> = { '5m': 5 * 60_000, '1h': 60 * 60_000 }
25// Each idle period permits at most this many successful refreshes.
26const MAX_PINGS = 30
27const PING_AT = 0.9
28const MIN_HIT_RATIO = 0.9
29const TRANSCRIPT_TAIL_BYTES = 1_000_000
30const TRANSCRIPT_WRITE_WAIT_MS = 5_000
31const TRANSCRIPT_CHECKS = 3
32const RECENT_PINGS = MAX_PINGS
33const PING_PROMPT = 'Prompt cache keep-alive ping. Do not use tools. Reply with exactly: ok'
34
35/** Checks a decoded JSON object before reading its fields. */
36function isRecord(value: unknown): value is Record<string, unknown> {
37 return typeof value === 'object' && value !== null && !Array.isArray(value)
38}
39
40/** Requires explicit, nonnegative token totals; missing counters are unknown. */
41function validUsage(value: unknown): value is ModelUsage {
42 return isRecord(value) && ['input_tokens', 'output_tokens', 'cache_read_input_tokens', 'cache_creation_input_tokens']
43 .every(key => Number.isSafeInteger(value[key]) && (value[key] as number) >= 0)
44}
45
46/** Reads TTL writes, an explicit cache-free response, or a read needing evidence. */
47function usageMode(value: unknown): CacheMode | 'read' {
48 if (!isRecord(value)) return 'unknown'
49 const split = value.cache_creation
50 if (!validUsage(value)) return 'unknown'
51 let write5m = 0
52 let write1h = 0
53 if (split !== undefined && split !== null) {
54 if (!isRecord(split)) return 'unknown'
55 const five = split.ephemeral_5m_input_tokens
56 const hour = split.ephemeral_1h_input_tokens
57 if (!Number.isSafeInteger(five) || (five as number) < 0 ||
58 !Number.isSafeInteger(hour) || (hour as number) < 0) return 'unknown'
59 write5m = five as number
60 write1h = hour as number
61 } else if (value.cache_creation_input_tokens !== 0) {
62 return 'unknown'
63 }
64 if (write5m + write1h !== value.cache_creation_input_tokens) return 'unknown'
65 // With mixed TTLs, the shortest prefix lifetime determines the schedule.
66 if (write5m > 0) return '5m'
67 if (write1h > 0) return '1h'
68 if (value.cache_read_input_tokens > 0) return 'read'
69 return value.input_tokens > 0 ? 'off' : 'unknown'
70}
71
72/** Selects current main-session usage and respects intervening cache/model boundaries. */
73function cacheModeFromTail(
74 tail: string,
75 cut: boolean,
76 sessionId: string,
77 request: MainRequest,
78 epochAt: number,
79 prior: Evidence | undefined,
80 now: number,
81): Detection {
82 const lines = tail.split('\n')
83 if (cut) lines.shift()
84 // A row without its terminating newline may still be in flight.
85 if (!tail.endsWith('\n')) lines.pop()
86 const messages = new Map<string, { model: string; messageId: string; timestamp: number; order: number; usage: unknown }>()
87 for (const [order, line] of lines.entries()) {
88 if (line.trim() === '') continue
89 let row: unknown
90 try {
91 row = JSON.parse(line)
92 } catch (error) {
93 return { mode: 'unknown', reason: 'transcript-parse-error', detail: `row ${order + 1}: ${String(error)}` }
94 }
95 if (!isRecord(row) || row.type !== 'assistant' || row.sessionId !== sessionId ||
96 row.isSidechain === true || row.agentId != null || row.agent_id != null ||
97 row.isApiErrorMessage === true || row.error != null) continue
98 const message = row.message
99 if (!isRecord(message) || message.role !== 'assistant') continue
100 if (typeof message.model === 'string' && message.model.startsWith('<')) continue
101 const timestamp = typeof row.timestamp === 'string' ? Date.parse(row.timestamp) : NaN
102 if (!Number.isFinite(timestamp) || timestamp > now || typeof message.id !== 'string' ||
103 message.id === '' || typeof message.model !== 'string' || message.model === '') {
104 return { mode: 'unknown', reason: 'invalid-assistant-metadata' }
105 }
106 if (timestamp < epochAt) continue
107 // Streaming/content-block rows share a message id: the last row wins.
108 messages.set(message.id, { model: message.model, messageId: message.id, timestamp, order, usage: message.usage })
109 }
110 const rows = [...messages.values()].sort((a, b) => b.timestamp - a.timestamp || b.order - a.order)
111 const latest = rows[0]
112 if (latest === undefined || latest.timestamp < request.startedAt) return { mode: 'unknown', reason: 'no-current-usage' }
113 if (latest.model !== request.model) return { mode: 'unknown', reason: 'usage-model-mismatch' }
114 const mode = usageMode(latest.usage)
115 if (mode === 'unknown') return { mode, reason: 'invalid-usage' }
116 if (mode !== 'read') {
117 return {
118 mode, reason: mode === 'off' ? 'cache-off' : 'ttl-from-write',
119 source: { mode, model: latest.model, messageId: latest.messageId, timestamp: latest.timestamp },
120 }
121 }
122 const inherited = prior !== undefined && prior.mode !== 'off' && prior.model === request.model &&
123 prior.timestamp >= epochAt && prior.timestamp <= latest.timestamp ? prior : undefined
124 for (const row of rows.slice(1)) {
125 if (inherited !== undefined && row.timestamp < inherited.timestamp) break
126 if (row.model !== request.model) return { mode: 'unknown', reason: 'ttl-boundary' }
127 const older = usageMode(row.usage)
128 if (older === 'unknown') return { mode: 'unknown', reason: 'invalid-usage' }
129 if (older === 'off') return { mode: 'unknown', reason: 'ttl-boundary' }
130 if (older !== 'read') {
131 return {
132 mode: older, reason: 'ttl-from-prior-write',
133 source: { mode: older, model: row.model, messageId: row.messageId, timestamp: row.timestamp },
134 }
135 }
136 }
137 return inherited === undefined ? { mode: 'unknown', reason: 'ttl-unavailable' } :
138 { mode: inherited.mode, reason: 'ttl-from-prior-write', source: inherited }
139}
140
141/** Limits state filenames to one safe path component. */
142function safeName(value: string | undefined): value is string {
143 return value !== undefined && value !== '.' && value !== '..' && /^[A-Za-z0-9_.-]{1,128}$/.test(value)
144}
145
146/** Accepts local absolute main-transcript paths passed by the engine. */
147function safeTranscript(path: string): boolean {
148 return path.startsWith('/') && !path.startsWith('//') && path.endsWith('.jsonl') &&
149 !/[\x00-\x1f\x7f]/.test(path) && !path.split('/').includes('..') && !path.includes('/subagents/')
150}
151
152// One module environment belongs to one process; session changes reset this state.
153let maxIdleMs = 0
154const state = {
155 sessionId: null as string | null,
156 transcriptPath: null as string | null,
157 model: null as string | null,
158 effort: null as TurnStepInput['effort'] | null,
159 mode: 'unknown' as CacheMode,
160 status: 'paused' as 'paused' | 'warming' | 'pinging' | 'stopped',
161 reason: 'awaiting-main-request',
162 detail: null as string | null,
163 requestAt: null as number | null,
164 refreshedAt: null as number | null,
165 nextPingAt: null as number | null,
166 ttlSource: null as Evidence | null,
167 detectedAt: null as number | null,
168 pings: 0,
169 recentPings: [] as PingOutcome[],
170}
171let generation = 0
172let readSequence = 0
173let epochAt = 0
174let request: MainRequest | undefined
175let evidence: Evidence | undefined
176let inputModel: string | undefined
177let answeredModel: string | undefined
178let allowDetection = false
179let forkInFlight = false
180let timer: Timer | undefined
181let recheck: Timer | undefined
182let publishes = Promise.resolve()
183
184/** Serializes complete snapshots so an older write cannot finish last. */
185function publish($: EngineInterface): Promise<void> {
186 const sessionId = state.sessionId
187 if (sessionId === null) return publishes
188 const ttl = state.mode === '1h' || state.mode === '5m' ? state.mode : undefined
189 const text = JSON.stringify({
190 ...state, generation,
191 expiresAt: ttl === undefined || state.refreshedAt === null ? null : state.refreshedAt + TTL_MS[ttl],
192 }) + '\n'
193 publishes = publishes.then(async () => {
194 const user = await $.env.get('USER')
195 if (!safeName(user) || !safeName(sessionId)) throw new Error('unsafe USER or session id for state filename')
196 await $.fs.write(`/tmp/claude_cachewarm_${user}_${sessionId}.state`, text)
197 }).catch(error => {
198 $.ui.log(`cache-warm: state file not written: ${String(error)}`, { to: 'debug' })
199 })
200 return publishes
201}
202
203/** Cancels both kinds of timers and invalidates asynchronous work. */
204function invalidate() {
205 generation += 1
206 readSequence += 1
207 timer?.cancel()
208 recheck?.cancel()
209 timer = undefined
210 recheck = undefined
211 state.nextPingAt = null
212}
213
214/** Stops this request until another real main request explicitly starts it. */
215function stop($: EngineInterface, reason: string, clear = false, paused = false, detail: string | null = null): Promise<void> {
216 invalidate()
217 allowDetection = false
218 state.status = paused ? 'paused' : 'stopped'
219 state.reason = reason
220 state.detail = detail
221 if (clear) {
222 request = undefined
223 evidence = undefined
224 inputModel = undefined
225 answeredModel = undefined
226 state.mode = 'unknown'
227 state.refreshedAt = null
228 state.ttlSource = null
229 state.detectedAt = null
230 }
231 $.ui.status(undefined)
232 return publish($)
233}
234
235/** Starts a new session identity without retaining its predecessor's cache. */
236function useSession($: EngineInterface, sessionId: string, now: number) {
237 if (state.sessionId === sessionId) return
238 const first = state.sessionId === null
239 if (!first) void stop($, 'session-changed', true)
240 invalidate()
241 state.sessionId = sessionId
242 state.transcriptPath = null
243 state.model = null
244 state.effort = null
245 state.requestAt = null
246 state.refreshedAt = null
247 state.ttlSource = null
248 state.detectedAt = null
249 state.pings = 0
250 state.mode = 'unknown'
251 state.status = 'paused'
252 state.reason = 'awaiting-main-request'
253 state.detail = null
254 request = undefined
255 evidence = undefined
256 inputModel = undefined
257 answeredModel = undefined
258 allowDetection = false
259 epochAt = first ? 0 : now
260}
261
262/** Makes read failures visible; no configured or guessed TTL is substituted. */
263async function readMode($: EngineInterface, current: MainRequest): Promise<Detection> {
264 const path = state.transcriptPath
265 if (path === null) return { mode: 'unknown', reason: 'no-transcript-path' }
266 let size: number
267 let tail: string
268 try {
269 const stat = await $.fs.stat(path)
270 if (stat.kind !== 'file') return { mode: 'unknown', reason: 'transcript-read-failed', detail: 'not a regular file' }
271 size = stat.size
272 const result = await $.process.run(['tail', '-c', String(TRANSCRIPT_TAIL_BYTES), '--', path], { timeoutMs: 5_000 })
273 if (result.exitCode !== 0 || result.isStdoutTruncated) {
274 return { mode: 'unknown', reason: 'transcript-read-failed', detail: `tail exit ${result.exitCode}: ${result.stderr.slice(0, 500)}` }
275 }
276 tail = result.stdout
277 } catch (error) {
278 return { mode: 'unknown', reason: 'transcript-read-failed', detail: String(error) }
279 }
280 const now = await $.clock.now()
281 // The file may have grown between stat and tail.
282 const cut = size >= TRANSCRIPT_TAIL_BYTES || new TextEncoder().encode(tail).length >= TRANSCRIPT_TAIL_BYTES
283 return cacheModeFromTail(tail, cut, state.sessionId!, current, epochAt, evidence, now)
284}
285
286/** Reports unexpected callback failures without changing a newer request. */
287function runCheck($: EngineInterface, token: number, remaining: number) {
288 void evaluate($, token, remaining).catch(error => {
289 $.ui.log(`cache-warm: check failed: ${String(error)}`, { to: 'debug' })
290 if (token === generation) void stop($, 'check-failed', false, true, String(error))
291 })
292}
293
294/** Bounds transcript-write retries; later relevant events can try again. */
295function checkLater($: EngineInterface, token: number, remaining: number) {
296 recheck?.cancel()
297 recheck = undefined
298 if (remaining === 0) return
299 recheck = $.clock.after(TRANSCRIPT_WRITE_WAIT_MS, () => {
300 if (token !== generation) return
301 recheck = undefined
302 runCheck($, token, remaining - 1)
303 })
304}
305
306/** Plans from the real request start, with expiry, idle, and paid-ping limits. */
307async function plan($: EngineInterface, token: number, now: number): Promise<void> {
308 if (token !== generation || !allowDetection || request === undefined || state.refreshedAt === null ||
309 (state.mode !== '1h' && state.mode !== '5m')) return
310 const ttl = state.mode
311 const deadline = request.startedAt + maxIdleMs
312 if (now >= deadline) return stop($, 'idle-limit')
313 if (state.pings >= MAX_PINGS) return stop($, 'ping-cap')
314 if (now >= state.refreshedAt + TTL_MS[ttl]) return stop($, 'cache-expired', false, true)
315 const due = state.refreshedAt + TTL_MS[ttl] * PING_AT
316 if (now >= due) return ping($, token, ttl, now)
317 timer?.cancel()
318 state.nextPingAt = due < deadline ? due : null
319 state.status = 'warming'
320 state.reason = due < deadline ? 'scheduled' : 'idle-limit-before-next-ping'
321 timer = $.clock.after(Math.min(due, deadline) - now, () => {
322 if (token !== generation) return
323 timer = undefined
324 runCheck($, token, TRANSCRIPT_CHECKS)
325 })
326 await publish($)
327}
328
329/** Adopts only current usage and guards both generations and concurrent reads. */
330async function evaluate($: EngineInterface, token: number, remaining: number): Promise<void> {
331 const current = request
332 if (token !== generation || !allowDetection || current === undefined || !current.completed) return
333 const sequence = ++readSequence
334 recheck?.cancel()
335 recheck = undefined
336 const checkedAt = await $.clock.now()
337 if (token !== generation || sequence !== readSequence) return
338 if (checkedAt >= current.startedAt + maxIdleMs) return stop($, 'idle-limit')
339 const detection = await readMode($, current)
340 const now = await $.clock.now()
341 if (token !== generation || sequence !== readSequence) return
342 state.mode = detection.mode
343 state.reason = detection.reason
344 state.detail = detection.detail ?? null
345 state.ttlSource = detection.source ?? null
346 state.detectedAt = now
347 if (detection.mode === 'unknown') {
348 timer?.cancel()
349 timer = undefined
350 state.nextPingAt = null
351 state.status = 'paused'
352 if (['ttl-boundary', 'usage-model-mismatch', 'invalid-usage', 'transcript-parse-error', 'invalid-assistant-metadata'].includes(detection.reason)) {
353 evidence = undefined
354 }
355 checkLater($, token, remaining)
356 await publish($)
357 return
358 }
359 if (detection.mode === 'off') {
360 evidence = undefined
361 state.refreshedAt = null
362 return stop($, 'cache-off')
363 }
364 evidence = detection.source
365 state.refreshedAt ??= current.startedAt
366 await plan($, token, now)
367}
368
369/** Records each fork, including failures and completions superseded by real work. */
370async function ping($: EngineInterface, token: number, ttl: Ttl, startedAt: number): Promise<void> {
371 if (forkInFlight) return stop($, 'ping-in-flight', false, true)
372 forkInFlight = true
373 const outcome: PingOutcome = {
374 sessionId: state.sessionId!, model: state.model!, mode: ttl, startedAt, endedAt: null,
375 usage: null, result: 'pending', response: null,
376 }
377 state.recentPings.push(outcome)
378 state.recentPings = state.recentPings.slice(-RECENT_PINGS)
379 state.status = 'pinging'
380 state.reason = 'refreshing'
381 state.nextPingAt = null
382 void publish($)
383 let result: ModelForkResult | undefined
384 let failure: string | undefined
385 try {
386 result = await $.model.fork({ prompt: PING_PROMPT })
387 } catch (error) {
388 failure = String(error)
389 } finally {
390 forkInFlight = false
391 }
392 const endedAt = await $.clock.now()
393 outcome.endedAt = endedAt
394 if (result !== undefined) {
395 outcome.response = result.isAnswered ? 'answered' : result.reason
396 if ('usage' in result) outcome.usage = result.usage
397 if (!result.isAnswered && result.reason === 'api-error') failure = `api-error: ${result.error} (${result.status})`
398 } else {
399 outcome.response = failure ?? 'fork failed'
400 }
401 if (token !== generation) {
402 outcome.result = 'stale'
403 await publish($)
404 return
405 }
406 if (result === undefined || (!result.isAnswered && result.reason !== 'empty-reply')) {
407 outcome.result = 'failed'
408 return stop($, outcome.response === 'nothing-to-fork' ? 'fork-unavailable' : 'ping-failed', false, true, failure ?? outcome.response)
409 }
410 if (!validUsage(outcome.usage)) {
411 outcome.result = 'failed'
412 return stop($, 'ping-usage-invalid', false, true)
413 }
414 const usage = outcome.usage
415 const total = usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens
416 if (usage.cache_read_input_tokens + usage.cache_creation_input_tokens > 0) state.refreshedAt = startedAt
417 if (total === 0 || usage.cache_read_input_tokens / total < MIN_HIT_RATIO) {
418 outcome.result = 'miss'
419 return stop($, 'cache-miss')
420 }
421 outcome.result = 'hit'
422 state.pings += 1
423 $.ui.status(`cache warm (${ttl}): ${state.pings} ping${state.pings === 1 ? '' : 's'}`)
424 // The response may itself have consumed the TTL. Schedule using its end time.
425 await plan($, token, endedAt)
426}
427
428export const register: Register = (on, options) => {
429 const minutes = options.maxIdleMinutes
430 if (typeof minutes !== 'number' || !Number.isFinite(minutes) || minutes < 0 || !Number.isFinite(minutes * 60_000)) {
431 throw new RangeError('cache-warm: maxIdleMinutes must be a finite nonnegative number')
432 }
433 maxIdleMs = minutes * 60_000
434
435 // MessageDisplay fires for each streaming batch; it needs no path RPC or write.
436 const classicEvents = new Set([
437 'classic.SessionStart', 'classic.Stop', 'classic.StopFailure', 'classic.UserPromptSubmit',
438 'classic.PostToolUse', 'classic.PostToolUseFailure', 'classic.PostToolBatch', 'classic.PostCompact',
439 'classic.PostModelSwitch', 'classic.SessionEnd',
440 ])
441 on('classic.*', async ($, e, next) => {
442 if (!classicEvents.has(next.event) || next.is('classic.PreToolUse', e) || e.agent_id !== undefined) return next(e)
443 const sessionId = await $.session.id()
444 if (e.session_id !== sessionId) return next(e)
445 const now = await $.clock.now()
446 let changed = state.sessionId !== sessionId
447 useSession($, sessionId, now)
448 if (next.is('classic.SessionStart', e) && e.source !== 'compact') {
449 // A first, late startup path can complete an already observed request.
450 const lateStartup = e.source === 'startup' && state.transcriptPath === null && request !== undefined
451 if (!lateStartup) {
452 changed = true
453 void stop($, 'awaiting-main-request', true)
454 state.requestAt = null
455 state.pings = 0
456 epochAt = now
457 }
458 }
459 const path = safeTranscript(e.transcript_path) ? e.transcript_path : null
460 if (path !== state.transcriptPath) {
461 changed = true
462 if (state.transcriptPath === null && path !== null) {
463 invalidate()
464 if (request !== undefined) request.generation = generation
465 } else {
466 void stop($, 'transcript-changed', true)
467 epochAt = now
468 }
469 state.transcriptPath = path
470 }
471 if (next.is('classic.PostModelSwitch', e)) {
472 state.model = e.to_model
473 state.effort = null
474 epochAt = now
475 await stop($, 'model-switched', true)
476 } else if (next.is('classic.SessionEnd', e)) {
477 await stop($, 'session-ended', true)
478 } else if (allowDetection && request?.completed && state.mode === 'unknown') {
479 await evaluate($, generation, TRANSCRIPT_CHECKS)
480 } else if (changed) {
481 await publish($)
482 }
483 return next(e)
484 })
485
486 on('turn.step', async function* ($, e, next) {
487 // Forks and embedded/in-process agents have an agentId. A tmux teammate's
488 // own main loop does not. No global ping flag may suppress real main work.
489 if (e.agentId !== undefined) return yield* next(e)
490 const sessionId = await $.session.id()
491 const now = await $.clock.now()
492 useSession($, sessionId, now)
493 if (inputModel !== undefined && (inputModel !== e.model || state.effort !== (e.effort ?? null))) {
494 epochAt = now
495 evidence = undefined
496 answeredModel = undefined
497 }
498 inputModel = e.model
499 invalidate()
500 const current: MainRequest = { generation, startedAt: now, model: answeredModel ?? e.model, completed: false }
501 request = current
502 allowDetection = maxIdleMs > 0
503 state.model = current.model
504 state.effort = e.effort ?? null
505 state.requestAt = now
506 state.refreshedAt = null
507 state.pings = 0
508 state.mode = 'unknown'
509 state.ttlSource = null
510 state.detectedAt = null
511 state.detail = null
512 state.status = allowDetection ? 'paused' : 'stopped'
513 state.reason = !allowDetection ? 'disabled' : state.transcriptPath === null ? 'no-transcript-path' : 'main-request-active'
514 $.ui.status(undefined)
515 await publish($)
516 let responseModel: string | undefined
517 try {
518 const result = yield* next(e)
519 responseModel = result.usage?.model
520 return result
521 } finally {
522 if (request === current && current.generation === generation) {
523 if (responseModel !== undefined && responseModel !== current.model) {
524 invalidate()
525 current.generation = generation
526 current.model = responseModel
527 state.model = responseModel
528 // Aliases and context suffixes may differ from the answered model.
529 // Only an actual response-model change adds another epoch boundary.
530 if (answeredModel !== undefined && answeredModel !== responseModel) {
531 evidence = undefined
532 epochAt = current.startedAt
533 }
534 }
535 if (responseModel !== undefined) answeredModel = responseModel
536 current.completed = true
537 if (allowDetection) {
538 state.reason = state.transcriptPath === null ? 'no-transcript-path' : 'awaiting-transcript'
539 checkLater($, generation, TRANSCRIPT_CHECKS)
540 }
541 await publish($)
542 }
543 }
544 })
545
546 on('session.end', async ($, e, next) => {
547 if (state.sessionId === null) state.sessionId = e.sessionId
548 if (state.sessionId === e.sessionId) await stop($, 'session-ended', true)
549 return next(e)
550 })
551}
552