Skip to content

feat(sessions): keep an idle session's prompt cache warm with a guarded keep-alive turn - #666

Merged
Ark0N merged 3 commits into
Ark0N:masterfrom
JDProfresh:feat/prompt-cache-keepwarm-v2
Oct 22, 2026
Merged

Ark0N merged 3 commits into
Ark0N:masterfrom
JDProfresh:feat/prompt-cache-keepwarm-v2

Conversation

@JDProfresh

Copy link
Copy Markdown
Contributor

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. /compact at the end of the cap is not in this PR; it will be its own PR.

Design, against your answer in #554

  • idleCompaction: false only on keep-warm launches, in the one --settings JSON. It is folded last into the same object as ultracode, the advisor and the statusLine exporter in session-cli-registry-bridge.ts (the condition gets a keep-warm term; there is never a second --settings flag), so a launch with keep-warm off is byte-identical to master; test/cli-registry-spawn-golden.test.ts pins 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 exporter injected (without prompt_cache reports keep-warm cannot run, and the opt-out alone would only take Claude Code's idle compaction away from the user), and a new idleCompactionSetting gate 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.
  • The version floor is 2.1.290. I checked the published @anthropic-ai/claude-code-linux-x64 binaries 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 the tengu_sunny_locket flag, 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.
  • Recorded per launch. The decision lands on the mux record as MuxSession.idleCompactionOff, set on every create and respawn the same way statusLine is, and the keep-warm status publishes it. A session switched on after launch is kept warm best-effort, and its keep-alive line says idle 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":false in its command.
  • Stands down once compactedAt is 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.
  • Arms only on an injected launch. 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 own PROMPT_CACHE_NOT_COLLECTED sentence 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 same set-row-disabled pattern 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.
  • lastSubmitAt moves on every nudge. The nudge is a plain writeViaMux(), so _trackSubmit() stamps it and the prompt_submitted hook stamps it again through markPromptSubmitted(), 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 in SessionState and restored by both rebuild paths, so a restart never reopens the window. beginKeepAliveTurn() is the one marker; the keepAlive write option the first cut had is gone.
  • Home screens and tile grid. Both read lastSubmitAt only 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 on lastActivityAt, which the nudge's ok moves 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.
  • Read My Mind. captureIntentPrompt() hands every transcript prompt to intentStore.recordPrompt(), whose first line is isCapturablePrompt(), 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 from captureIntentPrompt() down to the filter.
  • Workers that already reported done. In this codebase an agent-spawned worker is a session with a resolved 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 read parentSessionId, 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.
  • Beyond the thread, from an adversarial review of my own cut. The empty-composer check reads the continuation lines down to the composer's bottom rule, so a draft that opens with a newline (blank glyph line, text on the line below) is never submitted with the nudge appended; with no rule to bound the read it stops rather than guesses. It finds the glyph line and the rule at column 0 before trimming anything, since Claude Code indents every continuation line, so a draft line that holds ❯ or starts with ─── never passes for the composer's own edges (a second review pass caught that one). A prompt_submitted hook whose prompt is 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 recorded paneExit (an exited agent keeps its pid under remain-on-exit). A remove() or stop() that lands during an evaluation's await cancels it there, and timers are clamped to what setTimeout honours, so a far-off expires_at cannot turn into a 1 ms loop.
  • Picks. The app-wide cap defaults to 2 hours per your simulation and stays configurable (0.5 to 24). The cost wording assumes nothing about output tokens; the only fixed number is the 20k-token floor at stake.

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, or Keep-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 is Codeman 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 real Session (nudge write and hook move lastSubmitAt only; 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 both createSession and respawnPane decide, 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":false last 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, and updateKeepWarmStatus() 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.ts and test/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.
  • I removed each guard in 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 count remove() 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, idleCompactionOff in the status, the --settings key), 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 lastSubmitAt alone) 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:

  • A session switched on before launch ran 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.
  • A session with keep-warm off ran a claude command byte-identical to the same launch on an unmodified master instance (compared after normalizing the session id and the data dir).
  • With a cache report expiring five minutes and 20 seconds out, the keep-warm armed, nudged at the due second, Claude answered ok in about five seconds, and its own report re-armed the timer an hour out. lastSubmitAt moved to the nudge, lastRealPromptAt did not, the stop hook went out with acknowledgedReason: "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.
  • After a Codeman restart the session re-armed from the restored report with its window end unchanged, and the session switched on after launch came back through dead-pane recovery with "idleCompaction":false on its command.
  • A draft that opens with a newline (\ then Enter, then unfinished draft, typed straight into the pane) held the nudge: [KeepWarm] 5d944b66: waiting (the composer holds text), the draft untouched.
  • Session Options showed the armed line for the first and the idle-compaction note for the second (re-checked in the browser after moving the line onto the Status row: 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.

…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.
@Ark0N

Ark0N commented Oct 18, 2026

Copy link
Copy Markdown
Owner

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 idleCompaction: false riding the one --settings JSON on launches that have it on. The rulebook in decideKeepWarm() and its test table, the fail-closed version gate and the byte-identical golden are all in good shape, and typecheck, lint, format and the full suite are green.

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:

  • the nudge's working transition resolves every idle item (src/web/session-listener-wiring.ts:243), and the approval:resolved broadcast clears the tab alert on every page;
  • its stop hook resolves what is left (src/web/routes/hook-event-routes.ts:229), and _onHookStop (src/web/public/settings-ui.js:74-77) calls clearPendingHooks() without checking acknowledgedReason;
  • its own idle_prompt then opens pre-acknowledged unconditionally (hook-event-routes.ts:190), so nothing re-arms, and a reload seeds no alert.

I reproduced it with app.inject against the real hook route and inbox: a real idle_prompt gives an unacknowledged item, then with keepAliveTurnActive set the stop leaves no pending item and the idle_prompt opens a new one with acknowledgedReason: 'cache keep-alive'. The ask: while keepAliveTurnActive, skip the idle resolve in the working listener and in the stop branch; make the keep-alive's idle_prompt inherit the acknowledgement state of the item it replaces, without a second push (sendPushNotifications fires whenever acknowledgedReason is unset, so this needs an "unacknowledged, already notified" state or a separate no-push flag); have _onHookStop skip its clear when data.acknowledgedReason is set; check _onSessionWorking in app.js, which deletes the tab alert once pendingHooks is empty; and add a test that starts from an unacknowledged idle item. The rule this protects is in CLAUDE.md under Approvals Inbox: an idle alert is spent only by a human.

2. src/web/public/app.js:3478: _onSessionIdle re-arms the stuck-detection timer on every idle, so about 10 minutes after each nudge the page raises another "Session Idle 10+ minutes" notification (on by default on desktop). Please skip re-arming it for the idle that ends a keep-alive turn.

3. src/web/public/settings-ui.js:79: the same _onHookStop raises "Response Complete" for every nudge for users who turned that notification on. Returning early on data.acknowledgedReason (as _onHookIdlePrompt does) covers it along with item 1.

4. src/web/public/settings-ui.js:2767: promptCacheKeepWarmMaxHours is sent unclamped, and the strict settings schema rejects the whole body for 0.25 or 30, which drops every other change in that save. Please clamp it like the git-status fields just above: Math.min(24, Math.max(0.5, parseFloat(...) || 2)).

Smaller things I can take at merge time unless you would rather fold them in:

  • src/web/routes/status-telemetry-routes.ts:84: every statusline report reads settings.json from disk before it can conclude "off", which every install pays while the feature is off. A short-circuit on keepWarmEnabled === false, or keeping the two keys in memory and refreshing them from rescheduleAll(), avoids it.
  • src/web/public/constants.js:786: after switching keep-warm off on a session launched with the opt-out, the readout could say idle compaction off until relaunch, the mirror of the note you already show.
  • src/web/schemas.ts:1873: KeepWarmSchema landed between the auto-resume docblock and AutoResumeSchema.

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.
@JDProfresh

Copy link
Copy Markdown
Contributor Author

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.

  • working listener (src/web/session-listener-wiring.ts): the idle resolve is skipped while keepAliveTurnActive.
  • stop branch (src/web/routes/hook-event-routes.ts): while keepAliveTurnActive, a stop leaves a pending idle item alone. A pending permission or question item is still resolved as before.
  • The keep-alive's idle_prompt: I went one step simpler than a new state. While keepAliveTurnActive, an idle item that is already pending is left in place rather than replaced, so it keeps its acknowledgement state, its id and its createdAt. An unseen alert stays unseen, one a person looked at still says so, and since no item opens and the broadcast carries acknowledgedReason, sendPushNotifications is not called a second time. With no idle item pending, the keep-alive's idle_prompt opens pre-acknowledged as before. If you would rather have the replacement inherit the state, I can do it that way.
  • _onHookStop (settings-ui.js) returns early on data.acknowledgedReason, before clearPendingHooks().
  • _onSessionWorking: I checked it and left the alert logic as is. tabAlerts is only ever written by updateTabAlertFromHooks(), so with pendingHooks intact after the _onHookStop fix, the delete there cannot fire for a pending idle alert. A test pins that.
  • Tests: test/routes/hook-event-routes.test.ts starts from a real idle_prompt, once unseen and once acknowledged, runs a keep-alive stop and idle_prompt through the real route and inbox, and checks the same item is still pending with the same acknowledgement and no push. A second case checks that a keep-alive stop still resolves a pending permission dialog. test/session-listener-wiring.test.ts covers the working resolve with and without the window. test/keep-alive-page-alerts.test.ts runs the shipped _onHookStop and _onSessionWorking against a pending idle alert. Each test goes red when its guard is removed.

2. Stuck detection. session:working and session:idle now carry keepAlive: true during the keep-alive turn. _onSessionIdle does not re-arm on it, and _onSessionWorking does not clear the timer either, so the real turn's timer keeps its original schedule (a stuck threshold longer than the gap to the nudge still fires) and nothing follows the nudge. Tested with fake timers against the shipped handlers.

3. "Response Complete" is covered by the same early return in _onHookStop.

4. The cap is clamped with Math.min(24, Math.max(0.5, parseFloat(...) || 2)); a test evaluates the shipped expression for 0.25, 30, empty and non-numeric input.

The smaller ones:

  • Telemetry route: the service now keeps the default and cap in memory and reads settings.json once. rescheduleAll() drops the copy, and the settings PUT already calls it whenever either key changes, so a change still applies at once. Since a kept fallback would stick, the service reads through a strict variant (readKeepWarmSettingsOrThrow()): a missing file is the defaults, but a read or parse failure, such as a read landing while another route rewrites settings.json, rejects and is retried at the next report instead of holding keep-warm off. Launches keep the lenient reader. Tested, including the read count and a truncated file.
  • Readout: switching keep-warm off on a launch that carries the opt-out now shows Keep-alive: off · idle compaction off until relaunch.
  • KeepWarmSchema moved below AutoResumeSchema, so the auto-resume docblock sits on its schema again.

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 idle_prompt about a minute after a turn ends, so a nudge turn slower than about two minutes would have alerted and pushed like a real one. The window now closes on the keep-alive's own idle_prompt (the route calls endKeepAliveTurn()), and on any other submit as before, now including a programmatic write of anything but the nudge (an agent, respawn, auto-resume). KEEP_WARM_TURN_WINDOW_MS is now only a ten-minute ceiling for a workspace whose hooks never report. Tested on the session and the route.

Docs follow: the keep-warm entries in CLAUDE.md, docs/architecture-invariants.md, docs/api-reference.md (the keepAlive flag and the pending idle item) and the wiki page.

Typecheck, lint, format and the full suite are green locally (11,913 tests).

@Ark0N

Ark0N commented Oct 19, 2026

Copy link
Copy Markdown
Owner

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 idleCompaction: false in the one --settings JSON. All four fixes and the three smaller items from the last round are in 5db6c633. I checked each against the code: the pending idle item now survives a keep-alive turn, the stuck timer is not re-armed, and Response Complete is not raised for it. The cap value is clamped before the save, the settings are kept in memory, and the off readout and the schema order are fixed. CI is green.

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 (src/web/server.ts:348-358, src/session-submit-verifier.ts:76-87)

readPane hands composerIsEmpty() the output of capturePaneText(), which is capture-pane -p without -e. Claude Code draws its prompt suggestion (on by default, promptSuggestionEnabled) on the composer line as faint text: the raw line is ❯ followed by ESC[2m<suggestion>ESC[0m. The plain capture keeps the words, so composerIsEmpty() returns false. decideKeepWarm() then waits with "the composer holds text" every minute until the last-chance line and stops, and a stop arms no timer, so the cache expires. On my machine (Claude Code 2.1.303), 11 of 21 idle Claude panes show a suggestion right now. composerIsEmpty() as shipped returns false on those and true on an empty composer. When I dropped the faint (SGR 2) runs before the same check, all four -e captures I tried came out right, so the styling is enough to tell a suggestion from a typed draft.

Two ways to fix it, your choice:

  • Read the styled visible frame (capturePaneBuffer(muxName) is already capture-pane -p -e without history) and drop faint text inside composerIsEmpty() before stripAnsi. detectUsageLimitPause then takes the stripped text.
  • Use tmux's cursor column: the composer is empty when the cursor sits right after the glyph.

Either way, please add a fixture built from a real -e capture with a suggestion on screen. Also confirm on a live pane that the nudge's paste plus Enter, with a suggestion visible, submits the nudge and not the suggestion.

Small ones for the same push:

  • src/prompt-cache-keepwarm.ts:321: the off status carries budgetEndsAt, which moves with every real prompt, so with keep-warm off each turn of each session broadcasts a session:keepWarm event (5 prompts gave 5 broadcasts against the shipped service). Please leave budgetEndsAt and nextCheckAt off the status when it is not enabled.
  • src/prompt-cache-keepwarm.ts:146, :137, :228: three stops are transient but arm nothing, and an idle session gets no further trigger until its next turn. They are an unreadable composer after one failed capture, automationActive caused only by an agent's long-poll waiter, and a rejected settings read, which schedule() logs and drops. Please make the first two wait(retryAt, ...) and arm a KEEP_WARM_RETRY_MS timer when an evaluation throws.
  • src/session.ts:1005: no pre-upgrade state.json has lastRealPromptAt, so an existing session older than the cap stops at once even after the user switches it on. Seeding with config.lastRealPromptAt ?? config.lastSubmitAt ?? 0 fixes it, since every pre-upgrade submit was a real one.

Two nits, fine to fold in or leave for me at merge time: src/prompt-cache-keepwarm.ts:275 drops a schedule() that lands during an in-flight evaluation (a toggle during a nudge's write returns the old status), and src/web/public/constants.js:776 names a local window, which shadows the global.

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.
@JDProfresh

Copy link
Copy Markdown
Contributor Author

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 route: capturePaneBuffer() without full history returns the repaint snapshot from formatPaneSnapshot(), where each row is placed by an absolute cursor move rather than a \n, plus a second tmux call for the cursor. composerIsEmpty() splits on newlines, so with that input it would never find the composer line and the session would still never be nudged. capturePaneText() takes an optional { styles: true } instead, which adds -e to the same single capture-pane -p and keeps one line per row. readPane uses it, and detectUsageLimitPause gets the text through a small plainFrame() that removes every SGR first, because tmux can write colon sub-parameters (ESC[4:3m, a curly underline) and the shared stripAnsi pattern leaves those behind. I kept that fix local; happy to send a separate PR adding : to ANSI_ESCAPE_PATTERN_FULL if you want it everywhere.
  • The rule: on my capture the suggestion is not all faint. Its first letter sits in Claude's reverse-video cursor cell, and only the rest is faint: ❯ ESC[7mw ESC[0;2m … rite tests for add ESC[0m. Dropping SGR 2 alone leaves the w, so the composer still read as holding text. I suspect your four captures had no cursor cell drawn. composerIsEmpty() now drops faint runs, plus a reverse-video cell when faint text follows it. A typed draft is never faint, so a cursor cell over the first letter of a real draft is kept (❯ ESC[7mh ESC[0m alf a draft still reads as text). The attribute walk skips an extended colour's arguments, so the 2 in 38;2;r;g;b is not taken for faint.

The fixture is a real capture-pane -p -e from Claude Code 2.1.300 with prompt suggestions on, byte for byte apart from the statusline row. It has five composer states: with the suggestion, empty, holding a draft, holding the same draft with the cursor moved home, and holding a 40-line paste collapsed to [Pasted text #1 +40 lines]. That chip is drawn plain, like typed text, so it still reads as a draft. A second test covers the shape you described (faint with no cursor cell), one that is faint and then typed, and a plain capture of a suggestion. Removing the faint drop or the cursor-cell rule turns the first test red.

On the same live pane (--model haiku, suggestion write tests for add visible) I sent the nudge the way writeViaMux() does: set-buffer, paste-buffer -p -d, then a separate send-keys Enter. The paste replaced the suggestion, and the transcript's user message is exactly Codeman cache keep-alive: no action needed, reply with just "ok".. Claude answered ok.

The small ones:

  • off status: budgetEndsAt is only set while keep-warm is enabled, and an off status never had nextCheckAt. A test schedules an off session, moves lastRealPromptAt, schedules again and checks that both statuses serialize the same, so the server's change-only dedupe broadcasts nothing.
  • Transient stops: an unreadable composer and automationActive are now wait(retryAt, ...). Automation moved below the schedule with the other transient guards, so respawn, Ralph and an assigned task retry inside the window too, rather than only the long-poll waiter. The readout shows them as waiting with the reason. An evaluation that throws is logged as before and arms a KEEP_WARM_RETRY_MS timer, unless remove() dropped that run meanwhile; a test fails the settings read once and sees the nudge go out a minute later.
  • lastRealPromptAt is seeded with config.lastRealPromptAt ?? config.lastSubmitAt ?? 0, tested for a pre-upgrade state and for one that has both.

Nits:

  • A schedule() that lands during an evaluation now queues one follow-up after it instead of returning, and every trigger that arrives meanwhile shares it. The returned promise resolves after the follow-up publishes, so the toggle route returns the new status. A test switches keep-warm off while the nudge is being written and sees nudged then off. remove() drops a queued follow-up and the retry, since the session can still be found while it is being deleted; another test holds the settings read, queues a follow-up, removes the session and sees no nudge, status or timer.
  • The local in formatKeepWarmStatus is now keepWarmWindow.

Each new guard was mutation-checked: with it removed, its test goes red.

Docs follow: the keep-warm entries in CLAUDE.md and docs/architecture-invariants.md (the styled read, why not capturePaneBuffer(), and the retry and follow-up rules), budgetEndsAt only while enabled in docs/api-reference.md, and a clause in the wiki that the greyed-out suggestion counts as an empty prompt.

Typecheck, lint, format and the full suite are green locally (11,922 tests).

@Ark0N

Ark0N commented Oct 20, 2026

Copy link
Copy Markdown
Owner

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 idleCompaction: false in the one --settings JSON. Everything from round 2 is in 83d80578, and I checked each item against the code: the composer is read from a styled capture and drops the faint suggestion and its cursor cell (a run of composerIsEmpty() over tmux -e captures of a suggestion, an empty composer and several drafts came out right on every one), the off status no longer carries the window, the transient stops wait and retry, a thrown evaluation retries, a trigger during an evaluation queues one follow-up, and old state seeds lastRealPromptAt. Typecheck, lint, format, the frontend checks and the full suite are green, here and in CI.

Three small things left, which I will apply on top at merge time:

1. A failed keep-alive write never retries (src/prompt-cache-keepwarm.ts:372, :360)

On ok === false the next check is armed at now + KEEP_WARM_LEAD_MS. A nudge only goes out inside the last five minutes before expiresAt, so that check lands at or past the expiry and decideKeepWarm() stops with "too close to expiry": the retry HH:MM in the status never happens. The window beginKeepAliveTurn() opened also stays open for the full ten-minute ceiling with no turn running. The fix I have in mind: end the window on !ok (add endKeepAliveTurn() to KeepWarmSessionView) and arm Math.min(now + KEEP_WARM_RETRY_MS, expiresAt - KEEP_WARM_LAST_CHANCE_MS), with the test at test/prompt-cache-keepwarm.test.ts:477 advancing the clock to see the second write.

2. An unparsable settings.json loops every session once a minute (src/prompt-cache-keepwarm.ts:256)

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 false, exporter injected, a warm report).

3. The keep-alive's stop shows up in the Away digest under Needs Attention (src/web/routes/hook-event-routes.ts:284)

recordHookEvent() runs for every stop; run-summary.ts:279 makes it a warning and away-digest.ts:271 files warnings under Needs Attention, so each nudge adds a row there and in the session's Summary timeline. Skipping recordHookEvent() when keepAliveReason is set covers it.

One nit I will take as well: src/web/routes/system-routes.ts:1116 reschedules on every App Settings save, because the browser always sends both keys; comparing merged with existing makes it fire only on a change.

If you would rather push these yourself, say so and I will wait for it; otherwise I will merge with them on top.

@Ark0N
Ark0N merged commit 83d8057 into Ark0N:master Oct 22, 2026
2 checks passed
Ark0N pushed a commit that referenced this pull request Oct 22, 2026
…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>
Ark0N pushed a commit that referenced this pull request Oct 22, 2026
…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>
Ark0N pushed a commit that referenced this pull request Oct 22, 2026
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>
Ark0N pushed a commit that referenced this pull request Oct 22, 2026
… 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>
Ark0N pushed a commit that referenced this pull request Oct 22, 2026
…t cache warm with a guarded keep-alive turn

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Ark0N pushed a commit that referenced this pull request Oct 22, 2026
…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>
Ark0N pushed a commit that referenced this pull request Oct 22, 2026
…h a configurable folder and retention

Conflicts in CLAUDE.md, README.md, docs/api-reference.md and
src/web/server.ts were additions beside #662's Copilot quota read and
#666's keep-warm sections; both sides kept.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@Ark0N

Ark0N commented Oct 22, 2026

Copy link
Copy Markdown
Owner

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:

  1. A failed keep-alive write is retried. The window closes (endKeepAliveTurn() is now part of KeepWarmSessionView), and the retry is armed at min(now + KEEP_WARM_RETRY_MS, expiresAt - KEEP_WARM_LAST_CHANCE_MS), with tests that see the second write and that it never lands past the last-chance line.
  2. An unreadable settings.json is logged once until a read succeeds. Only a session that could nudge (choice not false, exporter injected, a warm report) arms the retry.
  3. The keep-alive's hooks stay out of the run summary, both its stop and its idle_prompt, so nothing shows up in the Away digest.
  4. App Settings saves re-plan keep-alives only when the default or the cap actually changed.

The CLAUDE.md, invariants, wiki and api-reference text say so. The /compact-at-the-end-of-the-cap PR you mentioned is very welcome whenever you get to it.

@JDProfresh
JDProfresh deleted the feat/prompt-cache-keepwarm-v2 branch October 22, 2026 19:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants