From 2a56b2a9977b90cc8f729dc4801fcdfce485aeba Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Thu, 22 Oct 2026 19:10:48 +0200 Subject: [PATCH] fix(hooks): keep a keep-alive turn's hooks out of the run summary Merge-time fix for #666. `recordHookEvent()` ran for every `stop`, the run summary files a `stop` as a warning, and the Away digest lists warnings under Needs Attention, so each keep-alive nudge added a row there and to the session's Summary timeline. The hook route now skips the run summary while `keepAliveReason` is set (the keep-alive turn's `stop` and `idle_prompt`). Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/api-reference.md | 2 +- docs/architecture-invariants.md | 2 +- docs/wiki/Keeping-Agents-Running.md | 2 +- src/web/routes/hook-event-routes.ts | 6 ++++-- test/routes/hook-event-routes.test.ts | 30 +++++++++++++++++++++++++++ 5 files changed, 37 insertions(+), 5 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index 81fecd30..e6d05ff6 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; `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`. +`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"`, no push is sent for them and the run summary does not record 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 bc585b7b..1ddfa8fa 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: 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, and so does a nudge whose write fails, which also closes the keep-alive window at once (`endKeepAliveTurn()`, since no turn is running; a check a whole `KEEP_WARM_LEAD_MS` later would land past the expiry and stop), an evaluation that throws re-arms the same retry (a failed settings read only for a session that could nudge: its own switch not off, the exporter `injected`, a warm report; a settings.json that stays broken is logged once, not once a minute per session, until a read succeeds), 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`. +**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, and so does a nudge whose write fails, which also closes the keep-alive window at once (`endKeepAliveTurn()`, since no turn is running; a check a whole `KEEP_WARM_LEAD_MS` later would land past the expiry and stop), an evaluation that throws re-arms the same retry (a failed settings read only for a session that could nudge: its own switch not off, the exporter `injected`, a warm report; a settings.json that stays broken is logged once, not once a minute per session, until a read succeeds), 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, and neither is recorded in the run summary (`recordHookEvent()` files a `stop` as a warning, and the Away digest lists warnings under Needs Attention). ⚠️ 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 905f215f..35f81870 100644 --- a/docs/wiki/Keeping-Agents-Running.md +++ b/docs/wiki/Keeping-Agents-Running.md @@ -115,7 +115,7 @@ It only fires when there is nothing else going on: the session is idle with an e 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 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. +**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, nor a row in the Away digest or the session's Summary. 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/web/routes/hook-event-routes.ts b/src/web/routes/hook-event-routes.ts index 4e17a76b..e05d0448 100644 --- a/src/web/routes/hook-event-routes.ts +++ b/src/web/routes/hook-event-routes.ts @@ -278,9 +278,11 @@ export function registerHookEventRoutes( // Claude pane; only the ones where the conversation actually moved (a /clear // successor) carry information, and recording the rest would push a row into // the Summary timeline and /api/search per turn and evict useful rows from - // the 1000-event FIFO (#367 merge-time fix). + // the 1000-event FIFO (#367 merge-time fix). A keep-alive turn's stop and idle prompt are + // left out too: the summary files a `stop` as a warning, which the Away digest lists under + // Needs Attention, and that turn is Codeman's own, not the agent's. const summaryTracker = ctx.runSummaryTrackers.get(sessionId); - if (summaryTracker && (event !== 'prompt_submitted' || conversationChanged)) { + if (summaryTracker && !keepAliveReason && (event !== 'prompt_submitted' || conversationChanged)) { summaryTracker.recordHookEvent(event, safeData); } diff --git a/test/routes/hook-event-routes.test.ts b/test/routes/hook-event-routes.test.ts index a6066475..ba4184d9 100644 --- a/test/routes/hook-event-routes.test.ts +++ b/test/routes/hook-event-routes.test.ts @@ -427,6 +427,36 @@ describe('hook-event-routes', () => { expect(mockTracker.recordHookEvent).toHaveBeenCalledWith('stop', expect.any(Object)); }); + /** + * The run summary files every `stop` as a warning, and the Away digest lists warnings under + * Needs Attention, so a keep-alive turn's hooks would add a row there (and to the session's + * Summary timeline) for every nudge. They are Codeman's own turn, not the agent's. + */ + it("keeps a keep-alive turn's stop and idle prompt out of the run summary", async () => { + const sessionId = harness.ctx._sessionId; + const mockTracker = { recordHookEvent: vi.fn() }; + harness.ctx.runSummaryTrackers.set(sessionId, mockTracker as never); + harness.ctx._session.keepAliveTurnActive = true; + for (const event of ['stop', 'idle_prompt']) { + const res = await harness.app.inject({ + method: 'POST', + url: '/api/hook-event', + payload: { event, sessionId, data: null }, + }); + expect(res.statusCode).toBe(200); + } + approvalInbox.resolveForSession(sessionId, 'dismissed'); + expect(mockTracker.recordHookEvent).not.toHaveBeenCalled(); + + // The next real turn is recorded as before. + await harness.app.inject({ + method: 'POST', + url: '/api/hook-event', + payload: { event: 'stop', sessionId, data: null }, + }); + expect(mockTracker.recordHookEvent).toHaveBeenCalledWith('stop', expect.any(Object)); + }); + it('starts transcript watcher when transcript_path is provided', async () => { const res = await harness.app.inject({ method: 'POST',