From 5db6c633d34ce0adaca15a778b6e29014fb8fa92 Mon Sep 17 00:00:00 2001 From: JD Date: Mon, 19 Oct 2026 01:42:00 -0400 Subject: [PATCH] fix(sessions): leave a pending idle alert to the user through a keep-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. --- CLAUDE.md | 2 +- docs/api-reference.md | 2 +- docs/architecture-invariants.md | 2 +- docs/wiki/Keeping-Agents-Running.md | 4 +- src/hooks-config.ts | 30 +++++- src/prompt-cache-keepwarm.ts | 32 ++++++- src/session.ts | 22 ++++- src/web/public/app.js | 11 ++- src/web/public/constants.js | 16 +++- src/web/public/settings-ui.js | 7 +- src/web/routes/hook-event-routes.ts | 14 ++- src/web/routes/system-routes.ts | 4 +- src/web/schemas.ts | 6 +- src/web/server.ts | 4 +- src/web/session-listener-wiring.ts | 11 ++- test/keep-alive-page-alerts.test.ts | 126 ++++++++++++++++++++++++++ test/keep-warm-launch.test.ts | 18 +++- test/mocks/mock-session.ts | 3 + test/prompt-cache-keepwarm.test.ts | 58 +++++++++++- test/prompt-cache-status.test.ts | 9 ++ test/routes/hook-event-routes.test.ts | 69 ++++++++++++++ test/session-listener-wiring.test.ts | 32 +++++++ 22 files changed, 441 insertions(+), 41 deletions(-) create mode 100644 test/keep-alive-page-alerts.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 51ba4dec..829554ae 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; a `fromUser` write, or a `prompt_submitted` hook whose prompt is not the nudge, ends that window. 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; 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 77b5a8ae..00fc4ef6 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. +`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`. ## Webhook notifications diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 98273ed1..6ed205ae 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. Any `fromUser` write closes the window at once, and so does 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, and 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()`), `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`, and the keep-alive case in `test/routes/hook-event-routes.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: `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`. ### Cron jobs diff --git a/docs/wiki/Keeping-Agents-Running.md b/docs/wiki/Keeping-Agents-Running.md index 7150e7f6..18855654 100644 --- a/docs/wiki/Keeping-Agents-Running.md +++ b/docs/wiki/Keeping-Agents-Running.md @@ -113,9 +113,9 @@ Claude Code 2.1.251 and newer reports the cache state on the statusline after ev 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. -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. Sessions with keep-warm off launch exactly as before. +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. -**Keep prompt caches warm** in the same Automation group is the default for sessions that have not chosen on their Respawn tab; the per-session switch always wins. A session another agent started (a worker) does not follow the default: most workers are never prompted again once they report done, so turn it on for one by hand if you will come back to it. The keep-alive turn is Codeman's, not yours: it does not restart the **Keep warm for at most** window, does not rename a tab, Read My Mind ignores it, and the response it ends with raises no alert, toast, push or webhook. It is refused on the five-minute cache lifetime, which is what an API key, a cloud provider or extra-usage credits get; a session on an API key can ask Claude Code for the hour with `CLAUDE_CODE_PROMPT_CACHE_TTL=1h` in its environment overrides. +**Keep prompt caches warm** in the same Automation group is the default for sessions that have not chosen on their Respawn tab; the per-session switch always wins. A session another agent started (a worker) does not follow the default: most workers are never prompted again once they report done, so turn it on for one by hand if you will come back to it. The keep-alive turn is Codeman's, not yours: it does not restart the **Keep warm for at most** window, does not rename a tab, Read My Mind ignores it, and the response it ends with raises no alert, toast, push or webhook. It never clears an alert that was already waiting for you. It is refused on the five-minute cache lifetime, which is what an API key, a cloud provider or extra-usage credits get; a session on an API key can ask Claude Code for the hour with `CLAUDE_CODE_PROMPT_CACHE_TTL=1h` in its environment overrides. ## The plan usage chip diff --git a/src/hooks-config.ts b/src/hooks-config.ts index ca71dc7f..f1bd7ebc 100644 --- a/src/hooks-config.ts +++ b/src/hooks-config.ts @@ -1148,14 +1148,34 @@ export async function readPinCliSessionNameEnabled(): Promise { /** * The prompt-cache keep-warm's app-wide default (`promptCacheKeepWarm`, absent = OFF) and its * cap (`promptCacheKeepWarmMaxHours`, hours after the last real prompt). Read FRESH like - * {@link readPinCliSessionNameEnabled}: by the service on every evaluation, and at every claude - * create and respawn, which decides the launch's `idleCompaction` opt-out. + * {@link readPinCliSessionNameEnabled} at every claude create and respawn, which decides the + * launch's `idleCompaction` opt-out. */ export async function readKeepWarmSettings(): Promise { - const settings = await readJsonConfig>(SETTINGS_PATH, 'settings.json', {}); - const hours = settings.promptCacheKeepWarmMaxHours; + return keepWarmSettingsFrom(await readJsonConfig>(SETTINGS_PATH, 'settings.json', {})); +} + +/** + * The same two keys for the keep-warm service, which keeps what it reads until the next + * settings change. A missing file is the defaults, but any other read or parse failure rejects + * rather than falling back: settings.json is rewritten in place, so a read can land mid-write, + * and a fallback kept from it would hold keep-warm off until the next keep-warm settings change. + */ +export async function readKeepWarmSettingsOrThrow(): Promise { + let text: string; + try { + text = await readFile(SETTINGS_PATH, 'utf-8'); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return keepWarmSettingsFrom({}); + throw err; + } + return keepWarmSettingsFrom(JSON.parse(text) as Record); +} + +function keepWarmSettingsFrom(settings: Record): KeepWarmSettings { + const hours = settings?.promptCacheKeepWarmMaxHours; return { - keepWarmDefault: settings.promptCacheKeepWarm === true, + keepWarmDefault: settings?.promptCacheKeepWarm === true, maxHours: typeof hours === 'number' && Number.isFinite(hours) && hours > 0 ? hours : DEFAULT_KEEP_WARM_MAX_HOURS, }; } diff --git a/src/prompt-cache-keepwarm.ts b/src/prompt-cache-keepwarm.ts index fb16aaaa..7fc67450 100644 --- a/src/prompt-cache-keepwarm.ts +++ b/src/prompt-cache-keepwarm.ts @@ -45,8 +45,13 @@ export const KEEP_WARM_LAST_CHANCE_MS = 60_000; export const KEEP_WARM_MIN_TOKENS = 20_000; /** A person typed this recently: they may be about to submit, which refreshes the cache for free. */ export const KEEP_WARM_RECENT_INPUT_MS = 3 * 60_000; -/** How long after sending the nudge its own stop hook and idle prompt stay pre-acknowledged. */ -export const KEEP_WARM_TURN_WINDOW_MS = 3 * 60_000; +/** + * Ceiling on the keep-alive window. The window normally closes on the turn's own idle prompt + * (Claude Code sends it about a minute after the turn ends, so a slow turn must not outlast a + * short fixed window) or earlier, at any other submit; the ceiling covers a turn whose idle + * prompt never arrives, such as a workspace without Codeman's hooks. + */ +export const KEEP_WARM_TURN_WINDOW_MS = 10 * 60_000; /** Default cap: hours after the last real prompt during which nudges are sent. */ export const DEFAULT_KEEP_WARM_MAX_HOURS = 2; /** Longest delay `setTimeout` honours; Node fires anything longer after 1 ms. */ @@ -197,7 +202,7 @@ export interface KeepWarmPorts { export interface KeepWarmScheduler { /** Re-evaluate a session: after a cache report, a flag change, or a settings change. Resolves once the status is published. */ schedule(sessionId: string): Promise; - /** Re-evaluate every session (the app-wide default or cap changed). */ + /** Re-read the app-wide default and cap, then re-evaluate every session (either one changed). */ rescheduleAll(): void; /** The session is gone. */ remove(sessionId: string): void; @@ -208,6 +213,12 @@ 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 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. + * `rescheduleAll()` drops it, and the settings PUT calls that whenever either key changes. + */ + private settings: Promise | undefined; private stopped = false; constructor(private readonly ports: KeepWarmPorts) {} @@ -220,6 +231,7 @@ export class PromptCacheKeepWarmService implements KeepWarmScheduler { } rescheduleAll(): void { + this.settings = undefined; for (const id of this.ports.sessionIds()) void this.schedule(id); } @@ -234,6 +246,18 @@ export class PromptCacheKeepWarmService implements KeepWarmScheduler { for (const id of [...this.timers.keys()]) this.clearTimer(id); } + private readSettings(): Promise { + if (!this.settings) { + const read = this.ports.readSettings(); + this.settings = read; + // A failed read is not kept: the next evaluation tries again. + read.catch(() => { + if (this.settings === read) this.settings = undefined; + }); + } + return this.settings; + } + private clearTimer(id: string): void { const t = this.timers.get(id); if (t) clearTimeout(t); @@ -258,7 +282,7 @@ export class PromptCacheKeepWarmService implements KeepWarmScheduler { this.clearTimer(id); const session = this.ports.getSession(id); if (!session) return; - const settings = await this.ports.readSettings(); + const settings = await this.readSettings(); if (cancelled()) return; const tally = this.tallies.get(id) ?? { nudges: 0 }; const facts: KeepWarmFacts = { diff --git a/src/session.ts b/src/session.ts index a9e1fc63..e8ac08b1 100644 --- a/src/session.ts +++ b/src/session.ts @@ -456,6 +456,15 @@ export interface ClaudeMessage { duration_ms?: number; } +/** + * Whether a programmatic write leaves the keep-alive turn running: the nudge itself, or a bare + * Enter (which submits nothing new). Anything else carrying an Enter starts a turn of its own. + */ +function isKeepAliveWrite(data: string): boolean { + const text = data.replace(/[\r\n]/g, '').trim(); + return text === '' || text === KEEP_WARM_NUDGE; +} + /** * Event signatures emitted by the Session class. * Subscribe using `session.on('eventName', handler)`. @@ -1562,8 +1571,10 @@ export class Session extends EventEmitter { /** * True while the running turn is Codeman's own keep-alive nudge: its stop hook and idle * prompt open pre-acknowledged, and its submit moves `lastSubmitAt` (it IS a submit, and the - * response viewer's conversation correlation needs it) but not `lastRealPromptAt`. Any - * `fromUser` write ends it early, so a person's prompt right after a nudge counts and alerts normally. + * response viewer's conversation correlation needs it) but not `lastRealPromptAt`. The turn's + * own idle prompt ends it (`endKeepAliveTurn()`), and so does any other submit: a `fromUser` + * write, a programmatic write of anything but the nudge, or a `prompt_submitted` hook for + * another prompt, so a prompt right after a nudge counts and alerts normally. */ get keepAliveTurnActive(): boolean { return Date.now() < this._keepAliveTurnUntil; @@ -1573,6 +1584,10 @@ export class Session extends EventEmitter { this._keepAliveTurnUntil = Date.now() + windowMs; } + endKeepAliveTurn(): void { + this._keepAliveTurnUntil = 0; + } + /** Last keystroke a person sent (`fromUser` write), epoch ms; 0 when none this process. */ get lastHumanInputAt(): number { return this._lastHumanInputAt; @@ -4741,6 +4756,9 @@ export class Session extends EventEmitter { this._lastHumanInputAt = Date.now(); // A person is here: whatever turn follows is theirs, not the keep-alive's. this._keepAliveTurnUntil = 0; + } else if (hasEnter && !isKeepAliveWrite(data)) { + // Anything else submitted (an agent, respawn, auto-resume) starts a turn of its own. + this._keepAliveTurnUntil = 0; } if (hasEnter) this._stampSubmit(); return submitted; diff --git a/src/web/public/app.js b/src/web/public/app.js index 153f71bf..16844f13 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -3470,8 +3470,10 @@ class CodemanApp { this.sendPendingCtrlL(data.id); if (data.id === this.activeSessionId) this._updateLocalEchoState(); } - // Start stuck detection timer (only if no respawn running) - if (!this.respawnStatus[data.id]?.enabled) { + // Start stuck detection timer (only if no respawn running). Not at the end of Codeman's + // own keep-alive turn (`keepAlive`): the session has been waiting since the last real + // turn, whose timer is still running or has already fired. + if (!data.keepAlive && !this.respawnStatus[data.id]?.enabled) { const threshold = this.notificationManager?.preferences?.stuckThresholdMs || 600000; clearTimeout(this.idleTimers.get(data.id)); this.idleTimers.set(data.id, setTimeout(() => { @@ -3493,8 +3495,9 @@ class CodemanApp { this.sendPendingCtrlL(data.id); if (data.id === this.activeSessionId) this._updateLocalEchoState(); } - // Clear stuck detection timer - const timer = this.idleTimers.get(data.id); + // Clear stuck detection timer, unless this is Codeman's own keep-alive turn, which is + // no sign of life from the agent's side. + const timer = data.keepAlive ? null : this.idleTimers.get(data.id); if (timer) { clearTimeout(timer); this.idleTimers.delete(data.id); diff --git a/src/web/public/constants.js b/src/web/public/constants.js index 611f7ec5..4f9df935 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -778,15 +778,21 @@ function formatKeepWarmStatus(status, now, statusLine) { ? `stops ${clock(status.budgetEndsAt)}` : 'window closed' : ''; - // Keep-warm switched on after launch: the `idleCompaction` opt-out rides only a launch. + // Keep-warm switched on or off after launch: the `idleCompaction` opt-out rides only a launch. const idleCompaction = - status.enabled && status.idleCompactionOff === false ? 'idle compaction on until relaunch' : ''; + status.enabled && status.idleCompactionOff === false + ? 'idle compaction on until relaunch' + : !status.enabled && status.idleCompactionOff === true + ? 'idle compaction off until relaunch' + : ''; let parts; switch (status.state) { - case 'off': - if (!status.reason || status.reason === 'off') return ''; - parts = ['off', status.reason]; + case 'off': { + const reason = status.reason === 'off' ? '' : status.reason; + if (!reason && !idleCompaction) return ''; + parts = ['off', reason, idleCompaction]; break; + } case 'armed': parts = [`next ${clock(status.nextCheckAt)}`, window, idleCompaction]; break; diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index d876a6e4..926ea984 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -72,6 +72,10 @@ Object.assign(CodemanApp.prototype, { }, _onHookStop(data) { + // The end of Codeman's own keep-alive turn (prompt-cache keep-warm): nothing finished + // that a person asked for, and an idle alert still pending is theirs to spend, so this + // neither clears it nor raises "Response Complete". + if (data.acknowledgedReason) return; // Clear all pending hooks when Claude finishes responding if (data.sessionId) { this.clearPendingHooks(data.sessionId); @@ -2765,7 +2769,8 @@ Object.assign(CodemanApp.prototype, { imageWatcherEnabled: document.getElementById('appSettingsImageWatcherEnabled').checked, promptCacheKeepWarm: document.getElementById('appSettingsPromptCacheKeepWarm').checked, promptCacheKeepWarmMaxHours: - parseFloat(document.getElementById('appSettingsPromptCacheKeepWarmMaxHours').value) || 2, + // Clamped to the schema's range: one value outside it fails the whole save. + Math.min(24, Math.max(0.5, parseFloat(document.getElementById('appSettingsPromptCacheKeepWarmMaxHours').value) || 2)), tunnelEnabled: document.getElementById('appSettingsTunnelEnabled').checked, localEchoEnabled: document.getElementById('appSettingsLocalEcho').checked, autoCopySelection: document.getElementById('appSettingsAutoCopySelection').checked, diff --git a/src/web/routes/hook-event-routes.ts b/src/web/routes/hook-event-routes.ts index cf9ee4f2..4e17a76b 100644 --- a/src/web/routes/hook-event-routes.ts +++ b/src/web/routes/hook-event-routes.ts @@ -190,8 +190,15 @@ export function registerHookEventRoutes( const keepAliveReason = session?.keepAliveTurnActive && (event === 'stop' || event === 'idle_prompt') ? 'cache keep-alive' : undefined; const approvalKind = APPROVAL_KIND_BY_EVENT[event]; + // Only a human spends an idle alert, so a keep-alive turn leaves a pending idle item exactly + // as it found it: its stop does not resolve it, and its idle prompt does not replace it (a + // replacement would forget the item was unseen, or that a person had seen it). With no idle + // item pending, the keep-alive's idle prompt opens a pre-acknowledged one. + const keepsPendingIdle = keepAliveReason !== undefined && approvalInbox.getForSession(sessionId)?.kind === 'idle'; if (session && hooksAvailableForMode(session.mode, sessionHookOptions(session))) { - if (approvalKind) { + if (approvalKind === 'idle' && keepsPendingIdle) { + // Left in place: no new item, and the broadcast below carries the keep-alive reason. + } else if (approvalKind) { const toolInput = safeData.tool_input && typeof safeData.tool_input === 'object' ? (safeData.tool_input as Record) @@ -226,12 +233,15 @@ export function registerHookEventRoutes( }); approvalId = item.id; acknowledgedReason = item.acknowledgedReason; - } else if (APPROVAL_RESOLVING_EVENTS.has(event)) { + } else if (APPROVAL_RESOLVING_EVENTS.has(event) && !keepsPendingIdle) { approvalInbox.resolveForSession(sessionId, 'resolved_in_terminal'); } } acknowledgedReason ??= keepAliveReason; + // The keep-alive turn's own idle prompt is the last thing it sends, so the window ends here + // rather than on a fixed clock that a slow turn could outlast. + if (keepAliveReason && event === 'idle_prompt') session?.endKeepAliveTurn(); ctx.broadcast(`hook:${event}`, { sessionId, diff --git a/src/web/routes/system-routes.ts b/src/web/routes/system-routes.ts index fd9c19d2..0359a8c9 100644 --- a/src/web/routes/system-routes.ts +++ b/src/web/routes/system-routes.ts @@ -1111,8 +1111,8 @@ export function registerSystemRoutes( // drift. Same convention as the tmuxHistoryLimit block above. // Handle subagent tracking toggle dynamically toggleService((merged.subagentTrackingEnabled as boolean) ?? true, subagentWatcher, 'Subagent watcher'); - // The prompt-cache keep-warm reads its default and cap fresh on every decision; a changed - // default or cap re-evaluates every session now rather than at its next cache report. + // The prompt-cache keep-warm keeps its default and cap in memory; a changed default or cap + // makes it re-read them and re-evaluate every session now rather than at its next cache report. if ('promptCacheKeepWarm' in settingsToStore || 'promptCacheKeepWarmMaxHours' in settingsToStore) { ctx.keepWarm.rescheduleAll(); } diff --git a/src/web/schemas.ts b/src/web/schemas.ts index becbeab9..5cd3503e 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -1871,13 +1871,13 @@ export const AutoCompactSchema = z.object({ }); /** POST /api/sessions/:id/auto-resume */ -/** `POST /api/sessions/:id/keep-warm`: `null` clears the session's choice so the app-wide default applies. */ -export const KeepWarmSchema = z.object({ enabled: z.boolean().nullable() }); - export const AutoResumeSchema = z.object({ enabled: z.boolean(), }); +/** `POST /api/sessions/:id/keep-warm`: `null` clears the session's choice so the app-wide default applies. */ +export const KeepWarmSchema = z.object({ enabled: z.boolean().nullable() }); + /** POST /api/sessions/:id/pin (COD-139) — explicit pin state for idempotency. */ export const PinSessionSchema = z.object({ pinned: z.boolean(), diff --git a/src/web/server.ts b/src/web/server.ts index 910305e7..ab55acba 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -99,7 +99,7 @@ import { getLifecycleLog } from '../session-lifecycle-log.js'; import { applyWorkspaceHooks, pruneAgentSessionPreambles, - readKeepWarmSettings, + readKeepWarmSettingsOrThrow, removeAgentSessionPreamble, } from '../hooks-config.js'; import { PushSubscriptionStore } from '../push-store.js'; @@ -332,7 +332,7 @@ export class WebServer extends EventEmitter { private readonly keepWarmService = new PromptCacheKeepWarmService({ getSession: (id) => this.sessions.get(id), sessionIds: () => this.sessions.keys(), - readSettings: readKeepWarmSettings, + readSettings: readKeepWarmSettingsOrThrow, isAutomationActive: (id) => { const session = this.sessions.get(id); if (!session) return true; diff --git a/src/web/session-listener-wiring.ts b/src/web/session-listener-wiring.ts index a1ce31f4..93d29898 100644 --- a/src/web/session-listener-wiring.ts +++ b/src/web/session-listener-wiring.ts @@ -240,7 +240,8 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe // kind: `working` is heuristic and can flap mid-turn, so clearing a // pending permission/question dialog on the signal ALONE would // false-clear real approvals. - approvalInbox.resolveForSession(session.id, 'resolved_in_terminal', ['idle']); + // Not for Codeman's own keep-alive turn: only a human spends an idle alert. + if (!session.keepAliveTurnActive) approvalInbox.resolveForSession(session.id, 'resolved_in_terminal', ['idle']); // A permission/question dialog gets the pane-VERIFIED variant instead: // the signal only decides when to look, `verifyStillAnswerable` re-reads // the screen and resolves only when the dialog is really gone. Without @@ -250,7 +251,11 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe // open. `stop` was the first thing to clear it, which on a long turn is // minutes away. approvalInbox.resolveIfDialogGone(session.id); - deps.broadcast(SseEvent.SessionWorking, { id: session.id }); + // `keepAlive` tells the page this turn is Codeman's own keep-alive (stuck detection ignores it). + deps.broadcast(SseEvent.SessionWorking, { + id: session.id, + ...(session.keepAliveTurnActive && { keepAlive: true }), + }); // Full state ride-along: the home screens sort the running group on // lastSubmitAt, and without this the browser keeps the stamp it loaded // with (a turn started after page load ranks by the PREVIOUS turn's @@ -266,7 +271,7 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe /** Broadcasts `session:idle` — Claude finished processing, waiting for input */ idle: () => { sessionWaits.notifySignal(session.id, 'idle'); - deps.broadcast(SseEvent.SessionIdle, { id: session.id }); + deps.broadcast(SseEvent.SessionIdle, { id: session.id, ...(session.keepAliveTurnActive && { keepAlive: true }) }); deps.broadcastSessionStateDebounced(session.id); deps.keepWarmSchedule?.(session.id); const tracker = deps.getRunSummaryTracker(session.id); diff --git a/test/keep-alive-page-alerts.test.ts b/test/keep-alive-page-alerts.test.ts new file mode 100644 index 00000000..3531bf86 --- /dev/null +++ b/test/keep-alive-page-alerts.test.ts @@ -0,0 +1,126 @@ +// Port: none (handlers sliced out of the shipped frontend files and run against stubs). +// +// The prompt-cache keep-alive turn (prompt-cache-keepwarm.ts) is Codeman's, not the user's, so +// on the page it must spend nothing: an idle alert still pending stays (only a human spends +// one), no "Response Complete" is raised, and the stuck-detection timer neither restarts nor +// re-arms ("Session Idle 10+ minutes" would otherwise follow every nudge). The server marks the +// turn's `hook:stop` with `acknowledgedReason` and its `session:working`/`session:idle` with +// `keepAlive`; these tests run the real handlers on those payloads. +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const PUBLIC = resolve(import.meta.dirname, '../src/web/public'); +const SESSION = 'keep-alive-session'; +const THRESHOLD = 600_000; + +/** The body of a method as shipped, compiled with `data` as its one parameter. */ +function method(file: string, signature: string): (this: unknown, data: Record) => void { + const source = readFileSync(resolve(PUBLIC, file), 'utf8'); + const start = source.indexOf(` ${signature} {`); + expect(start).toBeGreaterThan(-1); + const bodyStart = source.indexOf('{', start) + 1; + const end = source.indexOf('\n }', bodyStart); + return new Function('data', source.slice(bodyStart, end)) as (data: Record) => void; +} + +function makeApp() { + const notifications: string[] = []; + const app = { + sessions: new Map([[SESSION, { status: 'idle' }]]), + activeSessionId: 'another-session', + respawnStatus: {}, + notificationManager: { preferences: { stuckThresholdMs: THRESHOLD } }, + idleTimers: new Map(), + pendingHooks: new Map>(), + tabAlerts: new Map(), + notifications, + renderSessionTabs() {}, + sendPendingCtrlL() {}, + _updateLocalEchoState() {}, + _notifySession: (_id: string, _level: string, kind: string) => notifications.push(kind), + clearPendingHooks(sessionId: string) { + app.pendingHooks.delete(sessionId); + app.tabAlerts.delete(sessionId); + }, + onIdle: method('app.js', '_onSessionIdle(data)'), + onWorking: method('app.js', '_onSessionWorking(data)'), + onStop: method('settings-ui.js', '_onHookStop(data)'), + }; + return app; +} + +describe('the keep-alive turn spends nothing on the page', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it('keeps the stuck timer of the last real turn: not cleared, not re-armed', () => { + const app = makeApp(); + app.onIdle({ id: SESSION }); + const realTimer = app.idleTimers.get(SESSION); + expect(realTimer).toBeDefined(); + vi.advanceTimersByTime(THRESHOLD / 2); + + app.onWorking({ id: SESSION, keepAlive: true }); + expect(app.idleTimers.get(SESSION)).toBe(realTimer); + app.onIdle({ id: SESSION, keepAlive: true }); + expect(app.idleTimers.get(SESSION)).toBe(realTimer); + + // Fires once, on the real turn's schedule, and nothing follows the keep-alive. + vi.advanceTimersByTime(THRESHOLD / 2); + expect(app.notifications).toEqual(['session-stuck']); + vi.advanceTimersByTime(THRESHOLD * 2); + expect(app.notifications).toEqual(['session-stuck']); + }); + + it('a real turn still restarts the stuck timer', () => { + const app = makeApp(); + app.onIdle({ id: SESSION }); + app.onWorking({ id: SESSION }); + expect(app.idleTimers.has(SESSION)).toBe(false); + app.onIdle({ id: SESSION }); + expect(app.idleTimers.has(SESSION)).toBe(true); + }); + + it("its stop leaves a pending idle alert and raises no 'Response Complete'", () => { + const app = makeApp(); + app.pendingHooks.set(SESSION, new Set(['idle_prompt'])); + app.tabAlerts.set(SESSION, 'idle'); + app.onStop({ sessionId: SESSION, acknowledgedReason: 'cache keep-alive' }); + expect([...(app.pendingHooks.get(SESSION) ?? [])]).toEqual(['idle_prompt']); + expect(app.tabAlerts.get(SESSION)).toBe('idle'); + // _onSessionWorking deletes the tab alert only with no pending hook, so it stays too. + app.onWorking({ id: SESSION, keepAlive: true }); + expect(app.tabAlerts.get(SESSION)).toBe('idle'); + expect(app.notifications).toEqual([]); + }); + + it('a real stop still clears and notifies', () => { + const app = makeApp(); + app.pendingHooks.set(SESSION, new Set(['idle_prompt'])); + app.onStop({ sessionId: SESSION }); + expect(app.pendingHooks.has(SESSION)).toBe(false); + expect(app.notifications).toEqual(['hook-stop']); + }); +}); + +/** + * `promptCacheKeepWarmMaxHours` is clamped to the settings schema's range (0.5 to 24) before the + * save: the schema is strict, so one value outside it would reject every other change in the body. + */ +describe('the keep-warm cap is clamped before the save', () => { + it.each([ + ['0.25', 0.5], + ['30', 24], + ['', 2], + ['abc', 2], + ['3.5', 3.5], + ])('%j saves as %d', (input, expected) => { + const source = readFileSync(resolve(PUBLIC, 'settings-ui.js'), 'utf8'); + const match = source.match(/promptCacheKeepWarmMaxHours:\s*\n?((?:\s*\/\/.*\n)*)\s*([^\n]+),\n/); + expect(match).not.toBeNull(); + const document = { getElementById: () => ({ value: input }) }; + const value = new Function('document', `return ${match![2]};`)(document); + expect(value).toBe(expected); + }); +}); diff --git a/test/keep-warm-launch.test.ts b/test/keep-warm-launch.test.ts index 61ced246..50b4c6f1 100644 --- a/test/keep-warm-launch.test.ts +++ b/test/keep-warm-launch.test.ts @@ -15,7 +15,7 @@ import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { describe, it, expect, afterEach } from 'vitest'; import { decideIdleCompactionOptOut } from '../src/tmux-manager.js'; -import { readKeepWarmSettings } from '../src/hooks-config.js'; +import { readKeepWarmSettings, readKeepWarmSettingsOrThrow } from '../src/hooks-config.js'; import { SETTINGS_PATH } from '../src/web/route-helpers.js'; import { DEFAULT_KEEP_WARM_MAX_HOURS } from '../src/prompt-cache-keepwarm.js'; @@ -155,4 +155,20 @@ describe('readKeepWarmSettings', () => { await writeFile(SETTINGS_PATH, JSON.stringify({ promptCacheKeepWarm: 'yes', promptCacheKeepWarmMaxHours: -1 })); expect(await readKeepWarmSettings()).toEqual({ keepWarmDefault: false, maxHours: 2 }); }); + + /** + * The service keeps what it reads until the next keep-warm settings change, so its reader must + * never hand back a fallback for a file it could not read: settings.json is rewritten in place + * by several routes, and defaults kept from a mid-write read would hold keep-warm off. + */ + it('the service reader rejects an unreadable file instead of falling back, and the server wires it', async () => { + expect(await readKeepWarmSettingsOrThrow()).toEqual({ keepWarmDefault: false, maxHours: 2 }); + await mkdir(dirname(SETTINGS_PATH), { recursive: true }); + await writeFile(SETTINGS_PATH, JSON.stringify({ promptCacheKeepWarm: true, promptCacheKeepWarmMaxHours: 3.5 })); + expect(await readKeepWarmSettingsOrThrow()).toEqual({ keepWarmDefault: true, maxHours: 3.5 }); + await writeFile(SETTINGS_PATH, '{"promptCacheKeepWarm": tr'); + await expect(readKeepWarmSettingsOrThrow()).rejects.toThrow(); + expect(await readKeepWarmSettings()).toEqual({ keepWarmDefault: false, maxHours: 2 }); + expect(src('web/server.ts')).toContain('readSettings: readKeepWarmSettingsOrThrow,'); + }); }); diff --git a/test/mocks/mock-session.ts b/test/mocks/mock-session.ts index f58b7447..665cffbe 100644 --- a/test/mocks/mock-session.ts +++ b/test/mocks/mock-session.ts @@ -56,6 +56,9 @@ export class MockSession extends EventEmitter { } /** Mirrors the prompt-cache keep-warm surface on Session (prompt-cache-keepwarm.ts). */ keepAliveTurnActive = false; + endKeepAliveTurn(): void { + this.keepAliveTurnActive = false; + } keepWarmEnabled: boolean | undefined = undefined; setKeepWarmEnabled(enabled: boolean | undefined): void { this.keepWarmEnabled = enabled; diff --git a/test/prompt-cache-keepwarm.test.ts b/test/prompt-cache-keepwarm.test.ts index 1364079a..a4265114 100644 --- a/test/prompt-cache-keepwarm.test.ts +++ b/test/prompt-cache-keepwarm.test.ts @@ -228,6 +228,28 @@ describe('the two submit clocks on a Session', () => { } }); + /** + * The window is a ceiling, not the turn's length: a slow keep-alive turn's idle prompt lands + * about a minute after it ends, so the window closes on that prompt (the route calls + * endKeepAliveTurn) or on any other submit, never by a short clock alone. + */ + it('the keep-alive window survives the nudge and a bare Enter, and ends on any other submit', () => { + const session = new Session({ workingDir: '/tmp' }); + session.beginKeepAliveTurn(KEEP_WARM_TURN_WINDOW_MS); + session.write(`${KEEP_WARM_NUDGE}\r`); + session.write('\r'); + expect(session.keepAliveTurnActive).toBe(true); + session.write('/compact\r'); + expect(session.keepAliveTurnActive).toBe(false); + + session.beginKeepAliveTurn(KEEP_WARM_TURN_WINDOW_MS); + session.write('typed but not sent'); + expect(session.keepAliveTurnActive).toBe(true); + session.endKeepAliveTurn(); + expect(session.keepAliveTurnActive).toBe(false); + expect(KEEP_WARM_TURN_WINDOW_MS).toBeGreaterThanOrEqual(10 * 60_000); + }); + it('persists the real-prompt clock and the choice, and restores both', () => { const session = new Session({ workingDir: '/tmp', lastRealPromptAt: NOW, keepWarmEnabled: true }); expect(session.toState()).toMatchObject({ lastRealPromptAt: NOW, keepWarmEnabled: true }); @@ -529,16 +551,48 @@ describe('PromptCacheKeepWarmService', () => { // Second evaluation: not due, held in its settings read. session.promptCache = warmCache(NOW + HOUR); releaseRead = () => {}; - const second = service.schedule(session.id); + // rescheduleAll() drops the kept settings, so this evaluation really waits in its read. + service.rescheduleAll(); await settle(); releaseWrite(true); await first; releaseRead(); - await second; + await settle(); expect(published).toEqual([expect.objectContaining({ state: 'armed' })]); service.stop(); }); + /** + * Every statusline report schedules an evaluation, so the settings are read once and kept + * rather than read from disk on each report, the feature off included; the settings PUT calls + * rescheduleAll() when either key changes, which is what makes the next read fresh. + */ + it('reads the settings once, again after rescheduleAll(), and again after a failed read', async () => { + const session = new FakeSession(); + session.promptCache = warmCache(NOW + HOUR); + let reads = 0; + let fail = false; + const { ports } = makePorts(session, { + readSettings: async () => { + reads += 1; + if (fail) throw new Error('unreadable'); + return { keepWarmDefault: false, maxHours: 2 }; + }, + }); + const service = new PromptCacheKeepWarmService(ports); + await service.schedule(session.id); + await service.schedule(session.id); + expect(reads).toBe(1); + fail = true; + service.rescheduleAll(); + await settle(); + expect(reads).toBe(2); + fail = false; + await service.schedule(session.id); + expect(reads).toBe(3); + service.stop(); + }); + it('remove() forgets the nudge count, so an id seen again starts from zero', async () => { const session = new FakeSession(); session.keepWarmEnabled = true; diff --git a/test/prompt-cache-status.test.ts b/test/prompt-cache-status.test.ts index b5f0d515..fb7a0105 100644 --- a/test/prompt-cache-status.test.ts +++ b/test/prompt-cache-status.test.ts @@ -172,6 +172,15 @@ describe('Keep-warm line on the Status row', () => { `Keep-alive: next ${clock(T)} · idle compaction on until relaunch` ); }); + + it('says idle compaction stays off when keep-warm was switched off on a launch that carries the opt-out', () => { + const { formatKeepWarm } = loadApi(); + const off = { enabled: false, state: 'off', reason: 'off', nudges: 0 }; + expect(formatKeepWarm({ ...off, idleCompactionOff: false }, NOW, 'injected')).toBe(''); + expect(formatKeepWarm({ ...off, idleCompactionOff: true }, NOW, 'injected')).toBe( + 'Keep-alive: off · idle compaction off until relaunch' + ); + }); }); /** diff --git a/test/routes/hook-event-routes.test.ts b/test/routes/hook-event-routes.test.ts index 79d18c0d..a6066475 100644 --- a/test/routes/hook-event-routes.test.ts +++ b/test/routes/hook-event-routes.test.ts @@ -127,6 +127,75 @@ describe('hook-event-routes', () => { expect(item?.acknowledgedAt).toBe(item?.createdAt); }); + /** + * Only a human spends an idle alert (CLAUDE.md, Approvals Inbox). A keep-alive turn that + * starts while an idle item is pending must leave that item exactly as it found it: its stop + * does not resolve it, and its idle prompt does not replace it, so an unseen alert stays + * unseen (and pushes nothing a second time) and one a person looked at keeps saying so. + */ + it.each([ + { seen: false, label: 'an unseen' }, + { seen: true, label: 'a seen' }, + ])('leaves $label idle item pending through a keep-alive turn', async ({ seen }) => { + const sessionId = harness.ctx._sessionId; + const hook = (event: string) => + harness.app.inject({ method: 'POST', url: '/api/hook-event', payload: { event, sessionId, data: null } }); + await hook('idle_prompt'); + const before = approvalInbox.getForSession(sessionId); + expect(before?.acknowledgedAt).toBeUndefined(); + if (seen) approvalInbox.acknowledge(sessionId); + const acknowledgedAt = before?.acknowledgedAt; + harness.ctx.sendPushNotifications.mockClear(); + harness.ctx.broadcast.mockClear(); + + harness.ctx._session.keepAliveTurnActive = true; + await hook('stop'); + await hook('idle_prompt'); + + const after = approvalInbox.getForSession(sessionId); + approvalInbox.resolveForSession(sessionId, 'dismissed'); + expect(after?.id).toBe(before?.id); + expect(after?.acknowledgedAt).toBe(acknowledgedAt); + expect(after?.acknowledgedReason).toBeUndefined(); + expect(harness.ctx.sendPushNotifications).not.toHaveBeenCalled(); + // The page's _onHookStop and _onHookIdlePrompt return on the reason, leaving the tab alert. + for (const event of ['stop', 'idle_prompt']) { + expect(harness.ctx.broadcast).toHaveBeenCalledWith( + `hook:${event}`, + expect.objectContaining({ acknowledgedReason: 'cache keep-alive' }) + ); + } + }); + + it("a keep-alive turn's idle prompt ends the keep-alive window, its stop does not", async () => { + const sessionId = harness.ctx._sessionId; + const hook = (event: string) => + harness.app.inject({ method: 'POST', url: '/api/hook-event', payload: { event, sessionId, data: null } }); + harness.ctx._session.keepAliveTurnActive = true; + await hook('stop'); + expect(harness.ctx._session.keepAliveTurnActive).toBe(true); + await hook('idle_prompt'); + approvalInbox.resolveForSession(sessionId, 'dismissed'); + expect(harness.ctx._session.keepAliveTurnActive).toBe(false); + }); + + it("a keep-alive turn's stop still resolves a pending permission dialog", async () => { + const sessionId = harness.ctx._sessionId; + await harness.app.inject({ + method: 'POST', + url: '/api/hook-event', + payload: { event: 'permission_prompt', sessionId, data: null }, + }); + expect(approvalInbox.getForSession(sessionId)?.kind).toBe('permission'); + harness.ctx._session.keepAliveTurnActive = true; + await harness.app.inject({ + method: 'POST', + url: '/api/hook-event', + payload: { event: 'stop', sessionId, data: null }, + }); + expect(approvalInbox.getForSession(sessionId)).toBeUndefined(); + }); + it('sends push notifications for hook events', async () => { const res = await harness.app.inject({ method: 'POST', diff --git a/test/session-listener-wiring.test.ts b/test/session-listener-wiring.test.ts index cc54ff00..e6b1a70f 100644 --- a/test/session-listener-wiring.test.ts +++ b/test/session-listener-wiring.test.ts @@ -6,6 +6,7 @@ import { detachSessionListeners, } from '../src/web/session-listener-wiring.js'; import { SseEvent } from '../src/web/sse-events.js'; +import { approvalInbox } from '../src/web/approval-inbox.js'; describe('session listener wiring', () => { it('forwards the attachment request source through registerAttachment', async () => { @@ -62,6 +63,37 @@ describe('session listener wiring', () => { expect(session.listenerCount('displayModelChanged')).toBe(0); }); + /** + * A working transition resolves a pending idle item (input arrived), except during Codeman's + * own keep-alive turn: only a human spends an idle alert. Both transitions of that turn are + * flagged `keepAlive`, which is what keeps the page's stuck-detection timer out of it. + */ + it.each([ + { keepAlive: false, resolved: true }, + { keepAlive: true, resolved: false }, + ])('working and idle with keepAlive=$keepAlive', ({ keepAlive, resolved }) => { + const session = new Session({ id: `wiring-keepalive-${keepAlive}`, workingDir: '/tmp', mode: 'claude' }); + const broadcast = vi.fn(); + const deps = { + broadcast, + broadcastSessionStateDebounced: vi.fn(), + getRunSummaryTracker: vi.fn(() => undefined), + } as unknown as Parameters[1]; + const refs = createSessionListeners(session, deps); + approvalInbox.notePrompt({ sessionId: session.id, sessionName: 'w1', kind: 'idle' }); + if (keepAlive) session.beginKeepAliveTurn(60_000); + + refs.working(); + refs.idle(); + + const pending = approvalInbox.getForSession(session.id); + approvalInbox.resolveForSession(session.id, 'dismissed'); + expect(pending === undefined).toBe(resolved); + const flag = keepAlive ? { keepAlive: true } : {}; + expect(broadcast).toHaveBeenCalledWith(SseEvent.SessionWorking, { id: session.id, ...flag }); + expect(broadcast).toHaveBeenCalledWith(SseEvent.SessionIdle, { id: session.id, ...flag }); + }); + /** The listener reads the setting asynchronously; let its promise chain settle. */ const flush = () => new Promise((resolve) => setTimeout(resolve, 5));