Repository navigation
feat(sessions): keep an idle session's prompt cache warm with a guarded keep-alive turn - #666
Conversation
…ed keep-alive turn Claude Code lets a subscription's one-hour prompt cache expire after an hour idle, and the first turn back re-writes the whole conversation at the cache-write rate; nearly every gap over an hour goes cold, while the saving is concentrated in gaps of one to four hours. Codeman now sends one short keep-alive turn five minutes before the expiry Claude Code reports on its statusline, so the next real prompt still reads the conversation from cache, and stops two hours (configurable) after the last real prompt. Codeman owns the idle policy for these sessions, as agreed in Discussion Ark0N#554: a launch with keep-warm on also carries idleCompaction: false in its one --settings JSON, so Claude Code's own idle compaction cannot replace the conversation with a summary a minute before the nudge. It is decided once per launch (keep-warm on, the statusLine exporter injected, Claude Code 2.1.290 or newer, an unknown version fails closed like --name), recorded on the mux record, and picked up by any relaunch; a launch with keep-warm off is byte-identical. A session switched on after launch is kept warm best-effort and the readout says so. Every guard lives in one pure rulebook: the opt-in (an agent-spawned session does not inherit the app default), a launch that reports cache state, a warm 1h cache with enough tokens at stake and no compaction this gap, a live pane, no usage-limit pause, no respawn, Ralph, task or waiting agent, idle, no open dialog, an empty composer and no recent keystroke. The nudge is a submit and moves lastSubmitAt, which the response viewer's conversation correlation needs; the cap counts from its own persisted lastRealPromptAt instead. Read My Mind drops the exact nudge text, and the turn's stop hook and idle prompt open pre-acknowledged so nothing alerts. The last cache report and the clock survive a restart, so the timer re-arms.
|
Thank you for this, @JDProfresh. It is a careful build of step 2 from #554: an opt-in keep-alive turn before an idle Claude session's prompt cache expires, with One thing needs a fix before merge, and three small ones can ride the same push. 1. The keep-alive turn erases an unseen "waiting for input" alert. When a session already has an unacknowledged idle item (yellow tab, user away), the nudge spends it without any human action:
I reproduced it with 2. 3. 4. Smaller things I can take at merge time unless you would rather fold them in:
Once items 1 to 4 are pushed, I will re-review and merge. |
…alive turn Only a human spends an idle alert, but the keep-alive turn resolved an unseen one three ways: its working transition, its stop hook, and its own idle prompt replacing the item with a pre-acknowledged one. While the turn runs, a pending idle item is now left exactly as found, the page's stop handler returns on the keep-alive reason before clearing hooks or raising Response Complete, and session:working/session:idle carry keepAlive so the stuck-detection timer neither restarts nor re-arms. The window that marks the turn closes on its own idle prompt or any other submit instead of a fixed three minutes, which a slow turn's idle prompt (sent a minute after the turn ends) could outlast. The service keeps the default and cap in memory, read through a strict reader so a mid-write read is retried rather than kept, and the cap is clamped before the strict settings save.
|
Thanks for the careful review and the repro. All four are fixed in 5db6c63, and I took the three smaller ones too, so there is nothing left for merge time. 1. The keep-alive turn no longer spends an idle alert.
2. Stuck detection. 3. "Response Complete" is covered by the same early return in 4. The cap is clamped with The smaller ones:
One more from my own pass over the round: the keep-alive window was a fixed three minutes from the nudge, but Claude Code sends Docs follow: the keep-warm entries in CLAUDE.md, Typecheck, lint, format and the full suite are green locally (11,913 tests). |
|
Thank you for the quick and thorough turnaround, @JDProfresh. To recap, this PR sends an opt-in keep-alive turn shortly before an idle Claude session's prompt cache expires, and launches a session with it on with One thing needs a fix before merge, and three small ones can ride the same push. 1. Claude Code's prompt suggestion reads as a draft, so keep-warm never nudges those sessions (
Two ways to fix it, your choice:
Either way, please add a fixture built from a real Small ones for the same push:
Two nits, fine to fold in or leave for me at merge time: Once item 1 and the three small ones are pushed, I will re-review and merge. |
…ggestion is not a draft Claude Code draws its prompt suggestion in an empty composer, the first letter in the reverse-video cursor cell and the rest faint, so a plain capture read it as typed text and keep-warm never nudged those sessions. The keep-warm pane read now uses capture-pane -e and drops the suggestion before checking the composer. Also from review: an off status carries no window, automation and an unreadable composer retry instead of stopping, a failed evaluation re-arms a retry, a trigger during an evaluation queues one follow-up that remove() cancels, and the cap's clock is seeded from lastSubmitAt for pre-upgrade state.
|
Thanks @Ark0N, all of round 2 is in the new commit, including both nits. 1. Prompt suggestion read as a draft. I took the styled read, with one change to the route and one to the rule.
The fixture is a real On the same live pane ( The small ones:
Nits:
Each new guard was mutation-checked: with it removed, its test goes red. Docs follow: the keep-warm entries in CLAUDE.md and Typecheck, lint, format and the full suite are green locally (11,922 tests). |
|
Thank you for round three, @JDProfresh. This PR sends an opt-in keep-alive turn shortly before an idle Claude session's prompt cache expires, and launches a session with it on with Three small things left, which I will apply on top at merge time: 1. A failed keep-alive write never retries ( On 2. An unparsable settings.json loops every session once a minute ( The strict reader plus the retry after a throw is right for a read that lands mid-write. For a settings.json that stays broken (a hand edit with a trailing comma), every session, keep-warm off included, re-reads the file and logs a line every 60 seconds until it is fixed: 11 reads and 11 log lines in ten simulated minutes for one session against the shipped service. I will log a given failure once and arm the retry only for a session that can nudge (choice not 3. The keep-alive's
One nit I will take as well: If you would rather push these yourself, say so and I will wait for it; otherwise I will merge with them on top. |
…close its window Merge-time fix for #666. A nudge only goes out inside the last five minutes before `expiresAt`, so the check armed a whole `KEEP_WARM_LEAD_MS` after a failed write landed at or past the expiry and stopped with "too close to expiry": the retry the status promised never happened. The window `beginKeepAliveTurn()` opened also stayed open for its ten-minute ceiling with no turn running. A failed write now ends the window (`endKeepAliveTurn()` joins `KeepWarmSessionView`; `Session` already has it) and re-arms at `min(now + KEEP_WARM_RETRY_MS, expiresAt - KEEP_WARM_LAST_CHANCE_MS)`. The test advances the clock and sees the second write, and a second one pins that the retry never lands past the last-chance line. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ssions that can nudge Merge-time fix for #666. The strict settings reader plus the retry after a throw is right for a read that lands mid-write, but a settings.json that stays broken (a hand edit with a trailing comma) made every session, keep-warm off included, re-read the file and log a line every 60 seconds until it was fixed (11 reads and 11 log lines in ten simulated minutes for one session). The settings read now fails inside `evaluate()`: a given failure is logged once until a read succeeds, and the retry is armed only for a session that could nudge (its choice not `false`, the exporter `injected`, a warm report). Other throws keep the generic retry in `schedule()`. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Merge-time fix for #666. `recordHookEvent()` ran for every `stop`, the run summary files a `stop` as a warning, and the Away digest lists warnings under Needs Attention, so each keep-alive nudge added a row there and to the session's Summary timeline. The hook route now skips the run summary while `keepAliveReason` is set (the keep-alive turn's `stop` and `idle_prompt`). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… changes Merge-time fix for #666 (the review's nit). The settings PUT called `rescheduleAll()` whenever the body carried either keep-warm key, and the browser sends both on every App Settings save, so every save of anything re-read the settings and re-evaluated every session. It now compares the merged settings with the stored ones, following the rule that a settings action resolves from `merged`, never from the raw body. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…t cache warm with a guarded keep-alive turn Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ota (#662) Squash of PR #662. The header plan-usage chip gains a Copilot row: the calendar-month premium-request quota, with the request counts and the reset in the tooltip, read every 10 minutes from GitHub's copilot_internal/user endpoint with the Copilot CLI's own sign-in token. It rides showPlanUsageLimits and is on by default while the CLI is installed and signed in; any failure leaves the chip without the row. Merge-time fixes by the maintainer: the gates (setting, then token, then the CLI probe) live in readCopilotPlanUsageIfEnabled() so a host with no Copilot sign-in never runs the login-shell probe each tick, with a test that Plan Usage Limits off means no request; the X-GitHub-Api-Version header the Copilot CLI itself does not send is dropped; and CLAUDE.md, architecture-invariants, README and copilot-integration describe the read. The hooks-config import in server.ts was merged with #666's. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Merged and shipped in 1.44.0. Thank you @JDProfresh! This is a careful piece of work, from the version floor you dug out of the published binaries to the styled-capture composer check, and it is exactly the shape we agreed in #554. The three items and the nit, applied at merge as separate commits:
The CLAUDE.md, invariants, wiki and api-reference text say so. The |
This is step 2 of #554, built in the "Codeman owns the idle policy" shape from your answer: an opt-in keep-alive turn five minutes before an idle Claude session's prompt cache would expire, so the next real prompt reads the conversation from cache instead of re-writing it, and a session started with it on also launches with Claude Code's own idle compaction switched off.
/compactat the end of the cap is not in this PR; it will be its own PR.Design, against your answer in #554
idleCompaction: falseonly on keep-warm launches, in the one--settingsJSON. It is folded last into the same object as ultracode, the advisor and the statusLine exporter insession-cli-registry-bridge.ts(the condition gets a keep-warm term; there is never a second--settingsflag), so a launch with keep-warm off is byte-identical to master;test/cli-registry-spawn-golden.test.tspins both the off and the on literal.decideIdleCompactionOptOut()(tmux-manager.ts, pure) decides it at every create and respawn: keep-warm on at launch, the exporterinjected(withoutprompt_cachereports keep-warm cannot run, and the opt-out alone would only take Claude Code's idle compaction away from the user), and a newidleCompactionSettinggate on the claude registry entry, resolved fail-closed through the same machinery as--name: an unknown version means no key, and a log line says why.@anthropic-ai/claude-code-linux-x64binaries for the settings schema entry (idleCompaction:()=>...describe("Set to false to stop Claude Code from compacting a long conversation while the session is idle. Setting it to true does not turn idle compaction on.")): absent in 2.1.280, 2.1.287, 2.1.288 and 2.1.289, present in 2.1.290, 2.1.291, 2.1.294 and 2.1.300. 2.1.289 already carries thetengu_sunny_locketflag, which matches your first idle compactions on 2.1.289, so that one build can idle-compact with no opt-out; the gate leaves the key off there.MuxSession.idleCompactionOff, set on every create and respawn the same waystatusLineis, and the keep-warm status publishes it. A session switched on after launch is kept warm best-effort, and its keep-alive line saysidle compaction on until relaunch. Any relaunch re-decides from the current keep-warm state: on a throwaway instance I switched keep-warm on for a running session, ended its claude, and the dead-pane respawn came back with"idleCompaction":falsein its command.compactedAtis stamped.decideKeepWarm()stops with "the context was compacted; resumes after the next prompt" while the stamp holds.Session.setPromptCache()drops the stamp at the next request, which is what re-opens the next gap.injectedlaunch. Anything else is a hard stop (it also covers every CLI without cache telemetry, so the separate capability guard the first cut had is gone), and there the readout's ownPROMPT_CACHE_NOT_COLLECTEDsentence on the Status row already says why, so keep-warm adds nothing to it. That sentence now also says what to do, since the greyed switch points at it: "Plan Usage was off when this session started. Turn it on in App Settings → Header & Panels, then start a new session; this one cannot pick it up." (it used to say only "Turn it on in App Settings; sessions started after that report it"). On such a launch the switch is greyed out with the sameset-row-disabledpattern as the 1M context switch in_applyModelSelection(), and it cannot be switched on; one that is already on (from the app default) can still be switched off.lastSubmitAtmoves on every nudge. The nudge is a plainwriteViaMux(), so_trackSubmit()stamps it and theprompt_submittedhook stamps it again throughmarkPromptSubmitted(), as you asked. The 2h cap has its own clock,lastRealPromptAt: every submit outside the keep-alive window moves it (a person in the browser, an agent over the API, or someone typing in an attached tmux client, which Codeman only sees through the hook), it is persisted inSessionStateand restored by both rebuild paths, so a restart never reopens the window.beginKeepAliveTurn()is the one marker; thekeepAlivewrite option the first cut had is gone.lastSubmitAtonly to anchor a WORKING row (sessionActivityAnchor()/tileRankStamp(), and the phone overview's "since"), so a nudge shows as the latest prompt only for the few seconds its turn runs, which is true while it runs. Idle rows key onlastActivityAt, which the nudge'sokmoves like any output, so an idle session that was nudged sorts as recently active. I left both alone; say if you would rather the nudge's output not count as activity there.captureIntentPrompt()hands every transcript prompt tointentStore.recordPrompt(), whose first line isisCapturablePrompt(), so the filter sits there, next to the slash-command and system-reminder filters, and matches the exact nudge text (a prompt that merely quotes it is still captured). A test pins the call chain fromcaptureIntentPrompt()down to the filter.parentSessionId, and one that reported done is simply idle, which is the only state keep-warm acts on anyway; a waiter keeps no history once satisfied. So the smallest rule I found is that a session an agent spawned does not inherit the app-wide default: only its own switch keeps it warm (keepWarmOn(), shared by the launch and the service, so the opt-out and the nudges never disagree). That does readparentSessionId, which its doc comment calls decoration only; the agent-created case marker already reads a resolved parent as "an agent asked for this", and here it only picks a default, never ownership or permission, and I updated the comment to say so. If you would rather keep that field purely cosmetic, this rule comes out as one line.❯or starts with───never passes for the composer's own edges (a second review pass caught that one). Aprompt_submittedhook whosepromptis not the exact nudge ends the keep-alive window, so a person typing in an attached tmux client right after a nudge still counts as a real prompt and still gets their alerts. The usage-limit guard also reads a limit message whose reset is still ahead off the same pane capture, because auto-resume tracks pauses only while it is on. A live pane means a pid and no recordedpaneExit(an exited agent keeps its pid underremain-on-exit). Aremove()orstop()that lands during an evaluation's await cancels it there, and timers are clamped to whatsetTimeouthonours, so a far-offexpires_atcannot turn into a 1 ms loop.What it does for the user
Session Options → Respawn gets Keep the cache warm while idle under the Prompt cache readout. Its state shares the readout's Status row as one compact line, hidden while it has nothing to say:
Keep-alive: next 14:55 · stops 16:03,Keep-alive: waiting · the composer holds text · retry 14:56,Keep-alive: sent 14:55 (2×) · stops 16:03, orKeep-alive: stopped · <why>. App Settings → System → Automation gets Keep prompt caches warm (the default for sessions that have not chosen) and Keep warm for at most (2 hours after the last real prompt). The keep-alive isCodeman cache keep-alive: no action needed, reply with just "ok".; its stop hook and idle prompt open pre-acknowledged (acknowledgedReason: "cache keep-alive"), so it raises no tab alert, toast, push or webhook, and a real prompt right after it alerts normally.Tests
test/prompt-cache-keepwarm.test.ts: the rulebook as a table (every stop, wait and the nudge),keepWarmOn(), the composer read (including the leading-newline draft, an indented draft line holding the glyph or a rule, and the missing rule), the exact-text Read My Mind filter, the two submit clocks on a realSession(nudge write and hook movelastSubmitAtonly; a person's prompt ends the window; a tmux typist's hook counts, inside a keep-alive window too), persistence of the clock and the choice, and the service with fake timers and ports (the on-screen limit, an exited agent with a pid, the timer clamp,stop()during the settings read,remove()during the write, and a cancelled evaluation finishing after its replacement).test/keep-warm-launch.test.ts:decideIdleCompactionOptOut()across versions (unknown, 2.1.289, 2.1.290, 2.1.300), exporter states, the worker rule and a CLI without the gate; source slices pinning that bothcreateSessionandrespawnPanedecide, render and record it, that the session hands its choice to both option bags, that both rebuild paths restore the choice and the clock, and the Read My Mind call chain;readKeepWarmSettings()defaults.test/cli-registry-spawn-golden.test.ts: the keep-warm-off literal (byte-identical) and the on literal with"idleCompaction":falselast in the one JSON.test/prompt-cache-status.test.ts: the keep-alive line for every state, including the idle-compaction note and the empty line on a launch without the exporter, andupdateKeepWarmStatus()run as shipped against a minimal DOM (the line is hidden exactly when it is empty, and a launch without cache reports greys the switch's row and refuses to switch on but never to switch off).test/keep-warm-i18n.test.ts: the three rows in zh-CN through the real translator, and every new key in the dictionary once.test/routes/session-routes-keep-warm.test.tsandtest/routes/hook-event-routes.test.ts: the toggle route, the pre-acknowledged hooks and the inbox item a reload seeds alerts from, and the hook's prompt text reaching the session.decideKeepWarm(), each term of the launch decision, the bridge fold and its condition term, the version floor, the submit-clock split, the persistence, the Read My Mind filter and each of the review fixes one at a time (59 mutations) and confirmed the matching test went red before restoring it. Four stayed green at first and got a test: the bridge condition term (every launch that asks for the key also carries the exporter, so I added a literal for the key alone), the in-flight token check (a cancelled evaluation finishing after its replacement), the inbox item's pre-acknowledgement and the nudge countremove()forgets.The full suite is green (593 files passed and 1 skipped, 11894 tests passed and 12 skipped; the browser, mobile and perf suites are the usual environment-gated exclusions), along with typecheck, lint, format, the frontend syntax and browser-exclude checks.
Docs
CLAUDE.md and its architecture-invariants anchor (Prompt-cache keep-warm), api-reference (the toggle route,
lastRealPromptAt,idleCompactionOffin the status, the--settingskey), and the wiki (Keeping Agents Running, Settings Reference). zh-CN strings for the three rows and the toggle toasts.Verified
The first cut of this (keep-warm without the idle-compaction opt-out, a 3 h cap, and a nudge that left
lastSubmitAtalone) has been running on my own install since 2026-10-08. On the reshaped one, on a throwaway instance (loopback, own data dir and tmux socket) with Claude Code 2.1.300:claude --dangerously-skip-permissions --session-id <id> --settings {"statusLine":{"type":"command","command":"<data dir>/statusline-exporter.sh"},"idleCompaction":false} --name kw10-on, one--settings, and Claude Code started with no settings warning.okin about five seconds, and its own report re-armed the timer an hour out.lastSubmitAtmoved to the nudge,lastRealPromptAtdid not, the stop hook went out withacknowledgedReason: "cache keep-alive", and the idle prompt a minute later opened as an already-acknowledged item. Server log:[KeepWarm] e4a5f63b: armed,[KeepWarm] nudged e4a5f63b with 50000 tokens at stake (nudge 1),[KeepWarm] e4a5f63b: nudged,[KeepWarm] e4a5f63b: armed."idleCompaction":falseon its command.\then Enter, thenunfinished draft, typed straight into the pane) held the nudge:[KeepWarm] 5d944b66: waiting (the composer holds text), the draft untouched.Keep-alive: next 03:43 PM · stops 04:48 PM · idle compaction on until relaunch, the same width as the readout).Not measured: a nudge on xhigh with thinking forced on (your ~3k-token worst case); I still have only the 4-token Opus 5.5 replies from my own install.