From 83d80578e4c4ede0f20ea42773f4bede4eb32f37 Mon Sep 17 00:00:00 2001 From: JD Date: Mon, 19 Oct 2026 19:58:14 -0400 Subject: [PATCH] fix(sessions): read the composer from a styled capture so a prompt suggestion 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. --- CLAUDE.md | 2 +- docs/api-reference.md | 2 +- docs/architecture-invariants.md | 2 +- docs/wiki/Keeping-Agents-Running.md | 2 +- src/mux-interface.ts | 7 +- src/prompt-cache-keepwarm.ts | 48 +++++++-- src/session-submit-verifier.ts | 65 ++++++++++- src/session.ts | 5 +- src/tmux-manager.ts | 14 +-- src/web/public/constants.js | 8 +- src/web/server.ts | 9 +- test/keep-warm-launch.test.ts | 3 +- test/prompt-cache-keepwarm.test.ts | 162 +++++++++++++++++++++++++++- 13 files changed, 292 insertions(+), 37 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 829554ae..9b8f64f2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -158,7 +158,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Plan-usage chip** (`showPlanUsageLimits`, per-device: desktop default **ON**, handhelds OFF): resolve DISPLAY only through `planUsageChipEnabled()` (settings-ui.js). The same setting is the server-side COLLECTION switch, read fresh by `readPlanUsageTelemetryEnabled()` (hooks-config.ts) at every claude create/respawn. ⚠️ An ABSENT key reads as ON in the reader; `GET /api/settings` must never write. ⚠️ A save sends `showPlanUsageLimits` ONLY when it flips the chip on that device (`planUsageCollectionFlip()`), or a phone switches collection off for every desktop. Claude data comes from the statusLine exporter, injected as an EPHEMERAL `claude --settings` flag (`resolveStatusLineCliCommand`), never written to disk, WRAPPING a user's own statusLine, posting to `POST /api/status-telemetry`. Codex comes from a read-only `account/rateLimits/read` poll (main bucket only). The statusline's `prompt_cache` object (Claude Code v2.1.251+) is kept by `StatusTelemetrySchema` (every documented scalar field, null-tolerant; `parsePromptCache()` in usage-telemetry.ts normalizes only the stored subset) and shown in the footer as `cache:until HH:MM` / `cache:cold` (only when the user has no statusline of their own) and in Session Options → Respawn (`#promptCacheStatus`, fed by `Session.promptCache` in `toState()` plus the change-only `session:promptCache` SSE event, worded by the pure `CodemanPromptCache.format`): warm-until/cold, the re-cache stake, the hit rate, the last miss's causes, and a `Context compacted at HH:MM` note after a compaction or a tool-result clearing (stamped on the first warm report whose `recache_tokens_if_cold` is null after one that carried it, never from `expected_rebuilds`, which Claude Code bumps only at the next request; NEVER an alert). ⚠️ `Session.setPromptCache()` stamps `compactedAt` onto a NEW object (never mutating the parsed report) and the route broadcasts the STORED object; the last report is restored on restart (constructor `promptCache`, like `displayModel`) and the readout is time-aware (a passed `expiresAt` reads cold, not stale-warm). It is the source of truth for the cache TTL, never the plan tier. → [architecture-invariants#plan-usage-chip-statusline-telemetry](docs/architecture-invariants.md#plan-usage-chip-statusline-telemetry), `docs/usage-limits-display-plan.md` -**Prompt-cache keep-warm** (`src/prompt-cache-keepwarm.ts`; per-session opt-in on the Respawn tab, app-wide default `promptCacheKeepWarm` + cap `promptCacheKeepWarmMaxHours` in App Settings → System → Automation, SYNCED, default OFF / 2 h; step 2 of Discussion #554): one keep-alive turn (`KEEP_WARM_NUDGE`, under 100 chars) five minutes before an idle claude session's cache would expire, so the next prompt reads the conversation from cache instead of re-writing it at 2x. Codeman owns the idle policy for these sessions: a launch with keep-warm on also carries `"idleCompaction": false`, folded LAST into the ONE `--settings` JSON (`session-cli-registry-bridge.ts`), decided once per create/respawn by `decideIdleCompactionOptOut()` (tmux-manager.ts: keep-warm on via `keepWarmOn()`, exporter `injected`, claude's `idleCompactionSetting` gate at 2.1.290, fail-closed like `--name`) and recorded as `MuxSession.idleCompactionOff`; a keep-warm-off launch stays byte-identical (`test/cli-registry-spawn-golden.test.ts`). Switched on after launch it is best-effort and the readout says idle compaction may still run until the next launch. ⚠️ The deadline is `Session.promptCache.expiresAt` from the statusline, never idle detection or the plan tier; a `5m` TTL is a hard stop, and so is a launch whose `statusLineExporter` is not `injected`. ⚠️ Every guard lives in the pure `decideKeepWarm()` (effective opt-in, where an agent-spawned session (`parentSessionId`) does not inherit the app default; exporter injected; warm; no `compactedAt` this gap; tokens at stake; live pane (pid AND no `paneExit`); no limit pause (auto-resume's, or a limit message on screen); no respawn/Ralph/task/waiter; inside the cap since `lastRealPromptAt`; idle; no dialog; EMPTY composer via `composerIsEmpty()`, continuation lines included; no recent keystroke); add a guard there, never in the service. ⚠️ The nudge IS a submit and moves `lastSubmitAt` (the response viewer's conversation correlation needs it); the window `beginKeepAliveTurn()` opens keeps it off `lastRealPromptAt` (the cap's own persisted clock) and opens its `stop`/`idle_prompt` hooks pre-acknowledged (`preAcknowledged` on `notePrompt`), so nothing alerts, and leaves an idle item already pending exactly as found (only a human spends an idle alert; `session:working`/`session:idle` carry `keepAlive: true` so the page's stuck timer ignores the turn); the keep-alive's own `idle_prompt` ends that window (`endKeepAliveTurn()`; `KEEP_WARM_TURN_WINDOW_MS` is only a ceiling, since a slow turn's idle prompt lands a minute after it ends), and so does any other submit: a `fromUser` write, a programmatic write of anything but the nudge, or a `prompt_submitted` hook whose prompt is not the nudge. Read My Mind drops its exact text (`isCapturablePrompt`). → [architecture-invariants#prompt-cache-keep-warm](docs/architecture-invariants.md#prompt-cache-keep-warm) +**Prompt-cache keep-warm** (`src/prompt-cache-keepwarm.ts`; per-session opt-in on the Respawn tab, app-wide default `promptCacheKeepWarm` + cap `promptCacheKeepWarmMaxHours` in App Settings → System → Automation, SYNCED, default OFF / 2 h; step 2 of Discussion #554): one keep-alive turn (`KEEP_WARM_NUDGE`, under 100 chars) five minutes before an idle claude session's cache would expire, so the next prompt reads the conversation from cache instead of re-writing it at 2x. Codeman owns the idle policy for these sessions: a launch with keep-warm on also carries `"idleCompaction": false`, folded LAST into the ONE `--settings` JSON (`session-cli-registry-bridge.ts`), decided once per create/respawn by `decideIdleCompactionOptOut()` (tmux-manager.ts: keep-warm on via `keepWarmOn()`, exporter `injected`, claude's `idleCompactionSetting` gate at 2.1.290, fail-closed like `--name`) and recorded as `MuxSession.idleCompactionOff`; a keep-warm-off launch stays byte-identical (`test/cli-registry-spawn-golden.test.ts`). Switched on after launch it is best-effort and the readout says idle compaction may still run until the next launch. ⚠️ The deadline is `Session.promptCache.expiresAt` from the statusline, never idle detection or the plan tier; a `5m` TTL is a hard stop, and so is a launch whose `statusLineExporter` is not `injected`. ⚠️ Every guard lives in the pure `decideKeepWarm()` (effective opt-in, where an agent-spawned session (`parentSessionId`) does not inherit the app default; exporter injected; warm; no `compactedAt` this gap; tokens at stake; live pane (pid AND no `paneExit`); no limit pause (auto-resume's, or a limit message on screen); no respawn/Ralph/task/waiter; inside the cap since `lastRealPromptAt`; idle; no dialog; EMPTY composer via `composerIsEmpty()`, continuation lines included, read from a STYLED capture so Claude Code's faint prompt suggestion is not taken for a draft; no recent keystroke); add a guard there, never in the service. ⚠️ The nudge IS a submit and moves `lastSubmitAt` (the response viewer's conversation correlation needs it); the window `beginKeepAliveTurn()` opens keeps it off `lastRealPromptAt` (the cap's own persisted clock) and opens its `stop`/`idle_prompt` hooks pre-acknowledged (`preAcknowledged` on `notePrompt`), so nothing alerts, and leaves an idle item already pending exactly as found (only a human spends an idle alert; `session:working`/`session:idle` carry `keepAlive: true` so the page's stuck timer ignores the turn); the keep-alive's own `idle_prompt` ends that window (`endKeepAliveTurn()`; `KEEP_WARM_TURN_WINDOW_MS` is only a ceiling, since a slow turn's idle prompt lands a minute after it ends), and so does any other submit: a `fromUser` write, a programmatic write of anything but the nudge, or a `prompt_submitted` hook whose prompt is not the nudge. Read My Mind drops its exact text (`isCapturablePrompt`). → [architecture-invariants#prompt-cache-keep-warm](docs/architecture-invariants.md#prompt-cache-keep-warm) **Orchestrator**: State machine that turns a user goal into a phased plan and drives it to completion: `idle → planning → approval → executing → verifying → (replanning) → completed/failed`. `OrchestratorLoop` (engine) delegates plan generation to `orchestrator-planner` and per-phase verification gates to `orchestrator-verifier`, executing phases via team agents/`task-queue`. State persists under the `orchestrator` key in `state.json`. Distinct from Ralph (single-session autonomous loop) — orchestrator coordinates multi-phase, multi-agent execution. See `docs/orchestrator-loop-architecture.md`. diff --git a/docs/api-reference.md b/docs/api-reference.md index 00fc4ef6..81fecd30 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1160,7 +1160,7 @@ Claude Code reports the main conversation's prompt-cache state on its statusline | ------ | ---------------------------------- | ------------------------------------ | ----- | | `POST` | `/api/v1/sessions/:id/keep-warm` | `{ "enabled": true \| false \| null }` | Sets the session's choice; `null` clears it so the app-wide default applies. `200` with `{ keepWarm: { enabled, status } }`. `400 INVALID_INPUT` for a CLI that reports no cache telemetry on its statusline (anything but Claude today). | -`status` is `{ enabled, idleCompactionOff, state, nextCheckAt?, reason?, nudges, lastNudgeAt?, budgetEndsAt? }` (`idleCompactionOff`: whether the session's current launch carries that opt-out) with `state` one of `off`, `armed` (waiting for the due time), `waiting` (due, deferred by a guard named in `reason`), `nudged` (sent, waiting for the next cache report) or `stopped` (`reason` says why). Two SSE events carry changes: `session:promptCache` (`{ sessionId, ...promptCache }`) and `session:keepWarm` (`{ sessionId, ...status }`), both change-only. The keep-alive turn's `hook:stop` and `hook:idle_prompt` broadcasts carry `acknowledgedReason: "cache keep-alive"`, and no push is sent for them; an idle approval already pending stays as it was (only a person spends its alert). Its `session:working` and `session:idle` broadcasts carry `keepAlive: true`. +`status` is `{ enabled, idleCompactionOff, state, nextCheckAt?, reason?, nudges, lastNudgeAt?, budgetEndsAt? }` (`idleCompactionOff`: whether the session's current launch carries that opt-out; `budgetEndsAt` only while `enabled`) with `state` one of `off`, `armed` (waiting for the due time), `waiting` (due, deferred by a guard named in `reason`), `nudged` (sent, waiting for the next cache report) or `stopped` (`reason` says why). Two SSE events carry changes: `session:promptCache` (`{ sessionId, ...promptCache }`) and `session:keepWarm` (`{ sessionId, ...status }`), both change-only. The keep-alive turn's `hook:stop` and `hook:idle_prompt` broadcasts carry `acknowledgedReason: "cache keep-alive"`, and no push is sent for them; an idle approval already pending stays as it was (only a person spends its alert). Its `session:working` and `session:idle` broadcasts carry `keepAlive: true`. ## Webhook notifications diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 6ed205ae..835d5279 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -161,7 +161,7 @@ Further detail: the handheld OFF default comes from the mobile block in `getDefa ### Prompt-cache keep-warm -**Prompt-cache keep-warm** (`src/prompt-cache-keepwarm.ts`; per-session opt-in `keepWarmEnabled` on the Respawn tab, app-wide default `promptCacheKeepWarm` and cap `promptCacheKeepWarmMaxHours` under App Settings → System → Automation, both SYNCED, default OFF and 2 h; step 2 of Discussion #554): shortly before an idle claude session's prompt cache would expire, Codeman sends ONE keep-alive turn (`KEEP_WARM_NUDGE`, under 100 characters so Claude Code never reads the burst as a paste) so the next real prompt still reads the conversation from cache. Measured on two sessions of ~110k tokens: the nudge read 109,831 tokens from cache and wrote 70, replied `ok` in three seconds with no tool use, and a real turn seven minutes past the original expiry stayed a hit while the un-nudged control re-wrote 89,882 tokens with `last_miss_cause: ttl_expired_1h`; the payoff on real usage is concentrated in gaps of one to four hours, which is why there is a cap after the last real prompt rather than an indefinite loop (2 h by default: the maintainer's simulation over 30 days of gaps came out slightly cheaper than 3 h). ⚠️ **Codeman owns the idle policy for a keep-warm session.** Claude Code 2.1.289+ can compact an idle 1h-cache session by itself at 90% of the TTL (a server-flagged experiment, `fireAtFraction` tunable down to 0.5), which on the default timing always lands a minute before the nudge and leaves only a summary to keep warm. So a launch with keep-warm on carries `"idleCompaction": false`, folded LAST into the ONE `--settings` JSON with ultracode, the advisor and the statusLine exporter (`buildSpawnCommandFromRegistry`; never a second `--settings` flag, Claude Code takes one), so a keep-warm-off launch is byte-identical to one from before keep-warm (`test/cli-registry-spawn-golden.test.ts`). The `--settings` flag outranks user, project and local settings, which is why the key is conditional: everywhere else it would take Claude Code's own behaviour away with no way back. `decideIdleCompactionOptOut()` (tmux-manager.ts, pure) decides it once per create and respawn: keep-warm on (`keepWarmOn()`: the session's choice, else the app default, read fresh by `readKeepWarmSettings()`), the exporter `injected` (without `prompt_cache` reports keep-warm cannot run, and the opt-out alone would only cost the user the idle compaction), and claude's `idleCompactionSetting` gate (`capabilities.gates`, 2.1.290, the first release whose settings schema has the key; 2.1.289 already idle-compacts with no opt-out), fail-closed through `launchGatePasses()` exactly like `--name`: an unknown version means no key, and a log line says so. The result is recorded on the mux record (`MuxSession.idleCompactionOff`, like `statusLine`, persisted in mux-sessions.json) and published in the keep-warm status as `idleCompactionOff`; a session switched on after launch is kept warm best-effort (a relaunch to install the opt-out would cost exactly the re-write being avoided) and its readout says Claude Code's idle compaction may still run until its next launch, which any relaunch (dead-pane recovery, a custom-model `restartCli()`) fixes for free since every relaunch re-decides. ⚠️ The deadline is the statusline's `prompt_cache.expires_at` (`Session.promptCache`), never idle detection or the plan tier, and a `5m` lifetime is a hard stop (a nudge every four minutes costs more than the miss it prevents; API-key users get the hour from `CLAUDE_CODE_PROMPT_CACHE_TTL=1h` in `envOverrides`). ⚠️ `decideKeepWarm()` is the ONE rulebook and every guard lives in it, tested as a table: the effective opt-in (`keepWarmOn()`; a session an agent spawned, `parentSessionId`, does not inherit the app default, since an idle worker that reported done looks exactly like one that will be prompted again and most never are; its own switch still works), the launch's `statusLineExporter` is `injected` (which also stops every CLI without cache telemetry; the toggle line says why in the readout's own `PROMPT_CACHE_NOT_COLLECTED` words), a warm cache with an expiry, no `compactedAt` this gap (a compaction already replaced the context, and the stamp is dropped at the next request, which re-opens the next gap), not a `5m` lifetime, at least `KEEP_WARM_MIN_TOKENS` at stake, a live pane (a pid AND no recorded `paneExit`: an agent that exited in a `remain-on-exit` pane keeps its pid), no usage-limit pause (auto-resume's, or, read off the same pane capture as the composer, a usage-limit message whose reset is still ahead, since auto-resume tracks pauses only while it is on), no automation (respawn running, a Ralph loop active, a task assigned, or an agent long-polling `wait`), inside the cap measured from `lastRealPromptAt` (else `createdAt`), due at `expires_at` minus `KEEP_WARM_LEAD_MS` but not within `KEEP_WARM_LAST_CHANCE_MS`, idle, no open permission or question dialog, an EMPTY composer (`composerIsEmpty()` in session-submit-verifier.ts reads the last glyph line AND its continuation lines down to the composer's bottom rule, since a draft that opens with a newline leaves the glyph line blank: `send-keys -l` would append the nudge to a draft, and an unreadable pane or a missing rule is a stop, never a guess), and no human keystroke within `KEEP_WARM_RECENT_INPUT_MS`. Transient guards defer by `KEEP_WARM_RETRY_MS` and never past the last-chance line; the composer is read only once everything else says "due". ⚠️ **Two submit clocks.** The nudge goes out through a plain `writeViaMux()` after `beginKeepAliveTurn()`. It IS a submit, so it moves `lastSubmitAt` from the write and again from its `prompt_submitted` hook (`markPromptSubmitted()`): the response viewer's conversation correlation (`resolveActiveClaudeSessionIdFromHistory()`) matches that stamp against Claude's history, which records the nudge like any prompt. The cap needs the last REAL prompt instead, so `Session._stampSubmit()` also moves `lastRealPromptAt` for every submit outside the keep-alive window (a person in the browser, an agent over the API, a typist in an attached tmux client seen through the hook), persisted in `SessionState.lastRealPromptAt` and restored by both rebuild paths so a restart never reopens the window. The window closes on the keep-alive's own `idle_prompt` (the hook route calls `endKeepAliveTurn()`; Claude Code sends it about a minute after the turn ends, so a fixed short window would let a slow turn's idle prompt alert and push, and `KEEP_WARM_TURN_WINDOW_MS` is only a ceiling for a workspace whose hooks never report). Any other submit closes it at once: a `fromUser` write, a programmatic write carrying anything but the nudge or a bare Enter (an agent, respawn, auto-resume; `isKeepAliveWrite()` in session.ts), or a `prompt_submitted` hook whose `prompt` is not the exact nudge (the route hands the hook's prompt text to `markPromptSubmitted()`), so a person's prompt right after a nudge, typed in the browser or in an attached tmux client, counts and alerts normally. The home screens and the tile grid read `lastSubmitAt` only to anchor a WORKING row, so a nudge shows there only for the seconds its turn runs; idle rows key on `lastActivityAt`, which the nudge's reply moves like any output. ⚠️ The same window makes the turn's `stop` hook and `idle_prompt` open pre-acknowledged (`preAcknowledged: 'cache keep-alive'` through `notePrompt()`, the same path `watching` uses), so no tab alert, toast, push or webhook fires. ⚠️ Only a human spends an idle alert, so the keep-alive turn leaves an idle item already pending exactly as it found it: the `working` transition's idle resolve (session-listener-wiring.ts) and the `stop` resolve skip it, and the keep-alive's `idle_prompt` does not replace it (a replacement would forget that it was unseen, or that a person had seen it), while the page's `_onHookStop` returns on `acknowledgedReason` before clearing anything; `session:working` and `session:idle` carry `keepAlive: true` for the turn, so the page's stuck-detection timer is neither cleared nor re-armed by it. Read My Mind drops the exact nudge text in `isCapturablePrompt()`, the filter `captureIntentPrompt()` → `intentStore.recordPrompt()` runs every transcript prompt through. ⚠️ Re-evaluation is edge-triggered and cheap: every cache report (`status-telemetry-routes.ts`), the toggle route, a settings change (`rescheduleAll()`, which also drops the service's in-memory copy of the default and cap, so a cache report never reads settings.json), `setupSessionListeners()` and the session's `idle` event (`keepWarmSchedule` in session-listener-wiring.ts, which is also what arms a restored report once the recovered pane is attached); the service keeps one timer per session (clamped to the 2^31-1 ms `setTimeout` honours, so a far-off expiry cannot turn into a 1 ms loop), `remove()`s it with the session and stops with the server, and a `remove()` or `stop()` landing during an evaluation's await cancels it there (nothing sent, armed or published after), and the server logs one `[KeepWarm]` line per status change naming the guard that holds it. Status rides `Session.keepWarm` in `toState()` and the change-only `session:keepWarm` event for the readout; `keepWarmEnabled`, `lastRealPromptAt` and the last `promptCache` report are restored by the constructor on both rebuild paths (that is what re-arms the timer after a restart), the status never is. Tests: `test/prompt-cache-keepwarm.test.ts`, `test/keep-warm-launch.test.ts`, `test/keep-warm-i18n.test.ts`, `test/routes/session-routes-keep-warm.test.ts`, the keep-warm readout in `test/prompt-cache-status.test.ts`, the opt-out literals in `test/cli-registry-spawn-golden.test.ts`, the keep-alive cases in `test/routes/hook-event-routes.test.ts` and `test/session-listener-wiring.test.ts`, and the page handlers in `test/keep-alive-page-alerts.test.ts`. +**Prompt-cache keep-warm** (`src/prompt-cache-keepwarm.ts`; per-session opt-in `keepWarmEnabled` on the Respawn tab, app-wide default `promptCacheKeepWarm` and cap `promptCacheKeepWarmMaxHours` under App Settings → System → Automation, both SYNCED, default OFF and 2 h; step 2 of Discussion #554): shortly before an idle claude session's prompt cache would expire, Codeman sends ONE keep-alive turn (`KEEP_WARM_NUDGE`, under 100 characters so Claude Code never reads the burst as a paste) so the next real prompt still reads the conversation from cache. Measured on two sessions of ~110k tokens: the nudge read 109,831 tokens from cache and wrote 70, replied `ok` in three seconds with no tool use, and a real turn seven minutes past the original expiry stayed a hit while the un-nudged control re-wrote 89,882 tokens with `last_miss_cause: ttl_expired_1h`; the payoff on real usage is concentrated in gaps of one to four hours, which is why there is a cap after the last real prompt rather than an indefinite loop (2 h by default: the maintainer's simulation over 30 days of gaps came out slightly cheaper than 3 h). ⚠️ **Codeman owns the idle policy for a keep-warm session.** Claude Code 2.1.289+ can compact an idle 1h-cache session by itself at 90% of the TTL (a server-flagged experiment, `fireAtFraction` tunable down to 0.5), which on the default timing always lands a minute before the nudge and leaves only a summary to keep warm. So a launch with keep-warm on carries `"idleCompaction": false`, folded LAST into the ONE `--settings` JSON with ultracode, the advisor and the statusLine exporter (`buildSpawnCommandFromRegistry`; never a second `--settings` flag, Claude Code takes one), so a keep-warm-off launch is byte-identical to one from before keep-warm (`test/cli-registry-spawn-golden.test.ts`). The `--settings` flag outranks user, project and local settings, which is why the key is conditional: everywhere else it would take Claude Code's own behaviour away with no way back. `decideIdleCompactionOptOut()` (tmux-manager.ts, pure) decides it once per create and respawn: keep-warm on (`keepWarmOn()`: the session's choice, else the app default, read fresh by `readKeepWarmSettings()`), the exporter `injected` (without `prompt_cache` reports keep-warm cannot run, and the opt-out alone would only cost the user the idle compaction), and claude's `idleCompactionSetting` gate (`capabilities.gates`, 2.1.290, the first release whose settings schema has the key; 2.1.289 already idle-compacts with no opt-out), fail-closed through `launchGatePasses()` exactly like `--name`: an unknown version means no key, and a log line says so. The result is recorded on the mux record (`MuxSession.idleCompactionOff`, like `statusLine`, persisted in mux-sessions.json) and published in the keep-warm status as `idleCompactionOff`; a session switched on after launch is kept warm best-effort (a relaunch to install the opt-out would cost exactly the re-write being avoided) and its readout says Claude Code's idle compaction may still run until its next launch, which any relaunch (dead-pane recovery, a custom-model `restartCli()`) fixes for free since every relaunch re-decides. ⚠️ The deadline is the statusline's `prompt_cache.expires_at` (`Session.promptCache`), never idle detection or the plan tier, and a `5m` lifetime is a hard stop (a nudge every four minutes costs more than the miss it prevents; API-key users get the hour from `CLAUDE_CODE_PROMPT_CACHE_TTL=1h` in `envOverrides`). ⚠️ `decideKeepWarm()` is the ONE rulebook and every guard lives in it, tested as a table: the effective opt-in (`keepWarmOn()`; a session an agent spawned, `parentSessionId`, does not inherit the app default, since an idle worker that reported done looks exactly like one that will be prompted again and most never are; its own switch still works), the launch's `statusLineExporter` is `injected` (which also stops every CLI without cache telemetry; the toggle line says why in the readout's own `PROMPT_CACHE_NOT_COLLECTED` words), a warm cache with an expiry, no `compactedAt` this gap (a compaction already replaced the context, and the stamp is dropped at the next request, which re-opens the next gap), not a `5m` lifetime, at least `KEEP_WARM_MIN_TOKENS` at stake, a live pane (a pid AND no recorded `paneExit`: an agent that exited in a `remain-on-exit` pane keeps its pid), no usage-limit pause (auto-resume's, or, read off the same pane capture as the composer, a usage-limit message whose reset is still ahead, since auto-resume tracks pauses only while it is on), no automation (respawn running, a Ralph loop active, a task assigned, or an agent long-polling `wait`), inside the cap measured from `lastRealPromptAt` (else `createdAt`), due at `expires_at` minus `KEEP_WARM_LEAD_MS` but not within `KEEP_WARM_LAST_CHANCE_MS`, idle, no open permission or question dialog, an EMPTY composer (`composerIsEmpty()` in session-submit-verifier.ts reads the last glyph line AND its continuation lines down to the composer's bottom rule, since a draft that opens with a newline leaves the glyph line blank: the nudge's paste would join a draft, and a missing rule is never read as empty). ⚠️ The composer is read from a STYLED capture (`capturePaneText(…, { styles: true })`, `capture-pane -p -e`, still one line per row; never `capturePaneBuffer()`, whose visible frame is a cursor-addressed repaint with no line breaks): Claude Code's prompt suggestion (on by default) fills an empty composer with faint (SGR 2) text whose first letter sits in the reverse-video cursor cell, and only that styling tells it from a typed draft, so `composerIsEmpty()` drops faint runs and a reverse cell followed by faint text. The paste replaces a suggestion rather than joining it (measured on Claude Code 2.1.300), and no human keystroke within `KEEP_WARM_RECENT_INPUT_MS`. Transient guards (working, a dialog, automation, an unreadable or non-empty composer, a recent keystroke) defer by `KEEP_WARM_RETRY_MS` and never past the last-chance line, an evaluation that throws re-arms the same retry, and a trigger landing during an evaluation queues one follow-up after it instead of being dropped, and `remove()` cancels both the follow-up and the retry; the composer is read only once everything else says "due". ⚠️ **Two submit clocks.** The nudge goes out through a plain `writeViaMux()` after `beginKeepAliveTurn()`. It IS a submit, so it moves `lastSubmitAt` from the write and again from its `prompt_submitted` hook (`markPromptSubmitted()`): the response viewer's conversation correlation (`resolveActiveClaudeSessionIdFromHistory()`) matches that stamp against Claude's history, which records the nudge like any prompt. The cap needs the last REAL prompt instead, so `Session._stampSubmit()` also moves `lastRealPromptAt` for every submit outside the keep-alive window (a person in the browser, an agent over the API, a typist in an attached tmux client seen through the hook), persisted in `SessionState.lastRealPromptAt` and restored by both rebuild paths so a restart never reopens the window. The window closes on the keep-alive's own `idle_prompt` (the hook route calls `endKeepAliveTurn()`; Claude Code sends it about a minute after the turn ends, so a fixed short window would let a slow turn's idle prompt alert and push, and `KEEP_WARM_TURN_WINDOW_MS` is only a ceiling for a workspace whose hooks never report). Any other submit closes it at once: a `fromUser` write, a programmatic write carrying anything but the nudge or a bare Enter (an agent, respawn, auto-resume; `isKeepAliveWrite()` in session.ts), or a `prompt_submitted` hook whose `prompt` is not the exact nudge (the route hands the hook's prompt text to `markPromptSubmitted()`), so a person's prompt right after a nudge, typed in the browser or in an attached tmux client, counts and alerts normally. The home screens and the tile grid read `lastSubmitAt` only to anchor a WORKING row, so a nudge shows there only for the seconds its turn runs; idle rows key on `lastActivityAt`, which the nudge's reply moves like any output. ⚠️ The same window makes the turn's `stop` hook and `idle_prompt` open pre-acknowledged (`preAcknowledged: 'cache keep-alive'` through `notePrompt()`, the same path `watching` uses), so no tab alert, toast, push or webhook fires. ⚠️ Only a human spends an idle alert, so the keep-alive turn leaves an idle item already pending exactly as it found it: the `working` transition's idle resolve (session-listener-wiring.ts) and the `stop` resolve skip it, and the keep-alive's `idle_prompt` does not replace it (a replacement would forget that it was unseen, or that a person had seen it), while the page's `_onHookStop` returns on `acknowledgedReason` before clearing anything; `session:working` and `session:idle` carry `keepAlive: true` for the turn, so the page's stuck-detection timer is neither cleared nor re-armed by it. Read My Mind drops the exact nudge text in `isCapturablePrompt()`, the filter `captureIntentPrompt()` → `intentStore.recordPrompt()` runs every transcript prompt through. ⚠️ Re-evaluation is edge-triggered and cheap: every cache report (`status-telemetry-routes.ts`), the toggle route, a settings change (`rescheduleAll()`, which also drops the service's in-memory copy of the default and cap, so a cache report never reads settings.json), `setupSessionListeners()` and the session's `idle` event (`keepWarmSchedule` in session-listener-wiring.ts, which is also what arms a restored report once the recovered pane is attached); the service keeps one timer per session (clamped to the 2^31-1 ms `setTimeout` honours, so a far-off expiry cannot turn into a 1 ms loop), `remove()`s it with the session and stops with the server, and a `remove()` or `stop()` landing during an evaluation's await cancels it there (nothing sent, armed or published after), and the server logs one `[KeepWarm]` line per status change naming the guard that holds it. Status rides `Session.keepWarm` in `toState()` and the change-only `session:keepWarm` event for the readout; `keepWarmEnabled`, `lastRealPromptAt` and the last `promptCache` report are restored by the constructor on both rebuild paths (that is what re-arms the timer after a restart), the status never is. Tests: `test/prompt-cache-keepwarm.test.ts`, `test/keep-warm-launch.test.ts`, `test/keep-warm-i18n.test.ts`, `test/routes/session-routes-keep-warm.test.ts`, the keep-warm readout in `test/prompt-cache-status.test.ts`, the opt-out literals in `test/cli-registry-spawn-golden.test.ts`, the keep-alive cases in `test/routes/hook-event-routes.test.ts` and `test/session-listener-wiring.test.ts`, and the page handlers in `test/keep-alive-page-alerts.test.ts`. ### Cron jobs diff --git a/docs/wiki/Keeping-Agents-Running.md b/docs/wiki/Keeping-Agents-Running.md index 18855654..927e1011 100644 --- a/docs/wiki/Keeping-Agents-Running.md +++ b/docs/wiki/Keeping-Agents-Running.md @@ -111,7 +111,7 @@ Claude Code 2.1.251 and newer reports the cache state on the statusline after ev **Keep the cache warm while idle** is the switch under the readout. With it on, Codeman sends a one-line keep-alive turn (`Codeman cache keep-alive: no action needed, reply with just "ok".`) about five minutes before the cache would expire. Claude answers `ok`, the request refreshes the cache for another hour, and your next real prompt reads the conversation from cache instead of re-processing it. Measured on sessions of about 110k tokens, the keep-alive cost a cache read plus a one-word reply, a small fraction of the re-write it prevented. -It only fires when there is nothing else going on: the session is idle with an empty prompt, no permission or question dialog is open, nobody has typed in the last few minutes, no respawn loop, Ralph loop or waiting agent is driving the session, and the session is not paused on a usage limit. If any of those hold it waits a minute and looks again, and gives up a minute before the expiry rather than race it. It also stops by itself a few hours after your last prompt (**Keep warm for at most**, under App Settings → System → Automation, default 2 hours), because the saving is concentrated in gaps of one to four hours and an overnight run of hourly keep-alives would cost more than the single cold turn in the morning. It stands down for the rest of a gap once the context has been compacted. The readout's Status line gains a `Keep-alive:` line that says what it is doing: the next keep-alive and when the window closes, waiting and why, sent at a time with a count, or stopped and why. A session started without the cache readout (Plan Usage off at launch, a workspace with its own statusLine, a remote or Docker session) cannot be kept warm: the switch is greyed out and cannot be switched on (one that is already on can still be switched off), and the Status line says why. +It only fires when there is nothing else going on: the session is idle with an empty prompt (Claude Code's greyed-out prompt suggestion counts as empty), no permission or question dialog is open, nobody has typed in the last few minutes, no respawn loop, Ralph loop or waiting agent is driving the session, and the session is not paused on a usage limit. If any of those hold it waits a minute and looks again, and gives up a minute before the expiry rather than race it. It also stops by itself a few hours after your last prompt (**Keep warm for at most**, under App Settings → System → Automation, default 2 hours), because the saving is concentrated in gaps of one to four hours and an overnight run of hourly keep-alives would cost more than the single cold turn in the morning. It stands down for the rest of a gap once the context has been compacted. The readout's Status line gains a `Keep-alive:` line that says what it is doing: the next keep-alive and when the window closes, waiting and why, sent at a time with a count, or stopped and why. A session started without the cache readout (Plan Usage off at launch, a workspace with its own statusLine, a remote or Docker session) cannot be kept warm: the switch is greyed out and cannot be switched on (one that is already on can still be switched off), and the Status line says why. Recent Claude Code builds can compact a large idle session on their own shortly before the cache expires, which would leave only a summary to keep warm. So a session **started** with keep-warm on also launches with Claude Code's idle compaction switched off (Claude Code 2.1.290 or newer); Codeman then owns the idle policy for it. A session you switch on after it started is still kept warm, but Claude Code may compact it first until its next launch, and the Status line says so; one you switch off keeps Claude Code's idle compaction off until its next launch, and the Status line says that too. Sessions with keep-warm off launch exactly as before. diff --git a/src/mux-interface.ts b/src/mux-interface.ts index d064f165..446cba54 100644 --- a/src/mux-interface.ts +++ b/src/mux-interface.ts @@ -428,11 +428,12 @@ export interface TerminalMultiplexer extends EventEmitter { captureActivePaneBuffer?(muxName: string, opts?: PaneCaptureOptions): string | null; /** - * Plain text of the visible frame: no styles, no cursor query, no repaint - * reconstruction. Deliberately cheaper than `capturePaneBuffer` because idle + * Plain text of the visible frame: no cursor query, no repaint + * reconstruction, and no styles unless `styles` asks for them (SGR only, lines + * still `\n`-separated). Deliberately cheaper than `capturePaneBuffer` because idle * detection calls it on a timer: it only needs to read what the CLI is * currently rendering, never to replay it into an xterm. Returns null when the * pane cannot be read. */ - capturePaneText?(muxName: string, paneTarget?: string): string | null; + capturePaneText?(muxName: string, paneTarget?: string, opts?: { styles?: boolean }): string | null; } diff --git a/src/prompt-cache-keepwarm.ts b/src/prompt-cache-keepwarm.ts index 7fc67450..f721e16a 100644 --- a/src/prompt-cache-keepwarm.ts +++ b/src/prompt-cache-keepwarm.ts @@ -134,7 +134,6 @@ export function decideKeepWarm(f: KeepWarmFacts): KeepWarmDecision { if ((c.recacheTokensIfCold ?? 0) < KEEP_WARM_MIN_TOKENS) return stop('context too small to be worth a turn'); if (!f.paneAlive) return stop('no live pane'); if (f.limitPaused) return stop('usage-limit pause'); - if (f.automationActive) return stop('respawn, Ralph or an agent is driving the session'); if (f.now >= f.budgetAnchorAt + f.maxKeepWarmMs) return stop('keep-warm window after the last prompt has passed'); const dueAt = c.expiresAt - KEEP_WARM_LEAD_MS; if (f.now < dueAt) return wait(dueAt, 'not due yet'); @@ -143,7 +142,10 @@ export function decideKeepWarm(f: KeepWarmFacts): KeepWarmDecision { const retryAt = Math.min(f.now + KEEP_WARM_RETRY_MS, lastChance); if (!f.idle) return wait(retryAt, 'session is working'); if (f.pendingApproval) return wait(retryAt, 'a dialog is open'); - if (f.composerEmpty === null) return stop('cannot read the composer'); + // Transient, and nothing else re-triggers an idle session: an agent's long-poll waiter ends + // without a turn, and so does a capture that failed once. + if (f.automationActive) return wait(retryAt, 'respawn, Ralph or an agent is driving the session'); + if (f.composerEmpty === null) return wait(retryAt, 'cannot read the composer'); if (!f.composerEmpty) return wait(retryAt, 'the composer holds text'); if (f.lastHumanInputAt && f.now - f.lastHumanInputAt < KEEP_WARM_RECENT_INPUT_MS) { return wait(retryAt, 'someone typed a moment ago'); @@ -213,6 +215,9 @@ export class PromptCacheKeepWarmService implements KeepWarmScheduler { private readonly tallies = new Map(); /** One evaluation per session; `remove()` and `stop()` cancel it at its next await. */ private readonly inFlight = new Map(); + /** The evaluation running per session, and the one follow-up queued behind it. */ + private readonly running = new Map>(); + private readonly followUps = new Map>(); /** * The default and cap, read once and kept: every statusline report schedules an evaluation, * and reading settings.json on each would cost every install, the feature off included. @@ -225,9 +230,36 @@ export class PromptCacheKeepWarmService implements KeepWarmScheduler { schedule(sessionId: string): Promise { if (this.stopped) return Promise.resolve(); - return this.evaluate(sessionId).catch((err) => { - this.ports.log?.(`[KeepWarm] ${sessionId.slice(0, 8)}: ${err instanceof Error ? err.message : String(err)}`); - }); + const running = this.running.get(sessionId); + if (running) { + // A trigger landing mid-evaluation (a toggle while the nudge is written, a settings change) + // carries what that evaluation read too early, so one follow-up runs after it, shared by + // every trigger that arrives meanwhile. + let next = this.followUps.get(sessionId); + if (!next) { + next = running.then(() => { + // `remove()` drops the queued follow-up: a deleted session gets no evaluation. + if (this.followUps.get(sessionId) !== next) return; + this.followUps.delete(sessionId); + return this.schedule(sessionId); + }); + this.followUps.set(sessionId, next); + } + return next; + } + const run = this.evaluate(sessionId) + .catch((err) => { + this.ports.log?.(`[KeepWarm] ${sessionId.slice(0, 8)}: ${err instanceof Error ? err.message : String(err)}`); + // A failed settings read or write is transient, and an idle session gets no other + // trigger until its next turn. + // Not after `remove()`, which drops this run: the session is being deleted. + if (!this.stopped && this.running.get(sessionId) === run) this.arm(sessionId, Date.now() + KEEP_WARM_RETRY_MS); + }) + .finally(() => { + if (this.running.get(sessionId) === run) this.running.delete(sessionId); + }); + this.running.set(sessionId, run); + return run; } rescheduleAll(): void { @@ -239,6 +271,8 @@ export class PromptCacheKeepWarmService implements KeepWarmScheduler { this.clearTimer(sessionId); this.tallies.delete(sessionId); this.inFlight.delete(sessionId); + this.running.delete(sessionId); + this.followUps.delete(sessionId); } stop(): void { @@ -318,7 +352,9 @@ export class PromptCacheKeepWarmService implements KeepWarmScheduler { idleCompactionOff: session.idleCompactionOptOut, nudges: tally.nudges, lastNudgeAt: tally.lastNudgeAt, - budgetEndsAt: facts.budgetAnchorAt + facts.maxKeepWarmMs, + // Off, the window means nothing, and it moves with every real prompt: carrying it would + // broadcast a change on every turn of every session. + ...(enabled && { budgetEndsAt: facts.budgetAnchorAt + facts.maxKeepWarmMs }), }; if (decision.action === 'nudge') { session.beginKeepAliveTurn(KEEP_WARM_TURN_WINDOW_MS); diff --git a/src/session-submit-verifier.ts b/src/session-submit-verifier.ts index 0b6e2728..edadf998 100644 --- a/src/session-submit-verifier.ts +++ b/src/session-submit-verifier.ts @@ -65,26 +65,83 @@ export function promptStillInComposer(screen: string, prompt: string, glyph: str return head.length > 0 && composer.startsWith(head); } +/** + * A styled frame as plain text. SGR goes first with its own pattern because tmux writes + * colon sub-parameters (`ESC[4:3m`, a curly underline), which `stripAnsi` leaves behind. + */ +export function plainFrame(styled: string): string { + // eslint-disable-next-line no-control-regex + return stripAnsi(styled.replace(/\x1b\[[0-9;:]*m/g, '')); +} + +/** + * `screen` (a `capture-pane -p -e` frame) without the text Claude Code draws in an empty + * composer that nobody typed: its prompt suggestion, drawn faint (SGR 2), and the suggestion's + * first letter, which sits in the reverse-video cursor cell (SGR 7) right before the faint rest. + * A typed draft is never faint, so a cursor cell followed by anything else is kept. Every line + * break is kept, so line `n` here is line `n` of the plain frame, and plain text passes through + * unchanged. Other escapes are left for `stripAnsi`. + */ +function withoutSuggestion(screen: string): string { + let faint = false; + let inverse = false; + let cursorCell = ''; + let out = ''; + // eslint-disable-next-line no-control-regex + for (const m of screen.matchAll(/\x1b\[([0-9;:]*)m|[\s\S]/gu)) { + if (m[1] !== undefined) { + const params = m[1].split(';'); + for (let i = 0; i < params.length; i++) { + const p = params[i]; + if (p === '' || p === '0') faint = inverse = false; + else if (p === '2') faint = true; + else if (p === '22') faint = false; + else if (p === '7') inverse = true; + else if (p === '27') inverse = false; + // An extended colour's own arguments (`38;5;n`, `38;2;r;g;b`) are not attributes. + else if (p === '38' || p === '48' || p === '58') i += params[i + 1] === '5' ? 2 : params[i + 1] === '2' ? 4 : 0; + } + continue; + } + const ch = m[0]; + if (ch !== '\n' && faint) { + cursorCell = ''; + continue; + } + out += cursorCell; + cursorCell = ''; + if (ch !== '\n' && inverse) cursorCell = ch; + else out += ch; + } + return out + cursorCell; +} + /** * Whether the composer holds nothing: the LAST `glyph` line after the glyph, and every * continuation line below it down to the composer's bottom rule (a draft that opens with a * newline leaves the glyph line itself blank). Undefined when there is no composer line or no * rule under it to bound the read (a shell, a dialog, a direct-PTY session): nothing can be - * said. The prompt-cache keep-warm refuses to type unless this is true: `send-keys -l` would - * append its nudge to whatever draft sits there, and the Enter would submit both. + * said. The prompt-cache keep-warm refuses to type unless this is true: its paste would land + * in whatever draft sits there, and the Enter would submit both. + * + * Pass a STYLED frame (`capture-pane -p -e`): Claude Code's prompt suggestion (on by default) + * fills an empty composer with faint text, and only the styling tells it from a typed draft. + * The paste replaces a suggestion rather than joining it (measured on 2.1.300). A plain frame + * still parses, but reads a suggestion as a draft. */ export function composerIsEmpty(screen: string, glyph: string): boolean | undefined { if (!glyph) return undefined; // The structure is read at column 0, before any trimming: Claude Code draws the glyph line and // both rules there and indents every continuation line, so a draft line that holds the glyph or // starts with a rule (` ❯`, ` ───`) can never pass for the composer's own edges. - const lines = stripAnsi(screen).split('\n'); + const lines = plainFrame(screen).split('\n'); let at = lines.length - 1; while (at >= 0 && !lines[at].startsWith(glyph)) at--; if (at < 0) return undefined; const end = lines.findIndex((l, i) => i > at && l.startsWith('\u2500')); if (end < 0) return undefined; - return compact(lines[at].slice(glyph.length) + lines.slice(at + 1, end).join('')).length === 0; + const typed = plainFrame(withoutSuggestion(screen)).split('\n'); + return compact(typed[at].slice(glyph.length) + typed.slice(at + 1, end).join('')).length === 0; } export interface SubmitVerifierDeps { diff --git a/src/session.ts b/src/session.ts index e8ac08b1..8ded0775 100644 --- a/src/session.ts +++ b/src/session.ts @@ -1001,8 +1001,9 @@ export class Session extends EventEmitter { // response viewer re-derive the live conversation without waiting for the // user to type again. this._lastSubmitAt = config.lastSubmitAt ?? 0; - // Restored so a restart neither reopens the keep-warm window nor drops the choice. - this._lastRealPromptAt = config.lastRealPromptAt ?? 0; + // Restored so a restart neither reopens the keep-warm window nor drops the choice. A state.json + // from before the field has only `lastSubmitAt`, and every submit then was a real one. + this._lastRealPromptAt = config.lastRealPromptAt ?? config.lastSubmitAt ?? 0; this._keepWarmEnabled = config.keepWarmEnabled; // Restored chain: its tail is the conversation the CLI was actually on when // the server stopped, which outranks the launch id seeded just above. The diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index cbe5c94f..129f9d52 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -4143,17 +4143,19 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { /** * Plain visible-frame text for the working/idle probe (see `session.ts`). * - * One `capture-pane` and nothing else: no `-e` styles, no `display-message` - * cursor query, no repaint reconstruction: this feeds a regex, not a - * terminal. Returns null in tests (no tmux) so callers fall back to their - * stream heuristics rather than reading an empty screen as "not working". + * One `capture-pane` and nothing else: no `display-message` cursor query, no + * repaint reconstruction: this feeds a regex, not a terminal. `styles` adds + * `-e` for a reader that must tell faint text from typed text (the keep-warm + * composer check); lines stay `\n`-separated either way. Returns null in tests + * (no tmux) so callers fall back to their stream heuristics rather than + * reading an empty screen as "not working". */ - capturePaneText(muxName: string, paneTarget?: string): string | null { + capturePaneText(muxName: string, paneTarget?: string, opts?: { styles?: boolean }): string | null { if (IS_TEST_MODE) return null; const target = resolveTmuxPaneTarget(muxName, paneTarget); if (!target) return null; try { - return execSync(`${this.tmux()} capture-pane -p -t ${shellescape(target)}`, { + return execSync(`${this.tmux()} capture-pane -p${opts?.styles ? ' -e' : ''} -t ${shellescape(target)}`, { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS, }); diff --git a/src/web/public/constants.js b/src/web/public/constants.js index 4f9df935..4cb2f099 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -773,7 +773,7 @@ function formatKeepWarmStatus(status, now, statusLine) { if (PROMPT_CACHE_NOT_COLLECTED[statusLine]) return ''; if (!status || typeof status.state !== 'string') return ''; const clock = (ms) => new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); - const window = Number.isFinite(status.budgetEndsAt) + const keepWarmWindow = Number.isFinite(status.budgetEndsAt) ? status.budgetEndsAt > now ? `stops ${clock(status.budgetEndsAt)}` : 'window closed' @@ -794,13 +794,13 @@ function formatKeepWarmStatus(status, now, statusLine) { break; } case 'armed': - parts = [`next ${clock(status.nextCheckAt)}`, window, idleCompaction]; + parts = [`next ${clock(status.nextCheckAt)}`, keepWarmWindow, idleCompaction]; break; case 'waiting': - parts = ['waiting', status.reason || 'retrying', `retry ${clock(status.nextCheckAt)}`, window, idleCompaction]; + parts = ['waiting', status.reason || 'retrying', `retry ${clock(status.nextCheckAt)}`, keepWarmWindow, idleCompaction]; break; case 'nudged': - parts = [`sent ${clock(status.lastNudgeAt)} (${status.nudges}\u00d7)`, window, idleCompaction]; + parts = [`sent ${clock(status.lastNudgeAt)} (${status.nudges}\u00d7)`, keepWarmWindow, idleCompaction]; break; default: parts = ['stopped', status.reason || 'nothing to do', idleCompaction]; diff --git a/src/web/server.ts b/src/web/server.ts index ab55acba..4148bdda 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -119,7 +119,7 @@ import { AI_CHECK_MODEL } from '../config/ai-defaults.js'; import { approvalInbox } from './approval-inbox.js'; import { SCREEN_BLOCKED_SOURCE } from './screen-blocked.js'; import { PromptCacheKeepWarmService } from '../prompt-cache-keepwarm.js'; -import { composerIsEmpty } from '../session-submit-verifier.js'; +import { composerIsEmpty, plainFrame } from '../session-submit-verifier.js'; import { detectUsageLimitPause } from '../usage-limit-patterns.js'; import { stopDeepSeekWeb } from '../deepseek-web-server.js'; import { @@ -348,10 +348,11 @@ export class WebServer extends EventEmitter { readPane: (view) => { const session = view as Session; const muxName = session.muxName; - // `capture-pane -p`: the visible frame as plain text. - const text = muxName ? (this.mux.capturePaneText?.(muxName) ?? null) : null; + // `capture-pane -p -e`: the visible frame with its styles, which is how `composerIsEmpty()` + // tells Claude Code's faint prompt suggestion from a typed draft. + const text = muxName ? (this.mux.capturePaneText?.(muxName, undefined, { styles: true }) ?? null) : null; if (!text) return { composerEmpty: null, limitShown: false }; - const limit = detectUsageLimitPause(text); + const limit = detectUsageLimitPause(plainFrame(text)); return { composerEmpty: composerIsEmpty(text, getCli(session.mode)?.capabilities.workDetect?.promptGlyph ?? '') ?? null, limitShown: limit !== null && limit.resetAt > Date.now(), diff --git a/test/keep-warm-launch.test.ts b/test/keep-warm-launch.test.ts index 50b4c6f1..03eb819b 100644 --- a/test/keep-warm-launch.test.ts +++ b/test/keep-warm-launch.test.ts @@ -117,7 +117,8 @@ describe('the pane read behind the last two guards', () => { const at = port.indexOf('readPane: (view) => {'); expect(at).toBeGreaterThan(-1); const body = port.slice(at, port.indexOf('publish: (id, status) => {', at)); - expect(body).toContain('const limit = detectUsageLimitPause(text);'); + expect(body).toContain('this.mux.capturePaneText?.(muxName, undefined, { styles: true })'); + expect(body).toContain('const limit = detectUsageLimitPause(plainFrame(text));'); expect(body).toContain('limitShown: limit !== null && limit.resetAt > Date.now(),'); expect(body).toContain('composerEmpty: composerIsEmpty(text,'); }); diff --git a/test/prompt-cache-keepwarm.test.ts b/test/prompt-cache-keepwarm.test.ts index a4265114..4ad0f9e7 100644 --- a/test/prompt-cache-keepwarm.test.ts +++ b/test/prompt-cache-keepwarm.test.ts @@ -20,7 +20,8 @@ import { type KeepWarmPorts, type KeepWarmSessionView, } from '../src/prompt-cache-keepwarm.js'; -import { composerIsEmpty } from '../src/session-submit-verifier.js'; +import { composerIsEmpty, plainFrame } from '../src/session-submit-verifier.js'; +import { detectUsageLimitPause } from '../src/usage-limit-patterns.js'; import { isCapturablePrompt } from '../src/intent-store.js'; import { Session } from '../src/session.js'; import type { KeepWarmStatus, PromptCacheStatus } from '../src/types.js'; @@ -92,9 +93,7 @@ describe('decideKeepWarm', () => { ], ['a dead pane', { paneAlive: false }, 'no live pane'], ['a usage-limit pause', { limitPaused: true }, 'usage-limit'], - ['respawn, Ralph or an agent driving', { automationActive: true }, 'driving the session'], ['the cap after the last prompt', { budgetAnchorAt: NOW - 2 * HOUR }, 'window after the last prompt'], - ['an unreadable composer', { composerEmpty: null }, 'cannot read the composer'], ])('stops for %s', (_label, over, reason) => { const d = decideKeepWarm(dueFacts(over)); expect(d.action).toBe('stop'); @@ -119,6 +118,9 @@ describe('decideKeepWarm', () => { ['the session is working', { idle: false }, 'working'], ['a dialog is open', { pendingApproval: true }, 'dialog'], ['the composer holds a draft', { composerEmpty: false }, 'composer holds text'], + // Neither ends with a turn, so a stop here would leave an idle session with no trigger. + ['respawn, Ralph or an agent is driving (a long-poll waiter included)', { automationActive: true }, 'driving'], + ['one capture of the composer failed', { composerEmpty: null }, 'cannot read the composer'], ['someone typed a moment ago', { lastHumanInputAt: NOW - KEEP_WARM_RECENT_INPUT_MS + 1 }, 'typed'], ])('defers by a minute while %s', (_label, over, reason) => { const d = decideKeepWarm(dueFacts(over)); @@ -174,6 +176,50 @@ describe('composerIsEmpty', () => { it('gives no verdict without the bottom rule that bounds the composer', () => { expect(composerIsEmpty(`${glyph} \n unfinished draft`, glyph)).toBeUndefined(); }); + + /** + * `capture-pane -p -e` of one Claude Code 2.1.300 pane, byte for byte apart from the + * statusline row (dropped): the composer with its prompt suggestion, empty, holding a draft, + * holding the same draft with the cursor moved home, and holding a pasted-text chip. The suggestion's first letter sits + * in the reverse-video cursor cell and the rest is faint; a typed draft is neither. + */ + const RULE = `\x1b[38;5;244m${'─'.repeat(120)}`; + const FOOTER = ' \x1b[38;5;220m⏵⏵ auto mode on\x1b[38;5;246m (shift+tab to cycle)\x1b[39m'; + const frame = (composer: string) => + ['\x1b[38;5;246m✻ Cooked for 2s · done 6:56 PM\x1b[39m', '', RULE, composer, RULE, FOOTER].join('\n'); + const SUGGESTION = '\x1b[39m❯\u00a0\x1b[7mw\x1b[0;2m\x1b[39m\x1b[49mrite tests for add\x1b[0m\x1b[39m\x1b[49m'; + const EMPTY = '\x1b[39m❯\u00a0\x1b[7m \x1b[0m\x1b[39m\x1b[49m'; + const DRAFT = '\x1b[39m❯\u00a0half a draft\x1b[7m \x1b[0m\x1b[39m\x1b[49m'; + const DRAFT_CURSOR_HOME = '\x1b[39m❯\u00a0\x1b[7mh\x1b[0m\x1b[39m\x1b[49malf a draft'; + // A 40-line paste collapses to a chip, drawn plain like typed text. + const PASTED_CHIP = '\x1b[39m❯\u00a0[Pasted text #1 +40 lines]\x1b[7m \x1b[0m\x1b[39m\x1b[49m'; + + it("reads Claude Code's prompt suggestion as empty and a typed draft as text, from a real styled capture", () => { + expect(composerIsEmpty(frame(SUGGESTION), glyph)).toBe(true); + expect(composerIsEmpty(frame(EMPTY), glyph)).toBe(true); + expect(composerIsEmpty(frame(DRAFT), glyph)).toBe(false); + // The cursor cell holds the draft's first letter, but no faint text follows it. + expect(composerIsEmpty(frame(DRAFT_CURSOR_HOME), glyph)).toBe(false); + expect(composerIsEmpty(frame(PASTED_CHIP), glyph)).toBe(false); + }); + + it('reads a suggestion drawn without a cursor cell as empty, and a plain capture of it as text', () => { + expect(composerIsEmpty(frame('❯\u00a0\x1b[2mwrite tests for add\x1b[0m'), glyph)).toBe(true); + expect(composerIsEmpty(frame('❯\u00a0\x1b[2mwrite \x1b[22mtyped\x1b[0m'), glyph)).toBe(false); + // Without the styles nothing tells the two apart, which is why the pane is read with -e. + expect(composerIsEmpty(frame('❯\u00a0write tests for add'), glyph)).toBe(false); + }); + + it("reads text styled with tmux's colon sub-parameters, which stripAnsi leaves behind", () => { + const limit = "You've hit your \x1b[4:3mlimit\x1b[0m · resets 1:40pm (UTC)"; + expect(detectUsageLimitPause(plainFrame(limit), NOW)).not.toBeNull(); + expect(composerIsEmpty(frame('\x1b[4:3m❯\x1b[0m\u00a0'), glyph)).toBe(true); + }); + + it("never takes an extended colour's arguments for the faint or reverse attributes", () => { + expect(composerIsEmpty(frame('❯\u00a0\x1b[38;2;2;7;2mtyped in truecolour\x1b[0m'), glyph)).toBe(false); + expect(composerIsEmpty(frame('❯\u00a0\x1b[38;5;2mtyped in a 256 colour\x1b[0m'), glyph)).toBe(false); + }); }); describe('Read My Mind ignores the nudge', () => { @@ -257,6 +303,12 @@ describe('the two submit clocks on a Session', () => { expect(fresh.lastRealPromptAt).toBe(0); expect(fresh.toState().lastRealPromptAt).toBeUndefined(); }); + + it('seeds the real-prompt clock from lastSubmitAt for a state.json written before it existed', () => { + expect(new Session({ workingDir: '/tmp', lastSubmitAt: NOW - HOUR }).lastRealPromptAt).toBe(NOW - HOUR); + const both = new Session({ workingDir: '/tmp', lastSubmitAt: NOW, lastRealPromptAt: NOW - HOUR }); + expect(both.lastRealPromptAt).toBe(NOW - HOUR); + }); }); // ─── Service ───────────────────────────────────────────────────────────────── @@ -593,6 +645,110 @@ describe('PromptCacheKeepWarmService', () => { service.stop(); }); + it('publishes no window while off, so a real prompt on a session with keep-warm off broadcasts nothing', async () => { + const session = new FakeSession(); + session.keepWarmEnabled = false; + session.promptCache = warmCache(NOW + HOUR); + const { ports, published } = makePorts(session); + const service = new PromptCacheKeepWarmService(ports); + await service.schedule(session.id); + session.lastRealPromptAt = NOW; + await service.schedule(session.id); + expect(published[0]).toMatchObject({ enabled: false, state: 'off' }); + expect(published[0]).not.toHaveProperty('budgetEndsAt'); + expect(published[0]).not.toHaveProperty('nextCheckAt'); + expect(JSON.stringify(published[1])).toBe(JSON.stringify(published[0])); + service.stop(); + }); + + it('retries a minute later when an evaluation throws, since an idle session has no other trigger', async () => { + const session = new FakeSession(); + session.keepWarmEnabled = true; + session.promptCache = warmCache(NOW + KEEP_WARM_LEAD_MS - 1000); + let fail = true; + const { ports, last } = makePorts(session, { + readSettings: async () => { + if (fail) throw new Error('settings.json mid-rewrite'); + return { keepWarmDefault: false, maxHours: 2 }; + }, + }); + const service = new PromptCacheKeepWarmService(ports); + await service.schedule(session.id); + expect(session.writes).toHaveLength(0); + expect(vi.getTimerCount()).toBe(1); + fail = false; + await vi.advanceTimersByTimeAsync(KEEP_WARM_RETRY_MS); + await settle(); + expect(session.writes).toEqual([`${KEEP_WARM_NUDGE}\r`]); + expect(last()).toMatchObject({ state: 'nudged' }); + service.stop(); + }); + + it('runs one follow-up for triggers that land during an evaluation, and resolves after it', async () => { + const session = new FakeSession(); + session.keepWarmEnabled = true; + session.promptCache = warmCache(NOW + KEEP_WARM_LEAD_MS - 1000); + let releaseWrite!: (ok: boolean) => void; + session.writeViaMux = async (data: string) => { + session.writes.push(data); + return new Promise((r) => (releaseWrite = r)); + }; + const { ports, published } = makePorts(session); + const service = new PromptCacheKeepWarmService(ports); + const first = service.schedule(session.id); + await settle(); + expect(session.writes).toHaveLength(1); + // Switched off while the nudge is being written: the toggle's own evaluation must not be lost. + session.keepWarmEnabled = false; + const toggle = service.schedule(session.id); + const settingsChange = service.schedule(session.id); + expect(settingsChange).toBe(toggle); + releaseWrite(true); + await first; + await toggle; + expect(published.map((s) => s.state)).toEqual(['nudged', 'off']); + service.stop(); + }); + + it('remove() drops a follow-up queued behind an evaluation, and a failed read after it arms nothing', async () => { + const session = new FakeSession(); + session.keepWarmEnabled = true; + session.promptCache = warmCache(NOW + KEEP_WARM_LEAD_MS - 1000); + let release!: () => void; + let fail = false; + const { ports, published } = makePorts(session, { + readSettings: async () => { + await new Promise((r) => (release = r)); + if (fail) throw new Error('settings.json mid-rewrite'); + return { keepWarmDefault: false, maxHours: 2 }; + }, + }); + const service = new PromptCacheKeepWarmService(ports); + // The session stays discoverable while it is being deleted, so only remove() can stop these. + const first = service.schedule(session.id); + const followUp = service.schedule(session.id); + service.remove(session.id); + release(); + await first; + await followUp; + await settle(); + expect(session.writes).toHaveLength(0); + expect(published).toHaveLength(0); + expect(vi.getTimerCount()).toBe(0); + + // rescheduleAll() drops the kept settings, so this read really runs, and fails. + fail = true; + const logged: string[] = []; + ports.log = (m) => logged.push(m); + service.rescheduleAll(); + service.remove(session.id); + release(); + await settle(); + expect(logged).toEqual([expect.stringContaining('mid-rewrite')]); + expect(vi.getTimerCount()).toBe(0); + service.stop(); + }); + it('remove() forgets the nudge count, so an id seen again starts from zero', async () => { const session = new FakeSession(); session.keepWarmEnabled = true;