diff --git a/CLAUDE.md b/CLAUDE.md index bb78756b9..9b8f64f2c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -158,6 +158,8 @@ 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, read from a STYLED capture so Claude Code's faint prompt suggestion is not taken for a draft; no recent keystroke); add a guard there, never in the service. ⚠️ The nudge IS a submit and moves `lastSubmitAt` (the response viewer's conversation correlation needs it); the window `beginKeepAliveTurn()` opens keeps it off `lastRealPromptAt` (the cap's own persisted clock) and opens its `stop`/`idle_prompt` hooks pre-acknowledged (`preAcknowledged` on `notePrompt`), so nothing alerts, and leaves an idle item already pending exactly as found (only a human spends an idle alert; `session:working`/`session:idle` carry `keepAlive: true` so the page's stuck timer ignores the turn); the keep-alive's own `idle_prompt` ends that window (`endKeepAliveTurn()`; `KEEP_WARM_TURN_WINDOW_MS` is only a ceiling, since a slow turn's idle prompt lands a minute after it ends), and so does any other submit: a `fromUser` write, a programmatic write of anything but the nudge, or a `prompt_submitted` hook whose prompt is not the nudge. Read My Mind drops its exact text (`isCapturablePrompt`). → [architecture-invariants#prompt-cache-keep-warm](docs/architecture-invariants.md#prompt-cache-keep-warm) + **Orchestrator**: State machine that turns a user goal into a phased plan and drives it to completion: `idle → planning → approval → executing → verifying → (replanning) → completed/failed`. `OrchestratorLoop` (engine) delegates plan generation to `orchestrator-planner` and per-phase verification gates to `orchestrator-verifier`, executing phases via team agents/`task-queue`. State persists under the `orchestrator` key in `state.json`. Distinct from Ralph (single-session autonomous loop) — orchestrator coordinates multi-phase, multi-agent execution. See `docs/orchestrator-loop-architecture.md`. **Cron (`CronJob`s)**: saved, named jobs on a recurring schedule (`once`/`interval`/`daily`/`weekly`) with per-job run history. ⚠️ **Distinct from the legacy `ScheduledRun`** (`/api/scheduled`, a run-now duration-bounded loop); the two never interact and keep separate `Scheduled*` / `Cron*` names. `CronService` **reuses the existing session layer** rather than rebuilding tmux logic. Next-run math is pure and unit-tested in `cron-time.ts` (server-local timezone). The schedule is advanced BEFORE launch so a slow launch cannot re-trigger. → [architecture-invariants#cron-jobs](docs/architecture-invariants.md#cron-jobs), `docs/cron-discovery.md` diff --git a/docs/api-reference.md b/docs/api-reference.md index c0350a66b..81fecd30f 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1152,6 +1152,16 @@ Result (`data`): The result carries server **names** only, never `env` values, `headers` or file content. Each changed file keeps its previous content as `.codeman-bak` (overwritten by each sync); a file that receives servers carrying `env` or `headers` is left mode `0600`. +## Prompt-cache keep-warm + +Claude Code reports the main conversation's prompt-cache state on its statusline (v2.1.251+); the exporter keeps it per session as `promptCache` on `GET /api/sessions` (`{ warm, ttl, expiresAt, recacheTokensIfCold, misses, lastMissCauses }`, epoch ms). The keep-warm sends one keep-alive turn shortly before an idle session's cache would expire; `keepWarmEnabled` (absent = the app-wide `promptCacheKeepWarm` setting applies, which a session spawned by another session does not inherit), the live `keepWarm` status and `lastRealPromptAt` (epoch ms of the last submit that was not a keep-alive, the cap's clock) ride the same session state. A keep-alive is a submit, so it moves `lastSubmitAt` like any prompt. A claude launch with keep-warm on also carries `"idleCompaction": false` in its `--settings` JSON (Claude Code 2.1.290 or newer). + +| Method | Path | Body | Notes | +| ------ | ---------------------------------- | ------------------------------------ | ----- | +| `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`. + ## Webhook notifications Posts the Web Push events to ntfy, Slack, Discord or a generic JSON URL (Settings → Notifications). Off by default. The webhook URL is a bearer secret (anyone holding a Slack/Discord URL can post as it), so it lives in `~/.codeman/webhook.json` (0600), is **never returned**, and is kept out of `settings.json`. All three routes answer `403` for a non-admin in multi-user mode. diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index a50539c86..835d5279e 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -159,6 +159,10 @@ Session create additionally pre-seeds the skill's §0 preamble cache (`seedAgent Further detail: the handheld OFF default comes from the mobile block in `getDefaultSettings()`. `planUsageChipEnabled()` backs the two call sites that must never disagree (the App Settings checkbox and the chip's visibility). The absent-key-reads-as-ON rule mirrors `readWorkspaceHooksEnabled()`. The same statusline payload carries a `prompt_cache` object since Claude Code v2.1.251 (`warm`, `ttl`, `expires_at` in epoch seconds, `recache_tokens_if_cold`, `misses`, `expected_rebuilds`, `hit_ratio`, `last_miss_at`, `last_miss_cause`); `StatusTelemetrySchema` lists every documented scalar field null-tolerant and keeps `causes` as `unknown` (the object gains fields across CLI versions and several are documented as possibly null, so a stricter shape would 400 the whole post and blank the footer — the recurring `.optional()`/null trap), while `parsePromptCache()` (usage-telemetry.ts) normalizes only the subset Codeman stores, so a hostile localhost payload cannot bloat state. The footer shows it as `cache:until HH:MM` (server-local clock, never a countdown: the footer only re-renders on statusline triggers) while warm and `cache:cold` otherwise. Claude re-runs the statusline when `expires_at` passes, so the footer flips to cold on its own. ⚠️ A user's OWN statusline wins the footer (the exporter then posts in the background and prints theirs), so the state ALSO lives on `Session.promptCache` (`setPromptCache()`, in `toState()` for the Session Options → Respawn readout `#promptCacheStatus`, worded by the pure `CodemanPromptCache.format` in constants.js, which also shows the hit rate, the last miss's `causes`, and — after a compaction (`/compact` or Claude Code's own idle compaction) or a tool-result clearing, the first warm report whose `recache_tokens_if_cold` is null after one that carried it — a `Context compacted at HH:MM` note that is NEVER an alert) and the route broadcasts `session:promptCache` (the STORED object, so the compaction stamp rides along) only when the report CHANGES (the statusline fires per assistant message). ⚠️ `setPromptCache()` returns a NEW object when it stamps `compactedAt`, never mutating the parser's output, which is what keeps broadcasting the stored object meaningful; the stamp needs a KNOWN prior `recache_tokens_if_cold` (a first report already inside the null window, such as a mid-session restart's, gets none), is carried while the window holds and dropped at the next request, and never reads `expected_rebuilds`: Claude Code's tracker clears the window and counts the expected rebuild in that same next request, so no single report carries both (2.1.294-2.1.296), and `adoptClaudeSessionId()` drops the whole report on a conversation switch so a post-`/clear` pane never shows the old prefix's warm-until. The last report is restored on a Codeman restart (the constructor's `promptCache`, set directly so it is not re-stamped, like `displayModel`) so the readout is not blank until the pane's next render; the readout is time-aware, so a value whose `expiresAt` has passed reads as cold rather than as a stale warm (and `parsePromptCache` validates the converted-to-ms expiry against the Date range, so a huge `expires_at` cannot render `NaN:NaN`). ⚠️ A launch without the exporter never reports, so `TmuxManager._resolveStatusLine()` records why on the mux record at every create and respawn (`MuxSession.statusLine`, published as `SessionState.statusLineExporter`: `injected`, `collection-off`, `workspace-statusline`, `remote`) and the readout names the cause instead of "not reported yet" forever. It is a spawn-time fact: flipping Plan Usage on later changes nothing until the next launch. ⚠️ This object, not the plan tier, is the source of truth for the TTL: Claude Code requests the hour only for a subscription within plan usage, and drops to five minutes on an API key, a cloud provider or extra-usage credits. +### 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, an evaluation that throws re-arms the same retry, and a trigger landing during an evaluation queues one follow-up after it instead of being dropped, and `remove()` cancels both the follow-up and the retry; the composer is read only once everything else says "due". ⚠️ **Two submit clocks.** The nudge goes out through a plain `writeViaMux()` after `beginKeepAliveTurn()`. It IS a submit, so it moves `lastSubmitAt` from the write and again from its `prompt_submitted` hook (`markPromptSubmitted()`): the response viewer's conversation correlation (`resolveActiveClaudeSessionIdFromHistory()`) matches that stamp against Claude's history, which records the nudge like any prompt. The cap needs the last REAL prompt instead, so `Session._stampSubmit()` also moves `lastRealPromptAt` for every submit outside the keep-alive window (a person in the browser, an agent over the API, a typist in an attached tmux client seen through the hook), persisted in `SessionState.lastRealPromptAt` and restored by both rebuild paths so a restart never reopens the window. The window closes on the keep-alive's own `idle_prompt` (the hook route calls `endKeepAliveTurn()`; Claude Code sends it about a minute after the turn ends, so a fixed short window would let a slow turn's idle prompt alert and push, and `KEEP_WARM_TURN_WINDOW_MS` is only a ceiling for a workspace whose hooks never report). Any other submit closes it at once: a `fromUser` write, a programmatic write carrying anything but the nudge or a bare Enter (an agent, respawn, auto-resume; `isKeepAliveWrite()` in session.ts), or a `prompt_submitted` hook whose `prompt` is not the exact nudge (the route hands the hook's prompt text to `markPromptSubmitted()`), so a person's prompt right after a nudge, typed in the browser or in an attached tmux client, counts and alerts normally. The home screens and the tile grid read `lastSubmitAt` only to anchor a WORKING row, so a nudge shows there only for the seconds its turn runs; idle rows key on `lastActivityAt`, which the nudge's reply moves like any output. ⚠️ The same window makes the turn's `stop` hook and `idle_prompt` open pre-acknowledged (`preAcknowledged: 'cache keep-alive'` through `notePrompt()`, the same path `watching` uses), so no tab alert, toast, push or webhook fires. ⚠️ Only a human spends an idle alert, so the keep-alive turn leaves an idle item already pending exactly as it found it: the `working` transition's idle resolve (session-listener-wiring.ts) and the `stop` resolve skip it, and the keep-alive's `idle_prompt` does not replace it (a replacement would forget that it was unseen, or that a person had seen it), while the page's `_onHookStop` returns on `acknowledgedReason` before clearing anything; `session:working` and `session:idle` carry `keepAlive: true` for the turn, so the page's stuck-detection timer is neither cleared nor re-armed by it. Read My Mind drops the exact nudge text in `isCapturablePrompt()`, the filter `captureIntentPrompt()` → `intentStore.recordPrompt()` runs every transcript prompt through. ⚠️ Re-evaluation is edge-triggered and cheap: every cache report (`status-telemetry-routes.ts`), the toggle route, a settings change (`rescheduleAll()`, which also drops the service's in-memory copy of the default and cap, so a cache report never reads settings.json), `setupSessionListeners()` and the session's `idle` event (`keepWarmSchedule` in session-listener-wiring.ts, which is also what arms a restored report once the recovered pane is attached); the service keeps one timer per session (clamped to the 2^31-1 ms `setTimeout` honours, so a far-off expiry cannot turn into a 1 ms loop), `remove()`s it with the session and stops with the server, and a `remove()` or `stop()` landing during an evaluation's await cancels it there (nothing sent, armed or published after), and the server logs one `[KeepWarm]` line per status change naming the guard that holds it. Status rides `Session.keepWarm` in `toState()` and the change-only `session:keepWarm` event for the readout; `keepWarmEnabled`, `lastRealPromptAt` and the last `promptCache` report are restored by the constructor on both rebuild paths (that is what re-arms the timer after a restart), the status never is. Tests: `test/prompt-cache-keepwarm.test.ts`, `test/keep-warm-launch.test.ts`, `test/keep-warm-i18n.test.ts`, `test/routes/session-routes-keep-warm.test.ts`, the keep-warm readout in `test/prompt-cache-status.test.ts`, the opt-out literals in `test/cli-registry-spawn-golden.test.ts`, the keep-alive cases in `test/routes/hook-event-routes.test.ts` and `test/session-listener-wiring.test.ts`, and the page handlers in `test/keep-alive-page-alerts.test.ts`. + ### Cron jobs **Cron (cron-style `CronJob`s)**: saved, named jobs with a recurring schedule (`once`/`interval`/`daily`/`weekly`), enable/disable, Run Now, next-run calc, and per-job run history (`CronJobRun`). ⚠️ **Distinct from the legacy `ScheduledRun`** (`/api/scheduled`, a run-now duration-bounded autonomous loop) — the two never interact; the legacy concept keeps the `Scheduled*` names, the recurring-job feature is `Cron*`. `CronService` (`src/cron/cron-service.ts`) owns CRUD + the 30s background due-tick (`tickDueJobs`, registered via `cleanup.setInterval` in `server.ts`; `init()` recomputes nextRunAt on boot) and **reuses the existing session layer** (create → `addSession` → `setupSessionListeners` → `startInteractive`/`startShell` → prompt via `writeViaMux`/`write`) rather than rebuilding tmux logic. Next-run math is pure/unit-tested in `cron-time.ts` (SERVER-LOCAL timezone for daily/weekly). Dup-launch guard = `lastDueKey` (jobId:fireTime); schedule is advanced BEFORE launch so a slow launch can't re-trigger. `once` jobs self-disable after firing (`completedOnce`). Persisted via `AppState.cronJobs`/`cronJobRuns` (StateStore accessors). Routes `/api/cron/jobs*` + `/api/cron/runs` (`cron-routes.ts`, `CronPort`); schema `CronJobSchema` (cross-field `superRefine`; the `.partial()` update schema does NOT re-run it); SSE `cron:*`. Frontend `cron-ui.js` (#cronModal). Claude/shell/opencode/codex/gemini/antigravity/pi agent types. Tests: `test/cron-time.test.ts`, `test/cron-service.test.ts`. Design: `docs/cron-discovery.md`. diff --git a/docs/wiki/Keeping-Agents-Running.md b/docs/wiki/Keeping-Agents-Running.md index dfa4fc10e..927e10119 100644 --- a/docs/wiki/Keeping-Agents-Running.md +++ b/docs/wiki/Keeping-Agents-Running.md @@ -101,7 +101,7 @@ limit-paused. Without that, the next cycle would fire `/clear` and wipe the conv are waiting to resume. This is the single most useful setting for overnight runs on a subscription plan. -## The prompt cache readout +## The prompt cache: readout and keep-warm **Claude only.** On the Respawn tab, above the loop controls. @@ -109,6 +109,14 @@ Claude Code bills a turn against the cached conversation while that cache is war Claude Code 2.1.251 and newer reports the cache state on the statusline after every response, and Codeman's statusline exporter keeps it per session. The row shows **Warm until HH:MM** with the lifetime and the tokens that would be re-cached if it went cold, or **Cold** with the size of the re-cache the next turn pays. When Claude reports them it also shows the session's cache hit rate and the cause of the last cache miss (for example `ttl_expired_1h` or `tools_changed`), so a surprise miss has a reason next to it. If the conversation is compacted while it stays warm (Claude Code now does this on its own on large idle sessions, a manual `/compact` does it too, and so does a clearing of old tool results), the row notes **Context compacted at HH:MM** until the next turn: Claude Code reports no re-cache size until then. None of this raises an alert. The row refreshes live and flips to cold on its own when the lifetime passes, and the last state is kept across a Codeman restart so it is not blank until the session's next turn. Sessions without a statusline of their own also get a short `cache:until HH:MM` or `cache:cold` group in the terminal footer. The readout rides the plan usage chip's telemetry collection, which is on by default on desktop. +**Keep the cache warm while idle** is the switch under the readout. With it on, Codeman sends a one-line keep-alive turn (`Codeman cache keep-alive: no action needed, reply with just "ok".`) about five minutes before the cache would expire. Claude answers `ok`, the request refreshes the cache for another hour, and your next real prompt reads the conversation from cache instead of re-processing it. Measured on sessions of about 110k tokens, the keep-alive cost a cache read plus a one-word reply, a small fraction of the re-write it prevented. + +It only fires when there is nothing else going on: the session is idle with an empty prompt (Claude Code's greyed-out prompt suggestion counts as empty), no permission or question dialog is open, nobody has typed in the last few minutes, no respawn loop, Ralph loop or waiting agent is driving the session, and the session is not paused on a usage limit. If any of those hold it waits a minute and looks again, and gives up a minute before the expiry rather than race it. It also stops by itself a few hours after your last prompt (**Keep warm for at most**, under App Settings → System → Automation, default 2 hours), because the saving is concentrated in gaps of one to four hours and an overnight run of hourly keep-alives would cost more than the single cold turn in the morning. It stands down for the rest of a gap once the context has been compacted. The readout's Status line gains a `Keep-alive:` line that says what it is doing: the next keep-alive and when the window closes, waiting and why, sent at a time with a count, or stopped and why. A session started without the cache readout (Plan Usage off at launch, a workspace with its own statusLine, a remote or Docker session) cannot be kept warm: the switch is greyed out and cannot be switched on (one that is already on can still be switched off), and the Status line says why. + +Recent Claude Code builds can compact a large idle session on their own shortly before the cache expires, which would leave only a summary to keep warm. So a session **started** with keep-warm on also launches with Claude Code's idle compaction switched off (Claude Code 2.1.290 or newer); Codeman then owns the idle policy for it. A session you switch on after it started is still kept warm, but Claude Code may compact it first until its next launch, and the Status line says so; one you switch off keeps Claude Code's idle compaction off until its next launch, and the Status line says that too. Sessions with keep-warm off launch exactly as before. + +**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 **Claude only.** A header chip showing live subscription usage, on by default on desktop and diff --git a/docs/wiki/Settings-Reference.md b/docs/wiki/Settings-Reference.md index 2e7238ce5..10088f8bd 100644 --- a/docs/wiki/Settings-Reference.md +++ b/docs/wiki/Settings-Reference.md @@ -184,7 +184,7 @@ Rebinding for the shortcut registry. See [Keyboard Shortcuts](Keyboard-Shortcuts ### System -`CLAUDE.md` template for new cases, default working directory, the image watcher, and +`CLAUDE.md` template for new cases, default working directory, the image watcher, the prompt-cache keep-warm default (**Keep prompt caches warm**, off) and its cap (**Keep warm for at most**, 2 hours after a session's last prompt; see [Keeping Agents Running](Keeping-Agents-Running)), and Cloudflare tunnel controls including the tunnel URL. The **Diagnostics** group runs `codeman doctor` on the server and lists the agent CLIs, tmux, Node and the optional office tools with their versions and install hints (admin only in multi-user mode). In multi-user @@ -196,7 +196,7 @@ Per session, from the tab. | Panel | Contains | | ---------------- | ------------------------------------------------------------------------------------------- | -| **Respawn** | Auto-resume on usage limit, the respawn cycle configuration, presets, duration. See [Keeping Agents Running](Keeping-Agents-Running). | +| **Respawn** | Auto-resume on usage limit, the prompt cache readout and keep-warm switch, the respawn cycle configuration, presets, duration. See [Keeping Agents Running](Keeping-Agents-Running). | | **Session** | Name, working directory, environment overrides, per-tab pop-out override. | | **Ralph / Todo** | Loop configuration, iteration and todo caps, circuit breaker reset. See [Autonomous Loops](Autonomous-Loops). | | **Summary** | What this session has done: tokens, activity, run summary. | diff --git a/src/config/cli-registry/stock.ts b/src/config/cli-registry/stock.ts index ce5c14e3e..69af8cc9c 100644 --- a/src/config/cli-registry/stock.ts +++ b/src/config/cli-registry/stock.ts @@ -329,7 +329,12 @@ const CLAUDE: CliEntry = { 'CLAUDE_CODE_MAX_CONTEXT_TOKENS', 'CLAUDE_CONFIG_DIR', ], - gates: { nameFlag: { minVersion: '2.1.224', failClosed: true } }, + // idleCompactionSetting: the `idleCompaction` settings key first ships in 2.1.290 (2.1.289 + // already idle-compacts with no opt-out); see TmuxManager._resolveIdleCompactionOptOut. + gates: { + nameFlag: { minVersion: '2.1.224', failClosed: true }, + idleCompactionSetting: { minVersion: '2.1.290', failClosed: true }, + }, // claude reads `$CLAUDE_CONFIG_DIR/.claude.json` when that is set (checked in 2.1.289). mcpConfig: { path: '.claude.json', diff --git a/src/hooks-config.ts b/src/hooks-config.ts index 83f838ebd..f1bd7ebc9 100644 --- a/src/hooks-config.ts +++ b/src/hooks-config.ts @@ -41,6 +41,7 @@ import { HOOK_TIMEOUT_SECONDS } from './config/auth-config.js'; import { dataPath } from './config/instance.js'; import { readJsonConfig, SETTINGS_PATH } from './web/route-helpers.js'; import { isNearStalledPath, probePath } from './utils/index.js'; +import { DEFAULT_KEEP_WARM_MAX_HOURS, type KeepWarmSettings } from './prompt-cache-keepwarm.js'; /** * Existence check for a WRITER. Unlike the bounded read-side probe (`probePath`), @@ -1144,6 +1145,41 @@ export async function readPinCliSessionNameEnabled(): Promise { return settings.pinCliSessionName === true; } +/** + * 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} at every claude create and respawn, which decides the + * launch's `idleCompaction` opt-out. + */ +export async function readKeepWarmSettings(): Promise { + 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, + maxHours: typeof hours === 'number' && Number.isFinite(hours) && hours > 0 ? hours : DEFAULT_KEEP_WARM_MAX_HOURS, + }; +} + /** * Resolve the statusLine command to pass as an EPHEMERAL `claude --settings` * CLI flag for this one process (see buildSpawnCommandFromRegistry in diff --git a/src/intent-store.ts b/src/intent-store.ts index d1592bf92..2ea821ab9 100644 --- a/src/intent-store.ts +++ b/src/intent-store.ts @@ -24,6 +24,7 @@ import { createHash } from 'node:crypto'; import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from 'node:fs'; import { dirname } from 'node:path'; import { dataPath } from './config/instance.js'; +import { KEEP_WARM_NUDGE } from './prompt-cache-keepwarm.js'; import type { IntentProfile, IntentPromptEntry } from './types/index.js'; // ========== Limits ========== @@ -62,6 +63,9 @@ export function isCapturablePrompt(text: string): boolean { if (text.startsWith('')) return false; if (text.startsWith('Caveat: The messages below')) return false; if (text.startsWith('[Request interrupted')) return false; + // Codeman's own prompt-cache keep-alive turn is not something the user meant. The exact + // text, so a prompt of the user's that merely mentions it is still captured. + if (text.trim() === KEEP_WARM_NUDGE) return false; return true; } diff --git a/src/mux-interface.ts b/src/mux-interface.ts index c1df2492e..446cba54c 100644 --- a/src/mux-interface.ts +++ b/src/mux-interface.ts @@ -61,6 +61,11 @@ export interface MuxSession { ralphEnabled?: boolean; /** Whether the current launch carries the statusLine exporter; set on every create and respawn. */ statusLine?: StatusLineExporter; + /** + * Whether the current launch carries `idleCompaction: false` in its `--settings` JSON (keep-warm + * on at launch, the exporter injected, Claude Code new enough); set on every create and respawn. + */ + idleCompactionOff?: boolean; /** * This record was rebuilt from the tmux socket rather than from Codeman's own * bookkeeping, so everything on it but the name and the pid is a guess. Its @@ -123,6 +128,10 @@ export interface CreateSessionOptions { effort?: EffortLevel; /** Claude advisor model, merged into the same `--settings` JSON (overridable via /advisor in-session) */ advisorModel?: string; + /** The session's own prompt-cache keep-warm choice (undefined = app default); decides the `idleCompaction` opt-out. */ + keepWarm?: boolean; + /** Spawned by another session: it does not inherit the keep-warm app default (`keepWarmOn`). */ + spawnedByAgent?: boolean; /** tmux history-limit (scrollback lines) allocated when this session is created. */ historyLimit?: number; /** Remote execution metadata for local tmux sessions wrapping SSH */ @@ -176,6 +185,10 @@ export interface RespawnPaneOptions { effort?: EffortLevel; /** Claude advisor model (preserved across respawns, merged into the same `--settings` JSON) */ advisorModel?: string; + /** The session's own prompt-cache keep-warm choice, re-read at every relaunch (see CreateSessionOptions). */ + keepWarm?: boolean; + /** Spawned by another session (see CreateSessionOptions). */ + spawnedByAgent?: boolean; /** Original tmux history-limit retained for config parity; respawn cannot resize the existing pane. */ historyLimit?: number; /** Remote execution metadata for local tmux sessions wrapping SSH */ @@ -415,11 +428,12 @@ export interface TerminalMultiplexer extends EventEmitter { captureActivePaneBuffer?(muxName: string, opts?: PaneCaptureOptions): string | null; /** - * Plain text of the visible frame: no styles, no cursor query, no repaint - * reconstruction. Deliberately cheaper than `capturePaneBuffer` because idle + * Plain text of the visible frame: no cursor query, no repaint + * reconstruction, and no styles unless `styles` asks for them (SGR only, lines + * still `\n`-separated). Deliberately cheaper than `capturePaneBuffer` because idle * detection calls it on a timer: it only needs to read what the CLI is * currently rendering, never to replay it into an xterm. Returns null when the * pane cannot be read. */ - capturePaneText?(muxName: string, paneTarget?: string): string | null; + capturePaneText?(muxName: string, paneTarget?: string, opts?: { styles?: boolean }): string | null; } diff --git a/src/prompt-cache-keepwarm.ts b/src/prompt-cache-keepwarm.ts new file mode 100644 index 000000000..f721e16a2 --- /dev/null +++ b/src/prompt-cache-keepwarm.ts @@ -0,0 +1,397 @@ +/** + * @fileoverview Prompt-cache keep-warm: a one-line keep-alive turn shortly before an idle + * claude session's prompt cache would expire, so the next real prompt reads the conversation + * from cache instead of re-processing it at the cache-write rate. + * + * Why it exists: Claude Code gives a subscription's main conversation a one-hour cache and + * lets it expire after an hour idle. Measured on real sessions, nearly every gap over an + * hour went cold and the first turn back re-wrote the whole context at 2x; a keep-alive turn + * costs a cache read plus a one-word reply, a small fraction of the miss it prevents, and the + * payoff 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. + * + * Codeman owns the idle policy for these sessions (Discussion #554): a launch with keep-warm + * on also carries `idleCompaction: false` in its one `--settings` JSON, so Claude Code's own + * idle compaction (which fires at 90% of the TTL, a minute before the nudge) cannot replace + * the conversation with a summary first. That is decided once per launch in + * `TmuxManager._resolveIdleCompactionOptOut()` and recorded on the mux record; a session + * switched on after launch is kept warm best-effort and says so. + * + * Where the facts come from: `Session.promptCache` (the statusline's `prompt_cache` object, + * see usage-telemetry.ts) carries `expires_at`, the TTL and the tokens at stake, so nothing + * here infers the lifetime from idle detection or the plan tier. + * + * Shape: `decideKeepWarm()` is the pure rulebook (every guard in one place, tested as a + * table); `PromptCacheKeepWarmService` owns the per-session timer and the ports it needs + * from the server. It never touches the session except through `beginKeepAliveTurn()` and + * `writeViaMux()`: the window `beginKeepAliveTurn()` opens is what keeps the nudge off the + * real-prompt clock and opens its stop hook and idle prompt pre-acknowledged + * (hook-event-routes.ts). The nudge is still a submit, so it moves `lastSubmitAt`. + * + * @module prompt-cache-keepwarm + */ + +import type { KeepWarmStatus, PromptCacheStatus, StatusLineExporter } from './types/index.js'; + +/** The nudge. Under 100 chars on purpose: a longer burst reads as a paste to Claude Code. */ +export const KEEP_WARM_NUDGE = 'Codeman cache keep-alive: no action needed, reply with just "ok".'; +/** Nudge this long before `expires_at`. The nudge's own request is what refreshes the timer. */ +export const KEEP_WARM_LEAD_MS = 5 * 60_000; +/** Re-check cadence while a transient guard (working, dialog, draft in the composer) defers. */ +export const KEEP_WARM_RETRY_MS = 60_000; +/** Give up this close to expiry: a turn takes seconds to land and the Enter may be re-pressed. */ +export const KEEP_WARM_LAST_CHANCE_MS = 60_000; +/** Below this many tokens at stake a cold rewrite is cheap and a turn is not worth it. */ +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; +/** + * 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. */ +const MAX_TIMER_MS = 2 ** 31 - 1; + +/** + * Whether keep-warm is on for a session: its own choice, else the app-wide default. A session + * an agent spawned (`parentSessionId`) does not inherit the default: a worker that finished + * its task is idle exactly like one that will be prompted again, and most never are, so only + * its own switch keeps it warm. One function because the launch (the `idleCompaction` + * opt-out) and the service must agree. + */ +export function keepWarmOn(choice: boolean | undefined, appDefault: boolean, spawnedByAgent: boolean): boolean { + return choice ?? (appDefault && !spawnedByAgent); +} + +export interface KeepWarmFacts { + now: number; + /** The session's own choice; undefined = the app-wide default applies. */ + choice: boolean | undefined; + appDefault: boolean; + /** Spawned by another session (`parentSessionId`); see `keepWarmOn`. */ + spawnedByAgent: boolean; + /** Whether this launch carries the statusLine exporter; undefined when unknown. */ + statusLine?: StatusLineExporter; + cache: PromptCacheStatus | null | undefined; + /** Idle by Codeman's own probe and not mid-turn. */ + idle: boolean; + /** A pid and no recorded agent exit (`paneExit`: an exited agent's pane keeps its pid). */ + paneAlive: boolean; + /** Auto-resume's pause, or a usage-limit message on screen whose reset is still ahead. */ + limitPaused: boolean; + /** Respawn running, a Ralph loop active, a task assigned, or an agent long-polling the session. */ + automationActive: boolean; + /** A permission, question or elicitation dialog is open (never the idle item). */ + pendingApproval: boolean; + /** The composer line holds no text; null when the pane could not be read. */ + composerEmpty: boolean | null; + /** Last keystroke a person sent, epoch ms; 0 when none seen. */ + lastHumanInputAt: number; + /** Start of the keep-warm window: the last real (non-keep-alive) prompt, else the session start. */ + budgetAnchorAt: number; + maxKeepWarmMs: number; +} + +export type KeepWarmDecision = + | { action: 'nudge' } + | { action: 'wait'; until: number; reason: string } + | { action: 'stop'; reason: string }; + +const stop = (reason: string): KeepWarmDecision => ({ action: 'stop', reason }); +const wait = (until: number, reason: string): KeepWarmDecision => ({ action: 'wait', until, reason }); + +/** + * The rulebook. Hard stops first (nothing to do until the next cache report or a flag + * change), then the schedule, then the transient guards that defer by `KEEP_WARM_RETRY_MS` + * and never past the last-chance line. Pure: `now` is passed in. + */ +export function decideKeepWarm(f: KeepWarmFacts): KeepWarmDecision { + if (!keepWarmOn(f.choice, f.appDefault, f.spawnedByAgent)) { + return stop( + f.choice === undefined && f.appDefault + ? 'an agent-spawned session follows the app-wide default only when switched on for itself' + : 'off' + ); + } + // Only a launch that carries the exporter ever reports `prompt_cache`; the readout names the + // cause from `statusLineExporter`. This is also what stops a CLI without cache telemetry. + if (f.statusLine !== 'injected') return stop(`no cache reports on this launch (${f.statusLine ?? 'unknown'})`); + const c = f.cache; + if (!c) return stop('no cache report yet'); + if (!c.warm || !c.expiresAt) return stop('cache is cold; the next prompt rebuilds it'); + // Compacted this gap (`/compact`, or Claude Code's own idle compaction on a launch without + // the opt-out): the full context is already gone, so keeping the summary warm buys little. + // The stamp is dropped at the next request, which re-opens the next gap. + if (c.compactedAt != null) return stop('the context was compacted; resumes after the next prompt'); + if (c.ttl === '5m') { + return stop('5-minute cache lifetime; a nudge every four minutes costs more than the miss it prevents'); + } + if ((c.recacheTokensIfCold ?? 0) < KEEP_WARM_MIN_TOKENS) return stop('context too small to be worth a turn'); + if (!f.paneAlive) return stop('no live pane'); + if (f.limitPaused) return stop('usage-limit pause'); + if (f.now >= f.budgetAnchorAt + f.maxKeepWarmMs) return stop('keep-warm window after the last prompt has passed'); + const dueAt = c.expiresAt - KEEP_WARM_LEAD_MS; + if (f.now < dueAt) return wait(dueAt, 'not due yet'); + const lastChance = c.expiresAt - KEEP_WARM_LAST_CHANCE_MS; + if (f.now >= lastChance) return stop('too close to expiry to land a turn'); + const retryAt = Math.min(f.now + KEEP_WARM_RETRY_MS, lastChance); + if (!f.idle) return wait(retryAt, 'session is working'); + if (f.pendingApproval) return wait(retryAt, 'a dialog is open'); + // Transient, and nothing else re-triggers an idle session: an agent's long-poll waiter ends + // without a turn, and so does a capture that failed once. + if (f.automationActive) return wait(retryAt, 'respawn, Ralph or an agent is driving the session'); + if (f.composerEmpty === null) return wait(retryAt, 'cannot read the composer'); + if (!f.composerEmpty) return wait(retryAt, 'the composer holds text'); + if (f.lastHumanInputAt && f.now - f.lastHumanInputAt < KEEP_WARM_RECENT_INPUT_MS) { + return wait(retryAt, 'someone typed a moment ago'); + } + return { action: 'nudge' }; +} + +/** What the service needs from a session; `Session` satisfies it structurally. */ +export interface KeepWarmSessionView { + readonly id: string; + readonly createdAt: number; + readonly pid: number | null; + /** Set when the agent exited in a pane that stays open (`remain-on-exit`); the pid stays. */ + readonly paneExit?: unknown; + readonly parentSessionId?: string; + readonly promptCache: PromptCacheStatus | null; + readonly statusLineExporter?: StatusLineExporter; + /** Whether this launch carries `idleCompaction: false` (`MuxSession.idleCompactionOff`). */ + readonly idleCompactionOptOut: boolean; + readonly keepWarmEnabled: boolean | undefined; + readonly isWorking: boolean; + readonly isLimitPaused: boolean; + readonly lastHumanInputAt: number; + /** Last submit that was not the keep-alive's own (persisted): the cap's clock. */ + readonly lastRealPromptAt: number; + isIdle(): boolean; + beginKeepAliveTurn(windowMs: number): void; + writeViaMux(data: string): Promise; +} + +export interface KeepWarmSettings { + /** App-wide default for sessions that have not chosen (`promptCacheKeepWarm`). */ + keepWarmDefault: boolean; + /** Cap in hours after the last real prompt (`promptCacheKeepWarmMaxHours`). */ + maxHours: number; +} + +export interface KeepWarmPorts { + getSession(id: string): KeepWarmSessionView | undefined; + sessionIds(): Iterable; + readSettings(): Promise; + isAutomationActive(id: string): boolean; + hasPendingApproval(id: string): boolean; + /** + * Read the pane once a nudge is otherwise due: whether the composer is empty (null when it + * cannot be read), and whether a usage-limit message with a reset still ahead is on screen + * (auto-resume's own pause tracking only runs while auto-resume is on). + */ + readPane(session: KeepWarmSessionView): { composerEmpty: boolean | null; limitShown: boolean }; + /** Store and broadcast the status (the server dedupes against the session's last one). */ + publish(id: string, status: KeepWarmStatus): void; + log?(message: string): void; +} + +/** The scheduler the routes see: one method per trigger, nothing else. */ +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-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; +} + +export class PromptCacheKeepWarmService implements KeepWarmScheduler { + private readonly timers = new Map(); + private readonly tallies = new Map(); + /** One evaluation per session; `remove()` and `stop()` cancel it at its next await. */ + private readonly inFlight = new Map(); + /** The evaluation running per session, and the one follow-up queued behind it. */ + private readonly running = new Map>(); + private readonly followUps = new Map>(); + /** + * The default and cap, read once and kept: every statusline report schedules an evaluation, + * and reading settings.json on each would cost every install, the feature off included. + * `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) {} + + schedule(sessionId: string): Promise { + if (this.stopped) return Promise.resolve(); + const running = this.running.get(sessionId); + if (running) { + // A trigger landing mid-evaluation (a toggle while the nudge is written, a settings change) + // carries what that evaluation read too early, so one follow-up runs after it, shared by + // every trigger that arrives meanwhile. + let next = this.followUps.get(sessionId); + if (!next) { + next = running.then(() => { + // `remove()` drops the queued follow-up: a deleted session gets no evaluation. + if (this.followUps.get(sessionId) !== next) return; + this.followUps.delete(sessionId); + return this.schedule(sessionId); + }); + this.followUps.set(sessionId, next); + } + return next; + } + const run = this.evaluate(sessionId) + .catch((err) => { + this.ports.log?.(`[KeepWarm] ${sessionId.slice(0, 8)}: ${err instanceof Error ? err.message : String(err)}`); + // A failed settings read or write is transient, and an idle session gets no other + // trigger until its next turn. + // Not after `remove()`, which drops this run: the session is being deleted. + if (!this.stopped && this.running.get(sessionId) === run) this.arm(sessionId, Date.now() + KEEP_WARM_RETRY_MS); + }) + .finally(() => { + if (this.running.get(sessionId) === run) this.running.delete(sessionId); + }); + this.running.set(sessionId, run); + return run; + } + + rescheduleAll(): void { + this.settings = undefined; + for (const id of this.ports.sessionIds()) void this.schedule(id); + } + + remove(sessionId: string): void { + this.clearTimer(sessionId); + this.tallies.delete(sessionId); + this.inFlight.delete(sessionId); + this.running.delete(sessionId); + this.followUps.delete(sessionId); + } + + stop(): void { + this.stopped = true; + 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); + this.timers.delete(id); + } + + private arm(id: string, at: number): void { + this.clearTimer(id); + const t = setTimeout(() => void this.schedule(id), Math.min(Math.max(0, at - Date.now()), MAX_TIMER_MS)); + t.unref?.(); + this.timers.set(id, t); + } + + private async evaluate(id: string): Promise { + if (this.stopped || this.inFlight.has(id)) return; + const token = Symbol(id); + this.inFlight.set(id, token); + // A session removed (or the service stopped) during an await gets nothing after it: no + // nudge, no tally, no timer, no status. + const cancelled = () => this.stopped || this.inFlight.get(id) !== token; + try { + this.clearTimer(id); + const session = this.ports.getSession(id); + if (!session) return; + const settings = await this.readSettings(); + if (cancelled()) return; + const tally = this.tallies.get(id) ?? { nudges: 0 }; + const facts: KeepWarmFacts = { + now: Date.now(), + choice: session.keepWarmEnabled, + appDefault: settings.keepWarmDefault, + spawnedByAgent: !!session.parentSessionId, + statusLine: session.statusLineExporter, + cache: session.promptCache, + idle: session.isIdle() && !session.isWorking, + paneAlive: session.pid !== null && !session.paneExit, + limitPaused: session.isLimitPaused, + automationActive: this.ports.isAutomationActive(id), + pendingApproval: this.ports.hasPendingApproval(id), + // Optimistic until everything else says "due": the pane read is the one costly fact. + composerEmpty: true, + lastHumanInputAt: session.lastHumanInputAt, + budgetAnchorAt: session.lastRealPromptAt || session.createdAt, + maxKeepWarmMs: settings.maxHours * 3_600_000, + }; + let decision = decideKeepWarm(facts); + if (decision.action === 'nudge') { + const pane = this.ports.readPane(session); + decision = decideKeepWarm({ + ...facts, + composerEmpty: pane.composerEmpty, + limitPaused: facts.limitPaused || pane.limitShown, + }); + } + const enabled = keepWarmOn(facts.choice, facts.appDefault, facts.spawnedByAgent); + const base = { + enabled, + idleCompactionOff: session.idleCompactionOptOut, + nudges: tally.nudges, + lastNudgeAt: tally.lastNudgeAt, + // Off, the window means nothing, and it moves with every real prompt: carrying it would + // broadcast a change on every turn of every session. + ...(enabled && { budgetEndsAt: facts.budgetAnchorAt + facts.maxKeepWarmMs }), + }; + if (decision.action === 'nudge') { + session.beginKeepAliveTurn(KEEP_WARM_TURN_WINDOW_MS); + const ok = await session.writeViaMux(`${KEEP_WARM_NUDGE}\r`); + if (cancelled()) return; + if (ok) { + tally.nudges += 1; + tally.lastNudgeAt = Date.now(); + this.tallies.set(id, tally); + } + this.ports.log?.( + `[KeepWarm] ${ok ? 'nudged' : 'failed to nudge'} ${id.slice(0, 8)} with ${facts.cache?.recacheTokensIfCold ?? '?'} tokens at stake (nudge ${tally.nudges})` + ); + // The nudge's own cache report re-schedules; this timer is the fallback if none arrives. + const nextCheckAt = Date.now() + KEEP_WARM_LEAD_MS; + this.arm(id, nextCheckAt); + this.ports.publish(id, { + ...base, + nudges: tally.nudges, + lastNudgeAt: tally.lastNudgeAt, + state: ok ? 'nudged' : 'waiting', + nextCheckAt, + reason: ok ? undefined : 'the keep-alive could not be written', + }); + } else if (decision.action === 'wait') { + this.arm(id, decision.until); + this.ports.publish(id, { + ...base, + state: decision.reason === 'not due yet' ? 'armed' : 'waiting', + nextCheckAt: decision.until, + reason: decision.reason === 'not due yet' ? undefined : decision.reason, + }); + } else { + this.ports.publish(id, { ...base, state: enabled ? 'stopped' : 'off', reason: decision.reason }); + } + } finally { + if (this.inFlight.get(id) === token) this.inFlight.delete(id); + } + } +} diff --git a/src/session-cli-registry-bridge.ts b/src/session-cli-registry-bridge.ts index bb2e49717..0c82c4bd2 100644 --- a/src/session-cli-registry-bridge.ts +++ b/src/session-cli-registry-bridge.ts @@ -68,6 +68,12 @@ export interface SpawnBridgeOptions { * rendered as two independent params. */ statusLineCommand?: string; + /** + * Add `idleCompaction: false` to the same `--settings` JSON: Codeman's prompt-cache keep-warm + * owns the idle policy for this launch. Decided (keep-warm on, exporter injected, version gate + * passed) by TmuxManager._resolveIdleCompactionOptOut, which records it on the mux record. + */ + idleCompactionOff?: boolean; } /** @@ -168,6 +174,20 @@ function resolveGatesPassed(entry: CliEntry, resolveVersion: () => string | null return passed; } +/** + * Whether `mode`'s `capabilities.gates[gate]` passes, through the same fail-closed resolution + * the launch template uses (an unknown version passes no gate). For a launch-time decision that + * is also recorded, like the `idleCompaction` opt-out. `claudeCliVersion` as in + * SpawnBridgeOptions: undefined probes the local CLI, null means unknown. + */ +export function launchGatePasses(mode: string, gate: string, claudeCliVersion?: string | null): boolean { + const entry = getCli(mode); + if (!entry?.capabilities.gates[gate]) return false; + return resolveGatesPassed(entry, () => + claudeCliVersion !== undefined ? claudeCliVersion : getClaudeCliVersion() + ).has(gate); +} + /** * Render the spawn command for `entry` from the legacy option bag. Returns `undefined` for a * `shell`-kind entry (or any entry declaring no launch variants), which callers take as "fall @@ -208,10 +228,15 @@ export function buildSpawnCommandFromRegistry(entry: CliEntry, options: SpawnBri // rendering them as independent params would let the last one silently win. Claude-only in // practice: only claude's launch template renders this engine value, so another CLI's // session carrying an advisorModel launches exactly as before. - // Key order (ultracode, advisorModel, statusLine) keeps a launch without an advisor - // byte-identical to one from before the advisor existed. + // Key order (ultracode, advisorModel, statusLine, idleCompaction) keeps a launch without an + // advisor or keep-warm byte-identical to one from before either existed. const advisorSettings = buildAdvisorSettings(options.advisorModel); - if ((effortFlag === '--settings' && effortValue) || advisorSettings.advisorModel || options.statusLineCommand) { + if ( + (effortFlag === '--settings' && effortValue) || + advisorSettings.advisorModel || + options.statusLineCommand || + options.idleCompactionOff + ) { const settingsObj: Record = { ...(effortFlag === '--settings' && effortValue ? JSON.parse(effortValue) : {}), ...advisorSettings, @@ -219,6 +244,7 @@ export function buildSpawnCommandFromRegistry(entry: CliEntry, options: SpawnBri if (options.statusLineCommand) { settingsObj.statusLine = { type: 'command', command: options.statusLineCommand }; } + if (options.idleCompactionOff) settingsObj.idleCompaction = false; engineValues.effortSettingsJson = JSON.stringify(settingsObj); } diff --git a/src/session-submit-verifier.ts b/src/session-submit-verifier.ts index 063ad6ce5..edadf9981 100644 --- a/src/session-submit-verifier.ts +++ b/src/session-submit-verifier.ts @@ -65,6 +65,85 @@ export function promptStillInComposer(screen: string, prompt: string, glyph: str return head.length > 0 && composer.startsWith(head); } +/** + * A styled frame as plain text. SGR goes first with its own pattern because tmux writes + * colon sub-parameters (`ESC[4:3m`, a curly underline), which `stripAnsi` leaves behind. + */ +export function plainFrame(styled: string): string { + // eslint-disable-next-line no-control-regex + return stripAnsi(styled.replace(/\x1b\[[0-9;:]*m/g, '')); +} + +/** + * `screen` (a `capture-pane -p -e` frame) without the text Claude Code draws in an empty + * composer that nobody typed: its prompt suggestion, drawn faint (SGR 2), and the suggestion's + * first letter, which sits in the reverse-video cursor cell (SGR 7) right before the faint rest. + * A typed draft is never faint, so a cursor cell followed by anything else is kept. Every line + * break is kept, so line `n` here is line `n` of the plain frame, and plain text passes through + * unchanged. Other escapes are left for `stripAnsi`. + */ +function withoutSuggestion(screen: string): string { + let faint = false; + let inverse = false; + let cursorCell = ''; + let out = ''; + // eslint-disable-next-line no-control-regex + for (const m of screen.matchAll(/\x1b\[([0-9;:]*)m|[\s\S]/gu)) { + if (m[1] !== undefined) { + const params = m[1].split(';'); + for (let i = 0; i < params.length; i++) { + const p = params[i]; + if (p === '' || p === '0') faint = inverse = false; + else if (p === '2') faint = true; + else if (p === '22') faint = false; + else if (p === '7') inverse = true; + else if (p === '27') inverse = false; + // An extended colour's own arguments (`38;5;n`, `38;2;r;g;b`) are not attributes. + else if (p === '38' || p === '48' || p === '58') i += params[i + 1] === '5' ? 2 : params[i + 1] === '2' ? 4 : 0; + } + continue; + } + const ch = m[0]; + if (ch !== '\n' && faint) { + cursorCell = ''; + continue; + } + out += cursorCell; + cursorCell = ''; + if (ch !== '\n' && inverse) cursorCell = ch; + else out += ch; + } + return out + cursorCell; +} + +/** + * Whether the composer holds nothing: the LAST `glyph` line after the glyph, and every + * continuation line below it down to the composer's bottom rule (a draft that opens with a + * newline leaves the glyph line itself blank). Undefined when there is no composer line or no + * rule under it to bound the read (a shell, a dialog, a direct-PTY session): nothing can be + * said. The prompt-cache keep-warm refuses to type unless this is true: its paste would land + * in whatever draft sits there, and the Enter would submit both. + * + * Pass a STYLED frame (`capture-pane -p -e`): Claude Code's prompt suggestion (on by default) + * fills an empty composer with faint text, and only the styling tells it from a typed draft. + * The paste replaces a suggestion rather than joining it (measured on 2.1.300). A plain frame + * still parses, but reads a suggestion as a draft. + */ +export function composerIsEmpty(screen: string, glyph: string): boolean | undefined { + if (!glyph) return undefined; + // The structure is read at column 0, before any trimming: Claude Code draws the glyph line and + // both rules there and indents every continuation line, so a draft line that holds the glyph or + // starts with a rule (` ❯`, ` ───`) can never pass for the composer's own edges. + const lines = plainFrame(screen).split('\n'); + let at = lines.length - 1; + while (at >= 0 && !lines[at].startsWith(glyph)) at--; + if (at < 0) return undefined; + const end = lines.findIndex((l, i) => i > at && l.startsWith('\u2500')); + if (end < 0) return undefined; + const typed = plainFrame(withoutSuggestion(screen)).split('\n'); + return compact(typed[at].slice(glyph.length) + typed.slice(at + 1, end).join('')).length === 0; +} + export interface SubmitVerifierDeps { /** The rendered pane, or null when there is none to read. */ capture: () => string | null | undefined; diff --git a/src/session.ts b/src/session.ts index 63ac4aa81..8ded07752 100644 --- a/src/session.ts +++ b/src/session.ts @@ -65,6 +65,8 @@ import { type SessionWriteOptions, type PaneExit, type PromptCacheStatus, + type StatusLineExporter, + type KeepWarmStatus, } from './types.js'; import { resolveAndClaimOmpSessionId } from './utils/omp-session-resolver.js'; import { claudeTranscriptExists } from './utils/claude-transcript.js'; @@ -127,6 +129,7 @@ import { DEFAULT_TMUX_HISTORY_LIMIT } from './config/terminal-history.js'; import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js'; import { getCli } from './config/cli-registry/registry.js'; import { SubmitVerifier } from './session-submit-verifier.js'; +import { KEEP_WARM_NUDGE } from './prompt-cache-keepwarm.js'; import { compileVersionRegex } from './config/cli-registry/patterns.js'; import { resolveSessionCliVersion } from './utils/cli-resolver.js'; import { @@ -453,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)`. @@ -597,6 +609,16 @@ export class Session extends EventEmitter { private _watching: string | null = null; /** Last `prompt_cache` report from the statusline exporter; null until Claude sends one. */ private _promptCache: PromptCacheStatus | null = null; + /** Prompt-cache keep-warm opt-in; undefined = follow the app-wide default. Persisted. */ + private _keepWarmEnabled: boolean | undefined; + /** Last status the keep-warm service published for this session; ephemeral. */ + private _keepWarm: KeepWarmStatus | null = null; + /** Until when the current turn counts as Codeman's own keep-alive (hooks open pre-acknowledged). */ + private _keepAliveTurnUntil = 0; + /** Last keystroke a person sent through a `fromUser` write. */ + private _lastHumanInputAt = 0; + /** Last submit that was not the keep-alive's own; the keep-warm cap's clock. Persisted. */ + private _lastRealPromptAt = 0; /** Lazily compiled `capabilities.workDetect.workingLine`. See _workingLinePattern(). */ private _workingLineRe: RegExp | undefined = undefined; /** Lazily compiled `capabilities.workDetect.watchingLine`. See _watchingLinePattern(). */ @@ -804,7 +826,7 @@ export class Session extends EventEmitter { // The session that spawned this one (tab lineage lines). Resolved by the create // route before it reaches here, so this is always either an id that existed at - // create time or undefined. Decoration only — see SessionState.parentSessionId. + // create time or undefined. Decoration only (plus the keep-warm default); see SessionState.parentSessionId. private readonly _parentSessionId?: string; // Session color for visual differentiation @@ -899,6 +921,10 @@ export class Session extends EventEmitter { attachmentHistory?: SessionAttachmentHistoryItem[]; /** Restored wall-clock ms of the pane's last Enter (see `lastSubmitAt`). */ lastSubmitAt?: number; + /** Restored last non-keep-alive submit (see `lastRealPromptAt`). */ + lastRealPromptAt?: number; + /** Restored prompt-cache keep-warm choice (see `keepWarmEnabled`). */ + keepWarmEnabled?: boolean; /** Restored conversation chain, oldest first (see `claudeSessionChain`). */ claudeSessionChain?: string[]; /** Restored agent-exit observation for this session's pane (see `paneExit`). */ @@ -975,6 +1001,10 @@ export class Session extends EventEmitter { // response viewer re-derive the live conversation without waiting for the // user to type again. this._lastSubmitAt = config.lastSubmitAt ?? 0; + // Restored so a restart neither reopens the keep-warm window nor drops the choice. A state.json + // from before the field has only `lastSubmitAt`, and every submit then was a real one. + this._lastRealPromptAt = config.lastRealPromptAt ?? config.lastSubmitAt ?? 0; + this._keepWarmEnabled = config.keepWarmEnabled; // Restored chain: its tail is the conversation the CLI was actually on when // the server stopped, which outranks the launch id seeded just above. The // FIRST-HAND flag is deliberately NOT restored — a persisted claim is not a @@ -1479,12 +1509,17 @@ export class Session extends EventEmitter { * report. It rides `toState()` for the Session Options readout and is restored on a * Codeman restart (constructor `promptCache`) so the row is not blank until the pane's * next render; the readout is time-aware, so a value whose `expiresAt` has passed renders - * as cold rather than as a stale warm. + * as cold rather than as a stale warm, and a future one lets the keep-warm re-arm without a turn. */ get promptCache(): PromptCacheStatus | null { return this._promptCache; } + /** Whether the current launch carries the statusLine exporter (`MuxSession.statusLine`); undefined when unknown. */ + get statusLineExporter(): StatusLineExporter | undefined { + return this._muxSession?.statusLine; + } + /** * Replace the prompt-cache report; true when it differs from the previous one. Stamps * `compactedAt` when this report ENTERS the window in which Claude Code reads @@ -1513,6 +1548,66 @@ export class Session extends EventEmitter { return changed; } + /** Prompt-cache keep-warm opt-in; undefined means the app-wide default applies. */ + get keepWarmEnabled(): boolean | undefined { + return this._keepWarmEnabled; + } + + setKeepWarmEnabled(enabled: boolean | undefined): void { + this._keepWarmEnabled = enabled; + } + + /** What the keep-warm service last published for this session, or null before its first evaluation. */ + get keepWarm(): KeepWarmStatus | null { + return this._keepWarm; + } + + /** Replace the keep-warm status; true when it differs from the previous one. */ + setKeepWarmStatus(status: KeepWarmStatus): boolean { + const changed = JSON.stringify(status) !== JSON.stringify(this._keepWarm); + this._keepWarm = status; + return changed; + } + + /** + * 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`. 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; + } + + beginKeepAliveTurn(windowMs: number): void { + 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; + } + + /** + * Last submit that was not the keep-alive's own, from any writer or the UserPromptSubmit hook + * (a person typing in an attached tmux client reaches Codeman only through the hook); epoch + * ms, 0 if none. The keep-warm cap counts from it; persisted so a restart keeps it. + */ + get lastRealPromptAt(): number { + return this._lastRealPromptAt; + } + + /** Whether this launch carries `idleCompaction: false` (`MuxSession.idleCompactionOff`). */ + get idleCompactionOptOut(): boolean { + return this._muxSession?.idleCompactionOff === true; + } + /** * Check if the session's process tree has active child processes beyond Claude itself. * Detects running bash tools, test suites, builds, servers, etc. that Claude spawned. @@ -2008,7 +2103,9 @@ export class Session extends EventEmitter { autoResumeEnabled: this._autoOps.autoResumeEnabled, autoResumeAt: this._autoOps.autoResumeAt ?? undefined, promptCache: this._promptCache ?? undefined, - statusLineExporter: this._muxSession?.statusLine, + statusLineExporter: this.statusLineExporter, + keepWarmEnabled: this._keepWarmEnabled, + keepWarm: this._keepWarm ?? undefined, imageWatcherEnabled: this._imageWatcherEnabled, pinned: this._pinned || undefined, pinnedAt: this._pinned ? (this._pinnedAt ?? undefined) : undefined, @@ -2057,6 +2154,7 @@ export class Session extends EventEmitter { paneExit: this._paneExit ?? undefined, attachmentHistory: this.attachmentHistory.length > 0 ? this.attachmentHistory : undefined, lastSubmitAt: this._lastSubmitAt || undefined, + lastRealPromptAt: this._lastRealPromptAt || undefined, // Only a chain the CLI's own hooks vouched for is persisted, and only when // the pane actually moved conversation. Its LAST entry is the live one, so // it is also what restores `claudeSessionId` across a restart — `start()` @@ -2425,6 +2523,8 @@ export class Session extends EventEmitter { unsetEnvKeys: this._pendingEnvUnsets.size > 0 ? [...this._pendingEnvUnsets] : undefined, effort: this._effort, advisorModel: this._advisorModel, + keepWarm: this._keepWarmEnabled, + spawnedByAgent: !!this._parentSessionId, historyLimit: this._tmuxHistoryLimit, remote: this._remote, docker: this._docker, @@ -2904,6 +3004,8 @@ export class Session extends EventEmitter { envOverrides: this._envOverrides, effort: this._effort, advisorModel: this._advisorModel, + keepWarm: this._keepWarmEnabled, + spawnedByAgent: !!this._parentSessionId, historyLimit: this._tmuxHistoryLimit, remote: this._remote, docker: this._docker, @@ -4650,9 +4752,16 @@ export class Session extends EventEmitter { */ private _trackSubmit(data: string, options: SessionWriteOptions): string[] { const submitted = options.fromUser && this._acceptsPrompts ? this._submittedPromptTracker.feed(data) : []; - if (data.includes('\r') || data.includes('\n')) { - this._lastSubmitAt = Date.now(); - } + const hasEnter = data.includes('\r') || data.includes('\n'); + if (options.fromUser) { + 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; } @@ -4678,9 +4787,20 @@ export class Session extends EventEmitter { * `_trackSubmit` only sees input that flows through Codeman's write path, so * a pane the user drives by attaching to tmux directly never stamped this and * `lastSubmitAt` stayed 0 for its whole life. + * + * `prompt` is the hook's own prompt text: anything but the keep-alive's ends the keep-alive + * window, because a person typing in an attached tmux client reaches Codeman only here, and + * their prompt must count as real and alert normally. */ - markPromptSubmitted(): void { + markPromptSubmitted(prompt?: string): void { + if (prompt !== undefined && prompt.trim() !== KEEP_WARM_NUDGE) this._keepAliveTurnUntil = 0; + this._stampSubmit(); + } + + /** Every submit moves `lastSubmitAt`; only one outside a keep-alive turn moves `lastRealPromptAt`. */ + private _stampSubmit(): void { this._lastSubmitAt = Date.now(); + if (!this.keepAliveTurnActive) this._lastRealPromptAt = this._lastSubmitAt; } /** diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index d3a2d1749..129f9d520 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -67,12 +67,15 @@ import { missingCliMessage, resolveCliBinDir } from './utils/cli-resolver.js'; import { buildSpawnCommandFromRegistry, configSetenvValues, + launchGatePasses, legacyConfigForMode, } from './session-cli-registry-bridge.js'; +import { keepWarmOn } from './prompt-cache-keepwarm.js'; import type { CliEntry } from './config/cli-registry/types.js'; import { resolveStatusLineCliCommand, readPlanUsageTelemetryEnabled, + readKeepWarmSettings, findEffectiveUserStatusLineCommand, } from './hooks-config.js'; import { @@ -1070,6 +1073,28 @@ export function buildCodexCommand(config?: CodexConfig): string { return buildSpawnCommandFromRegistry(entry, { mode: 'codex', sessionId: '', codexConfig: config }) ?? 'codex'; } +/** + * Whether a launch carries `idleCompaction: false` in its one `--settings` JSON: keep-warm on + * at launch (`keepWarmOn`), the statusLine exporter injected (without its `prompt_cache` reports + * keep-warm cannot run, and the opt-out alone would only cost the user Claude Code's own idle + * compaction), and the CLI's `idleCompactionSetting` gate passed, fail-closed like `--name`: + * an unknown version means no key. Pure; `claudeCliVersion` as in buildSpawnCommand. + */ +export function decideIdleCompactionOptOut(o: { + mode: SessionMode; + statusLine: StatusLineExporter | undefined; + keepWarm: boolean | undefined; + keepWarmDefault: boolean; + spawnedByAgent: boolean | undefined; + claudeCliVersion?: string | null; +}): boolean { + return ( + o.statusLine === 'injected' && + keepWarmOn(o.keepWarm, o.keepWarmDefault, !!o.spawnedByAgent) && + launchGatePasses(o.mode, 'idleCompactionSetting', o.claudeCliVersion) + ); +} + export function buildSpawnCommand(options: { mode: SessionMode; sessionId: string; @@ -1091,6 +1116,8 @@ export function buildSpawnCommand(options: { advisorModel?: string; /** Resolved by resolveStatusLineCliCommand (hooks-config.ts) — undefined skips the exporter. Claude only. */ statusLineCommand?: string; + /** Add `idleCompaction: false` to the launch's one `--settings` JSON (see decideIdleCompactionOptOut). */ + idleCompactionOff?: boolean; /** Name pinned on claude as `--name` (version-gated, sanitized; local spawns only). By default only a user-chosen name; `pinCliSessionName` pins every tab name: see `Session.cliPinnedName`. */ sessionName?: string; /** @@ -2266,6 +2293,28 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { return { command, state: command ? 'injected' : enabled ? 'workspace-statusline' : 'collection-off' }; } + /** + * Whether this launch carries `idleCompaction: false` (decideIdleCompactionOptOut), with the + * keep-warm app default read fresh like the plan-usage switch above. Logged when keep-warm is + * on but the version gate keeps the key off, so a session that may still idle-compact says why. + */ + private async _resolveIdleCompactionOptOut( + mode: SessionMode, + statusLine: StatusLineExporter | undefined, + keepWarm: boolean | undefined, + spawnedByAgent: boolean | undefined + ): Promise { + if (statusLine !== 'injected') return false; // no settings read for a launch that can never carry it + const { keepWarmDefault } = await readKeepWarmSettings(); + const off = decideIdleCompactionOptOut({ mode, statusLine, keepWarm, keepWarmDefault, spawnedByAgent }); + if (!off && keepWarmOn(keepWarm, keepWarmDefault, !!spawnedByAgent)) { + console.log( + `[TmuxManager] Keep-warm is on but Claude Code's version is unknown or predates the idleCompaction setting; launching without the opt-out` + ); + } + return off; + } + /** * Export the user's own REAL statusLine command (found by * findEffectiveUserStatusLineCommand) via tmux setenv, so the shared @@ -2326,6 +2375,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { envOverrides, effort, advisorModel, + keepWarm, + spawnedByAgent, historyLimit = DEFAULT_TMUX_HISTORY_LIMIT, remote, docker, @@ -2389,6 +2440,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { workingDir, !!(remote || docker) ); + const idleCompactionOff = await this._resolveIdleCompactionOptOut(mode, statusLine, keepWarm, spawnedByAgent); // The user's own REAL statusLine, if any (walked via Claude Code's own // settings precedence) — exported below so the shared exporter script // can wrap it. Only worth discovering when we're actually injecting. @@ -2413,6 +2465,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { effort, advisorModel, statusLineCommand, + idleCompactionOff, sessionName: cliName, }); @@ -2554,6 +2607,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { attached: false, name, statusLine, + idleCompactionOff, }; this.sessions.set(sessionId, session); @@ -2664,6 +2718,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { unsetEnvKeys, effort, advisorModel, + keepWarm, + spawnedByAgent, remote, docker, cliName, @@ -2684,6 +2740,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { workingDir, !!(remote || docker) ); + const idleCompactionOff = await this._resolveIdleCompactionOptOut(mode, statusLine, keepWarm, spawnedByAgent); const userStatusLineCommand = statusLineCommand ? await findEffectiveUserStatusLineCommand(workingDir) : undefined; const baseCmd = buildSpawnCommand({ @@ -2705,6 +2762,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { effort, advisorModel, statusLineCommand, + idleCompactionOff, sessionName: cliName, }); const config = niceConfig || DEFAULT_NICE_CONFIG; @@ -2747,8 +2805,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { this.clearPaneExit(muxName); const pid = this.getPanePid(muxName); if (pid) session.pid = pid; - if (session.statusLine !== statusLine) { + if (session.statusLine !== statusLine || session.idleCompactionOff !== idleCompactionOff) { session.statusLine = statusLine; + session.idleCompactionOff = idleCompactionOff; this.saveSessions(); } return pid; @@ -4084,17 +4143,19 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { /** * Plain visible-frame text for the working/idle probe (see `session.ts`). * - * One `capture-pane` and nothing else: no `-e` styles, no `display-message` - * cursor query, no repaint reconstruction: this feeds a regex, not a - * terminal. Returns null in tests (no tmux) so callers fall back to their - * stream heuristics rather than reading an empty screen as "not working". + * One `capture-pane` and nothing else: no `display-message` cursor query, no + * repaint reconstruction: this feeds a regex, not a terminal. `styles` adds + * `-e` for a reader that must tell faint text from typed text (the keep-warm + * composer check); lines stay `\n`-separated either way. Returns null in tests + * (no tmux) so callers fall back to their stream heuristics rather than + * reading an empty screen as "not working". */ - capturePaneText(muxName: string, paneTarget?: string): string | null { + capturePaneText(muxName: string, paneTarget?: string, opts?: { styles?: boolean }): string | null { if (IS_TEST_MODE) return null; const target = resolveTmuxPaneTarget(muxName, paneTarget); if (!target) return null; try { - return execSync(`${this.tmux()} capture-pane -p -t ${shellescape(target)}`, { + return execSync(`${this.tmux()} capture-pane -p${opts?.styles ? ' -e' : ''} -t ${shellescape(target)}`, { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS, }); diff --git a/src/types/session.ts b/src/types/session.ts index 1d701bcd8..7186b644e 100644 --- a/src/types/session.ts +++ b/src/types/session.ts @@ -797,6 +797,33 @@ export interface PromptCacheStatus { */ export type StatusLineExporter = 'injected' | 'collection-off' | 'workspace-statusline' | 'remote'; +/** + * What the prompt-cache keep-warm (`prompt-cache-keepwarm.ts`) is doing for a session, for + * the Session Options readout. Ephemeral: published through `toState()` and the + * `session:keepWarm` event, never restored. + */ +export interface KeepWarmStatus { + /** Effective opt-in: the session's own flag, else the app-wide default (see `keepWarmOn`). */ + enabled: boolean; + /** + * Whether this launch carries `idleCompaction: false` (`MuxSession.idleCompactionOff`). False + * on a session switched on after launch: Claude Code's own idle compaction may still run + * until its next launch. + */ + idleCompactionOff: boolean; + /** `armed` = waiting for the due time, `waiting` = due but deferred by a guard, `nudged` = sent, waiting for the next cache report. */ + state: 'off' | 'armed' | 'waiting' | 'nudged' | 'stopped'; + /** Next evaluation, epoch ms (armed, waiting, nudged). */ + nextCheckAt?: number; + /** Why it is waiting or stopped, in words. */ + reason?: string; + /** Nudges sent this session so far. */ + nudges: number; + lastNudgeAt?: number; + /** When the keep-warm window after the last real prompt closes, epoch ms. */ + budgetEndsAt?: number; +} + export interface SessionState { /** Unique session identifier */ id: string; @@ -819,7 +846,9 @@ export interface SessionState { * * ⚠️ UI DECORATION ONLY — it draws the lineage lines between tabs. It is never an * ownership, permission, or lifecycle signal: a child outlives its parent, and an - * unresolvable value is dropped rather than failing the spawn. + * unresolvable value is dropped rather than failing the spawn. Its one non-visual reader + * picks a default, nothing more: a session an agent spawned does not inherit the app-wide + * prompt-cache keep-warm default (`keepWarmOn`), though its own switch still works. */ parentSessionId?: string; /** ID of currently assigned task, null if none */ @@ -858,6 +887,10 @@ export interface SessionState { promptCache?: PromptCacheStatus; /** Why a cache report will or will not arrive (see `StatusLineExporter`); absent when unknown. */ statusLineExporter?: StatusLineExporter; + /** Prompt-cache keep-warm opt-in; absent = follow the app-wide `promptCacheKeepWarm` default. */ + keepWarmEnabled?: boolean; + /** What the keep-warm is doing right now; ephemeral, never restored. */ + keepWarm?: KeepWarmStatus; /** Pinned to the top of the session manager list (COD-139) */ pinned?: boolean; /** When the session was pinned (epoch ms) — orders the pinned group, most-recent-first */ @@ -972,6 +1005,13 @@ export interface SessionState { * again until the pane's own Enter is known. */ lastSubmitAt?: number; + /** + * Wall-clock ms of the last submit that was NOT the prompt-cache keep-alive + * (Session.lastRealPromptAt): the keep-warm cap's clock. Persisted so a restart + * does not reopen the window, which `lastSubmitAt` cannot do since every nudge + * moves it. + */ + lastRealPromptAt?: number; /** * Claude conversations this pane has been on, oldest first, current last. * Written ONLY from a first-hand `UserPromptSubmit`/`Stop` hook payload — diff --git a/src/web/approval-inbox.ts b/src/web/approval-inbox.ts index c691fc985..4d6aacbc4 100644 --- a/src/web/approval-inbox.ts +++ b/src/web/approval-inbox.ts @@ -123,6 +123,12 @@ interface NotePromptArgs { * `ApprovalItem.answerInTerminal`. */ answerInTerminal?: boolean; + /** + * An idle prompt that should open ALREADY acknowledged for a reason other than + * `watching`, e.g. `cache keep-alive` when the turn that just ended was Codeman's own + * prompt-cache nudge. Same consequence as `watching`: the item exists, only its alert is spent. + */ + preAcknowledged?: string; /** Returns the raw (ANSI-bearing) pane frame, or null when unavailable. */ capture?: () => string | null; /** @@ -285,9 +291,9 @@ export class ApprovalInbox { cwd: args.cwd, ...(args.answerInTerminal ? { answerInTerminal: true } : {}), }; - if (args.kind === 'idle' && args.watching) { + if (args.kind === 'idle' && (args.preAcknowledged || args.watching)) { item.acknowledgedAt = item.createdAt; - item.acknowledgedReason = `watching ${args.watching}`; + item.acknowledgedReason = args.preAcknowledged ?? `watching ${args.watching}`; } if (!args.capture && args.context) item.context = normalizeCapturedFrame(args.context); this.applyCapture(item, args.capture); diff --git a/src/web/ports/index.ts b/src/web/ports/index.ts index c75b8c0d0..c94995530 100644 --- a/src/web/ports/index.ts +++ b/src/web/ports/index.ts @@ -15,3 +15,4 @@ export type { AuthPort } from './auth-port.js'; export type { OrchestratorPort } from './orchestrator-port.js'; export type { CronPort } from './cron-port.js'; export type { TabLayoutPort } from './tab-layout-port.js'; +export type { KeepWarmPort } from './keep-warm-port.js'; diff --git a/src/web/ports/keep-warm-port.ts b/src/web/ports/keep-warm-port.ts new file mode 100644 index 000000000..fd5c8fdc5 --- /dev/null +++ b/src/web/ports/keep-warm-port.ts @@ -0,0 +1,10 @@ +/** + * @fileoverview Port for the prompt-cache keep-warm scheduler (`prompt-cache-keepwarm.ts`). + * Routes only ever trigger a re-evaluation; the rulebook and the timers stay in the service. + */ + +import type { KeepWarmScheduler } from '../../prompt-cache-keepwarm.js'; + +export interface KeepWarmPort { + readonly keepWarm: KeepWarmScheduler; +} diff --git a/src/web/public/app.js b/src/web/public/app.js index 627164971..16844f13d 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -194,6 +194,7 @@ const _SSE_HANDLER_MAP = [ [SSE_EVENTS.SESSION_CLI_INFO, '_onSessionCliInfo'], [SSE_EVENTS.SESSION_STATUS_TELEMETRY, '_onSessionStatusTelemetry'], [SSE_EVENTS.SESSION_PROMPT_CACHE, '_onSessionPromptCache'], + [SSE_EVENTS.SESSION_KEEP_WARM, '_onSessionKeepWarm'], // Scheduled runs [SSE_EVENTS.SCHEDULED_CREATED, '_onScheduledCreated'], @@ -3469,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(() => { @@ -3492,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 cd44962f0..4cb2f099a 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -733,7 +733,7 @@ function computeConnectionLossUi(input) { // it says why instead of "not reported yet" forever. const PROMPT_CACHE_NOT_COLLECTED = { 'collection-off': - 'Not collected: Plan Usage was off when this session started. Turn it on in App Settings; sessions started after that report it.', + 'Not collected: Plan Usage was off when this session started. Turn it on in App Settings → Header & Panels, then start a new session; this one cannot pick it up.', 'workspace-statusline': "Not collected: this workspace's .claude/settings.local.json sets its own statusLine, which Codeman never replaces.", remote: 'Not collected for remote and Docker sessions.', @@ -763,6 +763,51 @@ function formatPromptCacheStatus(cache, now, statusLine) { return parts.join(' '); } +// Session Options → Respawn → Prompt cache → Status: the keep-warm line under the cache +// readout, saying what the keep-warm service is doing (`session.keepWarm`, published by the +// server on `session:keepWarm`), compact and dot-separated. Empty when there is nothing to +// say: switched off, not published yet, or a launch without the statusLine exporter, where +// the readout's own "Not collected" sentence on the same row already says why and +// session-ui.js greys the switch. Pure: no DOM, `now` is passed in. +function formatKeepWarmStatus(status, now, statusLine) { + if (PROMPT_CACHE_NOT_COLLECTED[statusLine]) return ''; + if (!status || typeof status.state !== 'string') return ''; + const clock = (ms) => new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + const keepWarmWindow = Number.isFinite(status.budgetEndsAt) + ? status.budgetEndsAt > now + ? `stops ${clock(status.budgetEndsAt)}` + : 'window closed' + : ''; + // 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 === true + ? 'idle compaction off until relaunch' + : ''; + let parts; + switch (status.state) { + 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)}`, keepWarmWindow, idleCompaction]; + break; + case 'waiting': + parts = ['waiting', status.reason || 'retrying', `retry ${clock(status.nextCheckAt)}`, keepWarmWindow, idleCompaction]; + break; + case 'nudged': + parts = [`sent ${clock(status.lastNudgeAt)} (${status.nudges}\u00d7)`, keepWarmWindow, idleCompaction]; + break; + default: + parts = ['stopped', status.reason || 'nothing to do', idleCompaction]; + } + return `Keep-alive: ${parts.filter(Boolean).join(' \u00b7 ')}`; +} + // SSE staleness policy: is this stream a zombie? // // An EventSource that stops delivering does not always error. A proxy that @@ -1503,6 +1548,8 @@ if (typeof window !== 'undefined') { }; window.CodemanPromptCache = { format: formatPromptCacheStatus, + formatKeepWarm: formatKeepWarmStatus, + keepWarmUnavailable: (statusLine) => !!PROMPT_CACHE_NOT_COLLECTED[statusLine], }; window.CodemanSessionOrder = { RANK: SESSION_ACTIVITY_RANK, @@ -1699,6 +1746,7 @@ const SSE_EVENTS = { SESSION_RUNNING: 'session:running', SESSION_STATUS_TELEMETRY: 'session:statusTelemetry', SESSION_PROMPT_CACHE: 'session:promptCache', + SESSION_KEEP_WARM: 'session:keepWarm', // Scheduled runs SCHEDULED_CREATED: 'scheduled:created', diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index 14db14899..c92e5eefd 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -962,6 +962,19 @@ 'Enable Respawn': '启用重生', 'Stop Respawn': '停止重生', 'Auto-resume when usage limit resets': '使用限制重置后自动继续', + // Prompt-cache keep-warm (Session Options → Respawn, App Settings → System → Automation, session-ui.js toasts). + 'Keep the cache warm while idle': '空闲时保持缓存预热', + "Sends a one-line keep-alive just before the cache expires, so your next prompt still reads from cache. Runs only while idle, up to the limit in App Settings → System → Automation; switch on before launch to also stop Claude Code's own idle compaction.": + '在缓存即将过期前发送一行保活消息,让您的下一条提示仍能从缓存读取。仅在空闲时运行,最长不超过“应用设置 → 系统 → 自动化”中的上限;在启动前开启还会关闭 Claude Code 自带的空闲压缩。', + 'Keep prompt caches warm': '保持提示缓存预热', + "Default for Claude sessions that have not chosen on their Respawn tab (a session an agent started needs its own switch): shortly before an idle session's prompt cache would expire, send a one-line keep-alive turn so the next prompt still reads the conversation from cache instead of re-processing it.": + '未在“重生”标签页中单独设置的 Claude 会话的默认值(由智能体启动的会话需单独开启):在空闲会话的提示缓存即将过期前发送一行保活消息,让下一条提示仍从缓存读取对话,而不必重新处理。', + 'Keep warm for at most': '最长保持预热', + 'Hours after your last prompt in a session during which keep-alives are sent; past that the cache is left to expire. A keep-alive costs a small fraction of the re-write it prevents, so a few hours of them pay off, an overnight gap does not.': + '会话中最后一条提示之后发送保活消息的小时数;超过之后任由缓存过期。一次保活的开销只是它所避免的重新写入的一小部分,因此几个小时的保活是值得的,整夜的空档则不值得。', + hours: '小时', + 'Prompt-cache keep-warm enabled': '提示缓存预热已开启', + 'Prompt-cache keep-warm disabled': '提示缓存预热已关闭', 'Auto-restart sessions when context fills up (usually not needed)': '上下文已满时自动重启会话(通常不需要)', 'Auto-Compact': '自动压缩', 'Auto-Clear': '自动清空', diff --git a/src/web/public/index.html b/src/web/public/index.html index abbd2cd9d..58b9c8f7d 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -1334,8 +1334,19 @@

Respawn

Status Not reported yet. +
+ +
+
+ Keep the cache warm while idle + Sends a one-line keep-alive just before the cache expires, so your next prompt still reads from cache. Runs only while idle, up to the limit in App Settings → System → Automation; switch on before launch to also stop Claude Code's own idle compaction. +
+ +
@@ -3252,6 +3263,23 @@

System

+
+
+ Keep prompt caches warm + Default for Claude sessions that have not chosen on their Respawn tab (a session an agent started needs its own switch): shortly before an idle session's prompt cache would expire, send a one-line keep-alive turn so the next prompt still reads the conversation from cache instead of re-processing it. +
+ +
+
+
+ Keep warm for at most + Hours after your last prompt in a session during which keep-alives are sent; past that the cache is left to expire. A keep-alive costs a small fraction of the re-write it prevents, so a few hours of them pay off, an overnight gap does not. +
+
+ + hours +
+
diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index 6067dc0ba..5009c2d53 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -2631,6 +2631,10 @@ Object.assign(CodemanApp.prototype, { document.getElementById('modalAutoResumeEnabled').checked = session.autoResumeEnabled ?? false; this.updateAutoResumeStatus(sessionId); this.updatePromptCacheStatus(sessionId); + // Effective opt-in: the server publishes it in the status (session flag, else the app default). + document.getElementById('modalKeepWarmEnabled').checked = + session.keepWarm?.enabled ?? session.keepWarmEnabled ?? false; + this.updateKeepWarmStatus(sessionId); document.getElementById('modalImageWatcherEnabled').checked = session.imageWatcherEnabled ?? true; document.getElementById('modalFlickerFilterEnabled').checked = session.flickerFilterEnabled ?? false; @@ -2915,6 +2919,59 @@ Object.assign(CodemanApp.prototype, { this.updatePromptCacheStatus(sessionId); }, + /** + * Session Options → Respawn → Prompt cache: the keep-warm line on the Status row (what the + * keep-warm service is doing, `session.keepWarm`, worded by `CodemanPromptCache.formatKeepWarm`, + * hidden when it has nothing to say) and the state of the "Keep the cache warm while idle" switch. + */ + updateKeepWarmStatus(sessionId) { + const el = document.getElementById('keepWarmStatus'); + if (!el || this.editingSessionId !== sessionId) return; + const session = this.sessions.get(sessionId); + const status = session?.keepWarm; + const text = window.CodemanPromptCache.formatKeepWarm(status, Date.now(), session?.statusLineExporter); + el.textContent = text; + el.hidden = !text; + // A launch without cache reports can never keep warm: grey the row and refuse to switch it on, + // but a switch that is already on stays reachable so it can be turned off. Same inert-switch + // pattern as the context switch in settings-ui.js _applyModelSelection(). + const box = document.getElementById('modalKeepWarmEnabled'); + if (!box) return; + const inert = window.CodemanPromptCache.keepWarmUnavailable(session?.statusLineExporter); + box.closest('.set-row')?.classList.toggle('set-row-disabled', inert); + box.disabled = inert && !box.checked; + }, + + _onSessionKeepWarm(data) { + const { sessionId, ...status } = data; + const session = this.sessions.get(sessionId); + if (!session) return; + session.keepWarm = status; + if (this.editingSessionId === sessionId) { + const box = document.getElementById('modalKeepWarmEnabled'); + if (box) box.checked = status.enabled; + } + this.updateKeepWarmStatus(sessionId); + }, + + async autoSaveKeepWarm() { + if (!this.editingSessionId) return; + const enabled = document.getElementById('modalKeepWarmEnabled').checked; + try { + // _apiPost unwraps the envelope; the route answers with the status for the NEW choice. + const data = await this._apiPost(`/api/sessions/${this.editingSessionId}/keep-warm`, { enabled }); + const session = this.sessions.get(this.editingSessionId); + if (session) { + session.keepWarmEnabled = enabled; + if (data?.keepWarm?.status) session.keepWarm = data.keepWarm.status; + } + this.updateKeepWarmStatus(this.editingSessionId); + this.showToast(`Prompt-cache keep-warm ${enabled ? 'enabled' : 'disabled'}`, 'success'); + } catch (err) { + this.showToast('Failed to toggle keep-warm: ' + err.message, 'error'); + } + }, + async toggleSessionImageWatcher() { if (!this.editingSessionId) return; const enabled = document.getElementById('modalImageWatcherEnabled').checked; diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index d1343edfc..926ea9846 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); @@ -597,6 +601,9 @@ Object.assign(CodemanApp.prototype, { document.getElementById('appSettingsSubagentTracking').checked = settings.subagentTrackingEnabled ?? defaults.subagentTrackingEnabled ?? true; document.getElementById('appSettingsSubagentActiveTabOnly').checked = settings.subagentActiveTabOnly ?? defaults.subagentActiveTabOnly ?? true; document.getElementById('appSettingsImageWatcherEnabled').checked = settings.imageWatcherEnabled ?? defaults.imageWatcherEnabled ?? false; + // Prompt-cache keep-warm: synced, default OFF, cap default 2 h (DEFAULT_KEEP_WARM_MAX_HOURS). + document.getElementById('appSettingsPromptCacheKeepWarm').checked = settings.promptCacheKeepWarm === true; + document.getElementById('appSettingsPromptCacheKeepWarmMaxHours').value = settings.promptCacheKeepWarmMaxHours ?? 2; document.getElementById('appSettingsTunnelEnabled').checked = settings.tunnelEnabled ?? false; this.loadTunnelStatus(); document.getElementById('appSettingsLocalEcho').checked = settings.localEchoEnabled ?? MobileDetection.isTouchDevice(); @@ -2760,6 +2767,10 @@ Object.assign(CodemanApp.prototype, { subagentTrackingEnabled: document.getElementById('appSettingsSubagentTracking').checked, subagentActiveTabOnly: document.getElementById('appSettingsSubagentActiveTabOnly').checked, imageWatcherEnabled: document.getElementById('appSettingsImageWatcherEnabled').checked, + promptCacheKeepWarm: document.getElementById('appSettingsPromptCacheKeepWarm').checked, + promptCacheKeepWarmMaxHours: + // 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 2cc594d70..4e17a76ba 100644 --- a/src/web/routes/hook-event-routes.ts +++ b/src/web/routes/hook-event-routes.ts @@ -143,7 +143,8 @@ export function registerHookEventRoutes( // bumped only by input that flowed through Codeman's own write path, so // it read 0 forever for those panes and every consumer of "when did this // pane last submit" silently degraded. - session?.markPromptSubmitted(); + // The prompt text tells a person's prompt from the keep-alive's (prompt-cache-keepwarm.ts). + session?.markPromptSubmitted(typeof data.prompt === 'string' ? data.prompt : undefined); } // Persist when the conversation actually moved: `/clear` emits no // completion event, so without this the successor id is lost on restart @@ -183,9 +184,21 @@ export function registerHookEventRoutes( // seeds from /api/approvals), and it suppresses the push: an alert nobody can answer // is worth even less on a phone than in a tab. let acknowledgedReason: string | undefined; + // A turn Codeman started itself (the prompt-cache keep-alive, prompt-cache-keepwarm.ts) + // ends with the same stop hook and idle prompt as a real one. Both open pre-acknowledged, + // so no tab, toast or push asks a person to look at a session that wants nothing from them. + 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) @@ -209,6 +222,7 @@ export function registerHookEventRoutes( // session that is watching its own work opens acknowledged, so it never arms an // alert nobody can answer; notePrompt() carries the whole reasoning. watching: session.watching, + preAcknowledged: keepAliveReason, // Visible tmux frame first (it IS the dialog); raw byte-buffer tail as // the fallback for direct-PTY sessions and the no-op test mux. capture: () => { @@ -219,11 +233,16 @@ 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, timestamp: Date.now(), diff --git a/src/web/routes/reboot-restore-routes.ts b/src/web/routes/reboot-restore-routes.ts index a989fb4d7..7945e96d8 100644 --- a/src/web/routes/reboot-restore-routes.ts +++ b/src/web/routes/reboot-restore-routes.ts @@ -201,6 +201,8 @@ export function registerRebootRestoreRoutes(app: FastifyInstance, ctx: RebootRes (saved as { __attachmentHistory?: SessionAttachmentHistoryItem[] }).__attachmentHistory ?? saved.attachmentHistory, lastSubmitAt: saved.lastSubmitAt, + lastRealPromptAt: saved.lastRealPromptAt, + keepWarmEnabled: saved.keepWarmEnabled, claudeSessionChain: saved.claudeSessionChain, lastActivityAt: saved.lastActivityAt, owner: saved.owner, diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 0a794d8a9..d8dbb40cc 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -55,6 +55,7 @@ import { AutoClearSchema, AutoCompactSchema, AutoResumeSchema, + KeepWarmSchema, PinSessionSchema, ImageWatcherSchema, FlickerFilterSchema, @@ -155,7 +156,15 @@ import { setHistoryIndexRefresher, setHistorySessionIndex, } from '../session-history-index.js'; -import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort, TabLayoutPort } from '../ports/index.js'; +import type { + SessionPort, + EventPort, + ConfigPort, + InfraPort, + AuthPort, + TabLayoutPort, + KeepWarmPort, +} from '../ports/index.js'; import { RunSummaryTracker } from '../../run-summary.js'; import { MAX_INPUT_LENGTH, MAX_SESSION_NAME_LENGTH } from '../../config/terminal-limits.js'; @@ -1024,7 +1033,7 @@ function wakeableHost(host: RemoteHost): WakeableRemote { export function registerSessionRoutes( app: FastifyInstance, - ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort & TabLayoutPort, + ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort & TabLayoutPort & KeepWarmPort, /** Test seam: inject a registry with fake IO instead of the real TCP/WoL probes. */ options: { remoteWake?: RemoteWakeRegistry } = {} ): RemoteWakeRegistry { @@ -3556,6 +3565,39 @@ export function registerSessionRoutes( }; }); + // ========== Prompt-cache keep-warm ========== + + // Per-session opt-in for the keep-alive nudge (prompt-cache-keepwarm.ts). `enabled: null` + // clears the choice so the app-wide default applies again. Refused for a CLI that reports + // no cache telemetry on its statusline: without `expires_at` there is nothing to schedule. + app.post('/api/sessions/:id/keep-warm', async (req) => { + const { id } = req.params as { id: string }; + const body = parseBody(KeepWarmSchema, req.body, 'Invalid request body'); + const session = findSessionOrFail(ctx, id, req); + if (!getCli(session.mode)?.capabilities.statusLineTelemetry) { + return createErrorResponse( + ApiErrorCode.INVALID_INPUT, + 'Prompt-cache keep-warm needs the cache telemetry this CLI does not report' + ); + } + + session.setKeepWarmEnabled(body.enabled ?? undefined); + // Re-evaluate BEFORE the state broadcast and the response: both carry `keepWarm`, and a + // page that received the previous status would overwrite the one the toggle just produced. + await ctx.keepWarm.schedule(id); + persistAndBroadcastSession(ctx, session); + + return { + success: true, + data: { + keepWarm: { + enabled: session.keepWarmEnabled ?? null, + status: session.keepWarm, + }, + }, + }; + }); + // ========== Auto-Resume (usage-limit pause) ========== app.post('/api/sessions/:id/auto-resume', async (req) => { diff --git a/src/web/routes/status-telemetry-routes.ts b/src/web/routes/status-telemetry-routes.ts index a93cb4e18..06c34001a 100644 --- a/src/web/routes/status-telemetry-routes.ts +++ b/src/web/routes/status-telemetry-routes.ts @@ -29,10 +29,10 @@ import { } from '../../usage-telemetry.js'; import { SessionPromptCache, SessionStatusTelemetry } from '../sse-events.js'; import { setLatestPlanUsage } from '../plan-usage-latest.js'; -import type { SessionPort, EventPort } from '../ports/index.js'; +import type { SessionPort, EventPort, KeepWarmPort } from '../ports/index.js'; import { getCli } from '../../config/cli-registry/index.js'; -export function registerStatusTelemetryRoutes(app: FastifyInstance, ctx: SessionPort & EventPort): void { +export function registerStatusTelemetryRoutes(app: FastifyInstance, ctx: SessionPort & EventPort & KeepWarmPort): void { // Last broadcast telemetry signature per session — the statusline fires on // every assistant message, so we only rebroadcast when the value changes. const lastSig = new Map(); @@ -74,10 +74,14 @@ export function registerStatusTelemetryRoutes(app: FastifyInstance, ctx: Session // arrive out of order; a stale one then briefly overwrites newer state until the next // render corrects it. Accepted for a display-only readout rather than ordered here. const promptCache = reportsStatusLine ? parsePromptCache(payload) : null; - if (promptCache && session?.setPromptCache(promptCache)) { - ctx.broadcast(SessionPromptCache, { sessionId, ...session.promptCache }); - // Persisted on change, like a model change, so a crash restores the latest report. - ctx.persistSessionState(session); + if (promptCache) { + if (session?.setPromptCache(promptCache)) { + ctx.broadcast(SessionPromptCache, { sessionId, ...session.promptCache }); + // Persisted on change, like a model change, so a crash restores the latest report. + ctx.persistSessionState(session); + } + // Every report moves `expires_at`, so the keep-warm re-evaluates on each one. + void ctx.keepWarm.schedule(sessionId); } // Plan-usage limits (account-wide) → broadcast to the header chip, when diff --git a/src/web/routes/system-routes.ts b/src/web/routes/system-routes.ts index b62f717be..0359a8c90 100644 --- a/src/web/routes/system-routes.ts +++ b/src/web/routes/system-routes.ts @@ -51,7 +51,15 @@ import { SseEvent } from '../sse-events.js'; import { getInstallInfo, checkForUpdate, startUpdate, getUpdateStatusForApi } from '../self-update.js'; import { getRepositoryStatus } from '../repo-status.js'; -import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort, TabLayoutPort } from '../ports/index.js'; +import type { + SessionPort, + EventPort, + ConfigPort, + InfraPort, + AuthPort, + TabLayoutPort, + KeepWarmPort, +} from '../ports/index.js'; import { AUTH_COOKIE_NAME } from '../middleware/auth.js'; import { QR_AUTH_FAILURE_MAX } from '../../config/tunnel-config.js'; import { AUTH_SESSION_TTL_MS } from '../../config/auth-config.js'; @@ -168,7 +176,7 @@ export function settingsForNonAdmin(settings: unknown): unknown { export function registerSystemRoutes( app: FastifyInstance, - ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort & TabLayoutPort + ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort & TabLayoutPort & KeepWarmPort ): void { const windowStatesPath = dataPath('subagent-window-states.json'); const parentMapPath = dataPath('subagent-parents.json'); @@ -1103,6 +1111,11 @@ 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 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(); + } // Handle ultracode/workflow run watcher toggle dynamically (default OFF). // Either the docked panel OR the floating windows keep the watcher running. diff --git a/src/web/schemas.ts b/src/web/schemas.ts index ce577394d..5cd3503e7 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -1483,6 +1483,10 @@ export const SettingsUpdateSchema = z * broadcast and /api/search, which is the user's choice to make. */ autoNameSessions: z.boolean().optional(), + /** Prompt-cache keep-warm default for sessions that have not chosen (Session Options overrides it). */ + promptCacheKeepWarm: z.boolean().optional(), + /** Hours after the last real prompt during which keep-alives are sent (default 2, DEFAULT_KEEP_WARM_MAX_HOURS). */ + promptCacheKeepWarmMaxHours: z.number().min(0.5).max(24).optional(), /** * Spawned tabs follow their parent: a session created by another one keeps * following its parent's tab (into its group, moving with it) until moved by @@ -1871,6 +1875,9 @@ 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 f9bb8b47b..4148bddac 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -96,7 +96,12 @@ import { RunSummaryTracker } from '../run-summary.js'; import { PlanOrchestrator } from '../plan-orchestrator.js'; import { OrchestratorLoop } from '../orchestrator-loop.js'; import { getLifecycleLog } from '../session-lifecycle-log.js'; -import { applyWorkspaceHooks, pruneAgentSessionPreambles, removeAgentSessionPreamble } from '../hooks-config.js'; +import { + applyWorkspaceHooks, + pruneAgentSessionPreambles, + readKeepWarmSettingsOrThrow, + removeAgentSessionPreamble, +} from '../hooks-config.js'; import { PushSubscriptionStore } from '../push-store.js'; import webpush from 'web-push'; import { SseStreamManager } from './sse-stream-manager.js'; @@ -113,6 +118,9 @@ import { intentStore } from '../intent-store.js'; import { AI_CHECK_MODEL } from '../config/ai-defaults.js'; import { approvalInbox } from './approval-inbox.js'; import { SCREEN_BLOCKED_SOURCE } from './screen-blocked.js'; +import { PromptCacheKeepWarmService } from '../prompt-cache-keepwarm.js'; +import { composerIsEmpty, plainFrame } from '../session-submit-verifier.js'; +import { detectUsageLimitPause } from '../usage-limit-patterns.js'; import { stopDeepSeekWeb } from '../deepseek-web-server.js'; import { wireRespawnListeners, @@ -317,6 +325,50 @@ export class WebServer extends EventEmitter { private _customModelDisplacedNotified: Set = new Set(); /** Cron service (assigned in setupRoutes). */ private cronService!: CronService; + // Prompt-cache keep-warm (prompt-cache-keepwarm.ts): the rulebook and timers live in the + // service; the server only lends it the facts a decision needs. A field initializer, not a + // setupRoutes() step: cleanup and listener wiring reach it on servers that never set up routes + // (the pane-exit and discard tests build a bare WebServer), and every port reads `this.*` lazily. + private readonly keepWarmService = new PromptCacheKeepWarmService({ + getSession: (id) => this.sessions.get(id), + sessionIds: () => this.sessions.keys(), + readSettings: readKeepWarmSettingsOrThrow, + isAutomationActive: (id) => { + const session = this.sessions.get(id); + if (!session) return true; + return ( + (this.respawnControllers.get(id)?.isRunning ?? false) || + session.ralphTracker.loopState.active || + session.currentTaskId !== null || + sessionWaits.signalWaiterCount(id) > 0 + ); + }, + hasPendingApproval: (id) => + approvalInbox.listPending().some((item) => item.sessionId === id && item.kind !== 'idle'), + readPane: (view) => { + const session = view as Session; + const muxName = session.muxName; + // `capture-pane -p -e`: the visible frame with its styles, which is how `composerIsEmpty()` + // tells Claude Code's faint prompt suggestion from a typed draft. + const text = muxName ? (this.mux.capturePaneText?.(muxName, undefined, { styles: true }) ?? null) : null; + if (!text) return { composerEmpty: null, limitShown: false }; + const limit = detectUsageLimitPause(plainFrame(text)); + return { + composerEmpty: composerIsEmpty(text, getCli(session.mode)?.capabilities.workDetect?.promptGlyph ?? '') ?? null, + limitShown: limit !== null && limit.resetAt > Date.now(), + }; + }, + publish: (id, status) => { + const session = this.sessions.get(id); + if (!session?.setKeepWarmStatus(status)) return; + this.broadcast(SseEvent.SessionKeepWarm, { sessionId: id, ...status }); + // Change-only, so one line per transition names whichever guard holds it now. + if (status.state !== 'off' || status.reason !== 'off') { + console.log(`[KeepWarm] ${id.slice(0, 8)}: ${status.state}${status.reason ? ` (${status.reason})` : ''}`); + } + }, + log: (message) => console.log(message), + }); /** * Wake-on-LAN registry, returned by `registerSessionRoutes`. Held for its LIFETIME * only — `drop()` on session cleanup, `stop()` on shutdown. Waking from here would @@ -747,6 +799,7 @@ export class WebServer extends EventEmitter { return { // SessionPort sessions: this.sessions as ReadonlyMap, + keepWarm: this.keepWarmService, addSession: this.registerSessionWithLayout.bind(this), tabLayouts: this.tabLayouts, cleanupSession: this.cleanupSession.bind(this), @@ -1416,6 +1469,7 @@ export class WebServer extends EventEmitter { coordinateLayout = true ): Promise { const session = this.sessions.get(sessionId); + this.keepWarmService.remove(sessionId); const pinned = session?.pinned === true || this.store.getSession(sessionId)?.pinned === true; if (coordinateLayout && session && killMux && !pinned) { return this.tabLayouts.runSessionDeletion([{ id: sessionId, owner: session.owner }], () => @@ -1886,6 +1940,9 @@ export class WebServer extends EventEmitter { } private async setupSessionListeners(session: Session): Promise { + // First keep-warm evaluation: publishes the effective opt-in for the Session Options + // readout even before any cache report arrives. + void this.keepWarmService.schedule(session.id); // Idempotent: the wiring exit handler detaches ALL listeners on every PTY exit // (removeSessionListenerRefs), so the re-attach routes (/interactive, // /interactive-respawn, /shell) call this again to restore observability @@ -1918,6 +1975,7 @@ export class WebServer extends EventEmitter { private buildSessionListenerDeps() { return { broadcast: this.broadcast.bind(this), + keepWarmSchedule: (id: string) => void this.keepWarmService.schedule(id), batchTerminalData: this.batchTerminalData.bind(this), batchTaskUpdate: this.batchTaskUpdate.bind(this), broadcastSessionStateDebounced: this.broadcastSessionStateDebounced.bind(this), @@ -3611,6 +3669,10 @@ export class WebServer extends EventEmitter { // the launch conversation until the user types again, even though // the re-attached CLI is on a post-`/clear` one. lastSubmitAt: savedState?.lastSubmitAt, + // The keep-warm choice and its cap's clock; with the restored `promptCache` + // below, a future `expiresAt` re-arms the timer once the pane is attached. + lastRealPromptAt: savedState?.lastRealPromptAt, + keepWarmEnabled: savedState?.keepWarmEnabled, // Conversations this pane provably owned, oldest first. Its tail is // the conversation the CLI was on when the server stopped, which is // what a re-attach must point the viewer at instead of the launch id. @@ -4063,6 +4125,7 @@ export class WebServer extends EventEmitter { // response), so without this a 10-minute wait holds shutdown open. sessionWaits.cancelEverything(); approvalInbox.stop(); + this.keepWarmService.stop(); // Same reason as `cancelEverything` above: an in-flight wake is awaited by a request, // and `app.close()` (the last line of this method) does not abort in-flight requests — // so without this a restart during a wake waits out the readiness poll. diff --git a/src/web/session-listener-wiring.ts b/src/web/session-listener-wiring.ts index e719847d5..93d29898e 100644 --- a/src/web/session-listener-wiring.ts +++ b/src/web/session-listener-wiring.ts @@ -93,6 +93,12 @@ interface SessionListenerDeps { updateSessionName(sessionId: string, name: string): boolean; /** The synced `autoNameSessions` setting, read fresh so a flip applies to the next prompt. */ isAutoNameEnabled(): Promise; + /** + * Re-evaluate the prompt-cache keep-warm when the session goes idle: a turn just ended + * (its cache report may already be in), and after a restart this is the first moment the + * recovered pane is attached, so the restored report can arm its timer. + */ + keepWarmSchedule?(sessionId: string): void; } /** @@ -234,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 @@ -244,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 @@ -260,8 +271,9 @@ 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); if (tracker) { tracker.recordIdle(); diff --git a/src/web/sse-events.ts b/src/web/sse-events.ts index 3c1df40d7..8e79c8569 100644 --- a/src/web/sse-events.ts +++ b/src/web/sse-events.ts @@ -5,10 +5,10 @@ * and referenced by the frontend (`SSE_EVENTS` in `constants.js`). * Both files MUST be kept in sync. * - * 162 event constants organized by category: + * 163 event constants organized by category: * - **Core** (1): init * - **Transport** (1): sse:heartbeat - * - **Session lifecycle** (24): created, updated, deleted, terminal, idle, working, ... + * - **Session lifecycle** (25): created, updated, deleted, terminal, idle, working, ... * - **Session: Ralph** (6): ralphLoopUpdate, todoUpdate, completionDetected, ... * - **Session: Bash tools** (3): bashToolStart, bashToolEnd, bashToolsUpdate * - **Session: Plan** (4): planTaskUpdate, planCheckpoint, planRollback, planTaskAdded @@ -122,6 +122,8 @@ export const SessionRunning = 'session:running' as const; export const SessionStatusTelemetry = 'session:statusTelemetry' as const; /** Prompt-cache state of one session's main conversation changed (`{ sessionId, ...PromptCacheStatus }`). */ export const SessionPromptCache = 'session:promptCache' as const; +/** The prompt-cache keep-warm's status for one session changed (`{ sessionId, ...KeepWarmStatus }`). */ +export const SessionKeepWarm = 'session:keepWarm' as const; // ─── Session: Ralph ────────────────────────────────────────────────────────── @@ -534,6 +536,7 @@ export const SseEvent = { SessionRunning, SessionStatusTelemetry, SessionPromptCache, + SessionKeepWarm, // Session: Ralph SessionRalphLoopUpdate, diff --git a/test/cli-registry-spawn-golden.test.ts b/test/cli-registry-spawn-golden.test.ts index 9d1a8f414..c4fe6032e 100644 --- a/test/cli-registry-spawn-golden.test.ts +++ b/test/cli-registry-spawn-golden.test.ts @@ -102,6 +102,30 @@ describe('claude', () => { 'claude --dangerously-skip-permissions --session-id "0f9c2b14-1111-2222-3333-444455556666"' ); }); + + it('folds the keep-warm idleCompaction opt-out LAST into the one --settings JSON, and only when asked', () => { + const launch = { + claudeCliVersion: '2.1.300', + statusLineCommand: '/x/exporter.sh', + advisorModel: 'opus', + sessionName: 'w1 alpha', + }; + // Keep-warm off: byte-identical to the launch from before keep-warm existed. + expect(claude(launch)).toBe( + 'claude --dangerously-skip-permissions --session-id "0f9c2b14-1111-2222-3333-444455556666" ' + + '--settings \'{"advisorModel":"opus","statusLine":{"type":"command","command":"/x/exporter.sh"}}\' --name "w1 alpha"' + ); + expect(claude({ ...launch, effort: 'ultracode', idleCompactionOff: true })).toBe( + 'claude --dangerously-skip-permissions --session-id "0f9c2b14-1111-2222-3333-444455556666" ' + + '--settings \'{"ultracode":true,"advisorModel":"opus","statusLine":{"type":"command","command":"/x/exporter.sh"},"idleCompaction":false}\' --name "w1 alpha"' + ); + // The builder folds the key whenever asked, even with nothing else in the JSON (the + // launch only asks alongside the exporter, but the builder does not rely on that). + expect(claude({ idleCompactionOff: true })).toBe( + 'claude --dangerously-skip-permissions --session-id "0f9c2b14-1111-2222-3333-444455556666" ' + + '--settings \'{"idleCompaction":false}\'' + ); + }); }); describe('opencode', () => { diff --git a/test/keep-alive-page-alerts.test.ts b/test/keep-alive-page-alerts.test.ts new file mode 100644 index 000000000..3531bf86f --- /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-i18n.test.ts b/test/keep-warm-i18n.test.ts new file mode 100644 index 000000000..444d6f16e --- /dev/null +++ b/test/keep-warm-i18n.test.ts @@ -0,0 +1,68 @@ +/** + * @fileoverview The prompt-cache keep-warm rows (Session Options → Respawn switch, App Settings + * → Automation default and cap) in Chinese (zh-CN): each label and description runs through + * the real translator over the real index.html in JSDOM and reads in Chinese with no English + * left beyond the product names, and every new key, the toggle toasts included, is in the + * dictionary exactly once (a repeated key in the object literal silently replaces the first). + * + * Port: N/A. + */ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { JSDOM } from 'jsdom'; +import { afterAll, describe, expect, it } from 'vitest'; + +const PUBLIC = resolve(import.meta.dirname, '../src/web/public'); +const I18N = readFileSync(resolve(PUBLIC, 'i18n.js'), 'utf8'); +const INDEX = readFileSync(resolve(PUBLIC, 'index.html'), 'utf8'); + +type Api = { configure(o: Record): void; start(): void }; + +const IDS = ['modalKeepWarmEnabled', 'appSettingsPromptCacheKeepWarm', 'appSettingsPromptCacheKeepWarmMaxHours']; + +/** What may stay Latin: the product names. */ +const leftover = (text: string) => + text.replace(/\b(Claude Code|Claude|Codeman|Ralph)\b/g, '').match(/[A-Za-z]+/g) ?? []; + +function rowStrings(doc: Document, id: string): { label: string; description: string } { + const row = doc.getElementById(id)!.closest('.set-row')!; + return { + label: row.querySelector('.set-row-label')!.textContent!.trim(), + description: row.querySelector('.set-row-desc')!.textContent!.trim(), + }; +} + +const source = new JSDOM(INDEX); +const dom = new JSDOM(INDEX, { runScripts: 'outside-only', url: 'http://localhost/' }); +vm.runInContext(I18N, dom.getInternalVMContext(), { filename: 'i18n.js' }); +const api = (dom.window as unknown as { CodemanI18n: Api }).CodemanI18n; +api.start(); +api.configure({ language: 'zh-CN' }); + +afterAll(() => { + source.window.close(); + dom.window.close(); +}); + +describe('the keep-warm rows in zh-CN', () => { + it.each(IDS)('translates the %s row, with no English left', (id) => { + const english = rowStrings(source.window.document, id); + const chinese = rowStrings(dom.window.document, id); + expect(english.label.length).toBeGreaterThan(0); + for (const key of ['label', 'description'] as const) { + expect(chinese[key]).not.toBe(english[key]); + expect(leftover(chinese[key])).toEqual([]); + } + }); + + it('adds each key to the dictionary once, the toasts included', () => { + const texts = IDS.flatMap((id) => Object.values(rowStrings(source.window.document, id))); + texts.push('hours', 'Prompt-cache keep-warm enabled', 'Prompt-cache keep-warm disabled'); + for (const text of texts) { + const quoted = I18N.split(`'${text}'`).length + I18N.split(`"${text}"`).length - 2; + const bare = /^\w+$/.test(text) ? I18N.split(`\n ${text}: `).length - 1 : 0; + expect(quoted + bare, text).toBe(1); + } + }); +}); diff --git a/test/keep-warm-launch.test.ts b/test/keep-warm-launch.test.ts new file mode 100644 index 000000000..03eb819bc --- /dev/null +++ b/test/keep-warm-launch.test.ts @@ -0,0 +1,175 @@ +/** + * @fileoverview The launch half of the prompt-cache keep-warm (Discussion #554): which launch + * carries `idleCompaction: false` (decideIdleCompactionOptOut, tmux-manager.ts), that every + * create and relaunch path decides it and records it on the mux record, that the session hands + * its choice to both, that a restart keeps the choice and the real-prompt clock, that the app + * settings read defaults to OFF and a 2 h cap, and that Read My Mind's capture runs through the + * filter that drops the nudge. The argv shape itself is pinned in cli-registry-spawn-golden. + * + * Port: N/A (pure functions and source slices; no server). + */ + +import { mkdir, writeFile, rm } from 'node:fs/promises'; +import { readFileSync } from 'node:fs'; +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, 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'; + +const src = (file: string) => readFileSync(resolve(import.meta.dirname, '../src', file), 'utf8'); + +/** The body of `signature` up to the next class member, so a pin cannot pass on a sibling. */ +function methodSlice(source: string, signature: string): string { + const start = source.indexOf(signature); + expect(start, `${signature} not found`).toBeGreaterThan(-1); + const next = source + .slice(start + signature.length) + .search(/\n {2}(?:(?:private|public|protected|static|async|get|set) )*\w+\(/); + return source.slice(start, next === -1 ? undefined : start + signature.length + next); +} + +describe('decideIdleCompactionOptOut', () => { + const on = { + mode: 'claude' as const, + statusLine: 'injected' as const, + keepWarm: true, + keepWarmDefault: false, + spawnedByAgent: false, + claudeCliVersion: '2.1.290', + }; + + it('adds the key when keep-warm is on, the exporter is injected and Claude Code knows the setting', () => { + expect(decideIdleCompactionOptOut(on)).toBe(true); + expect(decideIdleCompactionOptOut({ ...on, claudeCliVersion: '2.1.300' })).toBe(true); + expect(decideIdleCompactionOptOut({ ...on, keepWarm: undefined, keepWarmDefault: true })).toBe(true); + }); + + it.each<[string, Partial & Record]>([ + ['the version is unknown (fail-closed, like --name)', { claudeCliVersion: null }], + ['2.1.289, which idle-compacts but has no idleCompaction setting', { claudeCliVersion: '2.1.289' }], + ['keep-warm is off for the session', { keepWarm: false, keepWarmDefault: true }], + ['keep-warm is left to an app default that is off', { keepWarm: undefined }], + [ + 'an agent-spawned session on the app default', + { keepWarm: undefined, keepWarmDefault: true, spawnedByAgent: true }, + ], + ['Plan Usage was off at launch (keep-warm cannot run)', { statusLine: 'collection-off' }], + ["the workspace's own statusLine (keep-warm cannot run)", { statusLine: 'workspace-statusline' }], + ['a remote or Docker launch', { statusLine: 'remote' }], + ['a CLI with no exporter at all', { statusLine: undefined }], + ['a CLI that declares no such gate', { mode: 'codex' }], + ])('leaves it off when %s', (_label, over) => { + expect(decideIdleCompactionOptOut({ ...on, ...over } as Parameters[0])).toBe( + false + ); + }); +}); + +describe('every launch decides the opt-out and records it', () => { + const tmux = src('tmux-manager.ts'); + + it.each(['async createSession(', 'async respawnPane('])('%s resolves it, renders it and records it', (sig) => { + const body = methodSlice(tmux, sig); + expect(body).toContain( + 'const idleCompactionOff = await this._resolveIdleCompactionOptOut(mode, statusLine, keepWarm, spawnedByAgent);' + ); + expect(body).toMatch(/statusLineCommand,\n\s+idleCompactionOff,\n\s+sessionName: cliName,/); + }); + + it('createSession records it on the new mux record, next to statusLine', () => { + expect(methodSlice(tmux, 'async createSession(')).toMatch(/name,\n\s+statusLine,\n\s+idleCompactionOff,\n\s+};/); + }); + + it('respawnPane re-records it on the surviving mux record', () => { + expect(methodSlice(tmux, 'async respawnPane(')).toContain('session.idleCompactionOff = idleCompactionOff;'); + }); + + it('the slice stops at the next member, private async ones included', () => { + expect(methodSlice(tmux, 'async createSession(')).not.toContain('async respawnPane('); + expect(methodSlice(tmux, 'private async _resolveStatusLine(')).not.toContain('_resolveIdleCompactionOptOut(\n'); + }); + + it('the session hands its choice to the create AND the relaunch option bags', () => { + const session = src('session.ts'); + const handoff = /keepWarm: this\._keepWarmEnabled,\n\s+spawnedByAgent: !!this\._parentSessionId,/; + expect(session.match(new RegExp(handoff, 'g'))).toHaveLength(2); + expect(methodSlice(session, 'private _buildRespawnPaneOptions(')).toMatch(handoff); + expect(methodSlice(session, 'async startInteractive(')).toMatch(handoff); + }); +}); + +describe('restart keeps the choice and the real-prompt clock', () => { + it('both constructors that rebuild a session from state.json pass them', () => { + for (const file of ['web/server.ts', 'web/routes/reboot-restore-routes.ts']) { + const source = src(file); + expect(source, file).toMatch(/lastRealPromptAt: saved(State\?)?\.lastRealPromptAt,/); + expect(source, file).toMatch(/keepWarmEnabled: saved(State\?)?\.keepWarmEnabled,/); + } + }); +}); + +describe('the pane read behind the last two guards', () => { + it('reads the usage-limit message off the same frame and counts only a reset still ahead', () => { + const port = src('web/server.ts'); + const at = port.indexOf('readPane: (view) => {'); + expect(at).toBeGreaterThan(-1); + const body = port.slice(at, port.indexOf('publish: (id, status) => {', at)); + expect(body).toContain('this.mux.capturePaneText?.(muxName, undefined, { styles: true })'); + expect(body).toContain('const limit = detectUsageLimitPause(plainFrame(text));'); + expect(body).toContain('limitShown: limit !== null && limit.resetAt > Date.now(),'); + expect(body).toContain('composerEmpty: composerIsEmpty(text,'); + }); +}); + +describe('Read My Mind capture', () => { + it('records a transcript prompt only through the filter that drops the nudge', () => { + expect(methodSlice(src('web/server.ts'), 'private async captureIntentPrompt(')).toContain( + 'intentStore.recordPrompt(' + ); + const record = methodSlice(src('intent-store.ts'), 'recordPrompt('); + expect(record.indexOf('if (!isCapturablePrompt(rawText)) return false;')).toBeGreaterThan(-1); + expect(src('intent-store.ts')).toContain('if (text.trim() === KEEP_WARM_NUDGE) return false;'); + }); +}); + +describe('readKeepWarmSettings', () => { + afterEach(async () => { + await rm(SETTINGS_PATH, { force: true }); + }); + + it('reads the temp HOME the suite set up, never a real data dir', () => { + expect(SETTINGS_PATH.startsWith(join(tmpdir(), 'codeman-vitest-'))).toBe(true); + }); + + it('defaults to off with a 2 h cap', async () => { + expect(DEFAULT_KEEP_WARM_MAX_HOURS).toBe(2); + expect(await readKeepWarmSettings()).toEqual({ keepWarmDefault: false, maxHours: 2 }); + }); + + it('reads the default and the cap, and ignores a cap that is not a positive number', async () => { + await mkdir(dirname(SETTINGS_PATH), { recursive: true }); + await writeFile(SETTINGS_PATH, JSON.stringify({ promptCacheKeepWarm: true, promptCacheKeepWarmMaxHours: 3.5 })); + expect(await readKeepWarmSettings()).toEqual({ keepWarmDefault: true, maxHours: 3.5 }); + 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-route-context.ts b/test/mocks/mock-route-context.ts index 9cb26d6a8..28b632e84 100644 --- a/test/mocks/mock-route-context.ts +++ b/test/mocks/mock-route-context.ts @@ -83,6 +83,9 @@ export function createMockRouteContext(options?: { restoreRespawnController: vi.fn(), saveRespawnConfig: vi.fn(), + // -- KeepWarmPort -- + keepWarm: { schedule: vi.fn(), rescheduleAll: vi.fn(), remove: vi.fn() }, + // -- ConfigPort -- store: { getConfig: vi.fn(() => ({ ralphEnabled: false, maxConcurrentSessions: 5 })), diff --git a/test/mocks/mock-session.ts b/test/mocks/mock-session.ts index 9b5d01fe6..665cffbe1 100644 --- a/test/mocks/mock-session.ts +++ b/test/mocks/mock-session.ts @@ -4,7 +4,13 @@ */ import { EventEmitter } from 'node:events'; import { vi } from 'vitest'; -import type { PromptCacheStatus, SessionAttachmentHistoryItem, SessionStatus, SessionRemote } from '../../src/types.js'; +import type { + KeepWarmStatus, + PromptCacheStatus, + SessionAttachmentHistoryItem, + SessionStatus, + SessionRemote, +} from '../../src/types.js'; /** * Enhanced mock session for testing RespawnController. @@ -48,6 +54,21 @@ export class MockSession extends EventEmitter { this.promptCache = status; return changed; } + /** 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; + } + keepWarm: KeepWarmStatus | null = null; + setKeepWarmStatus(status: KeepWarmStatus): boolean { + const changed = JSON.stringify(status) !== JSON.stringify(this.keepWarm); + this.keepWarm = status; + return changed; + } private _activeChildProcesses: { pid: number; command: string }[] = []; ralphTracker: null = null; writeBuffer: string[] = []; @@ -76,8 +97,10 @@ export class MockSession extends EventEmitter { this.claudeSessionId = newId; } - /** Mirrors Session.markPromptSubmitted. */ - markPromptSubmitted(): void { + /** Mirrors Session.markPromptSubmitted; records the hook's prompt text it was handed. */ + submittedPrompts: Array = []; + markPromptSubmitted(prompt?: string): void { + this.submittedPrompts.push(prompt); this.lastSubmitAt = Date.now(); } diff --git a/test/prompt-cache-keepwarm.test.ts b/test/prompt-cache-keepwarm.test.ts new file mode 100644 index 000000000..4ad0f9e71 --- /dev/null +++ b/test/prompt-cache-keepwarm.test.ts @@ -0,0 +1,781 @@ +/** + * Prompt-cache keep-warm (src/prompt-cache-keepwarm.ts): the rulebook as a table, the + * effective opt-in shared with the launch, the composer-empty read it depends on, the two + * submit clocks on a real Session, and the service's timer and nudge path with a fake + * session and fake ports. Fake timers in the service block; nothing here touches tmux. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + decideKeepWarm, + keepWarmOn, + PromptCacheKeepWarmService, + KEEP_WARM_NUDGE, + KEEP_WARM_LEAD_MS, + KEEP_WARM_RETRY_MS, + KEEP_WARM_LAST_CHANCE_MS, + KEEP_WARM_MIN_TOKENS, + KEEP_WARM_RECENT_INPUT_MS, + KEEP_WARM_TURN_WINDOW_MS, + type KeepWarmFacts, + type KeepWarmPorts, + type KeepWarmSessionView, +} from '../src/prompt-cache-keepwarm.js'; +import { composerIsEmpty, plainFrame } from '../src/session-submit-verifier.js'; +import { detectUsageLimitPause } from '../src/usage-limit-patterns.js'; +import { isCapturablePrompt } from '../src/intent-store.js'; +import { Session } from '../src/session.js'; +import type { KeepWarmStatus, PromptCacheStatus } from '../src/types.js'; + +const NOW = Date.UTC(2026, 9, 6, 22, 0, 0); +const HOUR = 3_600_000; + +function warmCache(expiresAt: number, extra: Partial = {}): PromptCacheStatus { + return { warm: true, ttl: '1h', expiresAt, recacheTokensIfCold: 110_000, misses: 0, ...extra }; +} + +/** Everything green and due: the one shape that nudges. */ +function dueFacts(over: Partial = {}): KeepWarmFacts { + return { + now: NOW, + choice: true, + appDefault: false, + spawnedByAgent: false, + statusLine: 'injected', + cache: warmCache(NOW + KEEP_WARM_LEAD_MS - 1000), + idle: true, + paneAlive: true, + limitPaused: false, + automationActive: false, + pendingApproval: false, + composerEmpty: true, + lastHumanInputAt: NOW - HOUR, + budgetAnchorAt: NOW - HOUR, + maxKeepWarmMs: 2 * HOUR, + ...over, + }; +} + +describe('decideKeepWarm', () => { + it('nudges only when every guard holds', () => { + expect(decideKeepWarm(dueFacts())).toEqual({ action: 'nudge' }); + }); + + it('nudges on the app default, and for an agent-spawned session switched on for itself', () => { + expect(decideKeepWarm(dueFacts({ choice: undefined, appDefault: true }))).toEqual({ action: 'nudge' }); + expect(decideKeepWarm(dueFacts({ choice: true, spawnedByAgent: true }))).toEqual({ action: 'nudge' }); + }); + + it.each<[string, Partial, string]>([ + ['switched off for the session', { choice: false, appDefault: true }, 'off'], + ['no choice and the app default off', { choice: undefined }, 'off'], + [ + 'an agent-spawned session on the app default', + { choice: undefined, appDefault: true, spawnedByAgent: true }, + 'agent-spawned', + ], + ['Plan Usage off at launch', { statusLine: 'collection-off' }, '(collection-off)'], + ["the workspace's own statusLine", { statusLine: 'workspace-statusline' }, '(workspace-statusline)'], + ['a remote or Docker launch', { statusLine: 'remote' }, '(remote)'], + ['a launch nobody recorded (a CLI without the exporter, a direct PTY)', { statusLine: undefined }, '(unknown)'], + ['no report yet', { cache: null }, 'no cache report yet'], + ['a cold cache', { cache: { warm: false } }, 'cold'], + [ + 'a context compacted this gap (the shape Session.setPromptCache stamps: no re-cache size)', + { cache: warmCache(NOW + 60_000, { recacheTokensIfCold: undefined, compactedAt: NOW - 60_000 }) }, + 'compacted', + ], + ['warm but no expiry to schedule on', { cache: { warm: true, recacheTokensIfCold: 110_000 } }, 'cold'], + ['the five-minute lifetime', { cache: warmCache(NOW + 4 * 60_000, { ttl: '5m' }) }, '5-minute'], + [ + 'a small context', + { cache: warmCache(NOW + 60_000, { recacheTokensIfCold: KEEP_WARM_MIN_TOKENS - 1 }) }, + 'too small', + ], + ['a dead pane', { paneAlive: false }, 'no live pane'], + ['a usage-limit pause', { limitPaused: true }, 'usage-limit'], + ['the cap after the last prompt', { budgetAnchorAt: NOW - 2 * HOUR }, 'window after the last prompt'], + ])('stops for %s', (_label, over, reason) => { + const d = decideKeepWarm(dueFacts(over)); + expect(d.action).toBe('stop'); + expect(d.action === 'stop' && d.reason).toContain(reason); + }); + + it('waits for the due time, five minutes before the expiry, when it is not due yet', () => { + const expiresAt = NOW + HOUR; + expect(decideKeepWarm(dueFacts({ cache: warmCache(expiresAt) }))).toEqual({ + action: 'wait', + until: expiresAt - KEEP_WARM_LEAD_MS, + reason: 'not due yet', + }); + }); + + it('gives up inside the last-chance minute rather than race the expiry', () => { + const d = decideKeepWarm(dueFacts({ cache: warmCache(NOW + KEEP_WARM_LAST_CHANCE_MS - 1) })); + expect(d).toMatchObject({ action: 'stop', reason: expect.stringContaining('too close') }); + }); + + it.each<[string, Partial, string]>([ + ['the session is working', { idle: false }, 'working'], + ['a dialog is open', { pendingApproval: true }, 'dialog'], + ['the composer holds a draft', { composerEmpty: false }, 'composer holds text'], + // Neither ends with a turn, so a stop here would leave an idle session with no trigger. + ['respawn, Ralph or an agent is driving (a long-poll waiter included)', { automationActive: true }, 'driving'], + ['one capture of the composer failed', { composerEmpty: null }, 'cannot read the composer'], + ['someone typed a moment ago', { lastHumanInputAt: NOW - KEEP_WARM_RECENT_INPUT_MS + 1 }, 'typed'], + ])('defers by a minute while %s', (_label, over, reason) => { + const d = decideKeepWarm(dueFacts(over)); + expect(d).toMatchObject({ + action: 'wait', + until: NOW + KEEP_WARM_RETRY_MS, + reason: expect.stringContaining(reason), + }); + }); + + it('clamps a deferral to the last-chance line so a retry never lands on an expired cache', () => { + const expiresAt = NOW + KEEP_WARM_LAST_CHANCE_MS + 30_000; // due, 90 s left + const d = decideKeepWarm(dueFacts({ cache: warmCache(expiresAt), idle: false })); + expect(d).toMatchObject({ action: 'wait', until: expiresAt - KEEP_WARM_LAST_CHANCE_MS }); + }); + + it('checks the cap before the schedule, so a session past its window never arms a timer', () => { + const d = decideKeepWarm(dueFacts({ cache: warmCache(NOW + HOUR), budgetAnchorAt: NOW - 4 * HOUR })); + expect(d.action).toBe('stop'); + }); +}); + +describe('keepWarmOn', () => { + it('is the session choice, else the app default, which an agent-spawned session does not inherit', () => { + expect(keepWarmOn(true, false, false)).toBe(true); + expect(keepWarmOn(false, true, false)).toBe(false); + expect(keepWarmOn(undefined, true, false)).toBe(true); + expect(keepWarmOn(undefined, false, false)).toBe(false); + expect(keepWarmOn(undefined, true, true)).toBe(false); + expect(keepWarmOn(true, false, true)).toBe(true); + }); +}); + +describe('composerIsEmpty', () => { + const glyph = '\u276f'; + const rule = '─'.repeat(20); + it('reads the last composer down to its bottom rule: empty, a draft, or nothing to read', () => { + expect(composerIsEmpty(`transcript\n${glyph}\u00a0\n${rule}\n bypass permissions on`, glyph)).toBe(true); + expect(composerIsEmpty(`${glyph} old prompt\n● ok\n${glyph} half a draft\n${rule}`, glyph)).toBe(false); + expect(composerIsEmpty('Accessing workspace:\n Yes, I trust this folder', glyph)).toBeUndefined(); + expect(composerIsEmpty(`${glyph} anything\n${rule}`, '')).toBeUndefined(); + }); + + it('sees a draft that opens with a newline, on the continuation lines under a blank glyph line', () => { + expect(composerIsEmpty(`${glyph} \n unfinished draft\n${rule}\n bypass permissions on`, glyph)).toBe(false); + }); + + it('never takes an indented draft line holding the glyph or a rule for the composer edges', () => { + expect(composerIsEmpty(`${glyph} explain this terminal prompt:\n ${glyph}\n${rule}`, glyph)).toBe(false); + expect(composerIsEmpty(`${glyph} \n ${rule} under the line\n${rule}\n bypass permissions on`, glyph)).toBe(false); + }); + + it('gives no verdict without the bottom rule that bounds the composer', () => { + expect(composerIsEmpty(`${glyph} \n unfinished draft`, glyph)).toBeUndefined(); + }); + + /** + * `capture-pane -p -e` of one Claude Code 2.1.300 pane, byte for byte apart from the + * statusline row (dropped): the composer with its prompt suggestion, empty, holding a draft, + * holding the same draft with the cursor moved home, and holding a pasted-text chip. The suggestion's first letter sits + * in the reverse-video cursor cell and the rest is faint; a typed draft is neither. + */ + const RULE = `\x1b[38;5;244m${'─'.repeat(120)}`; + const FOOTER = ' \x1b[38;5;220m⏵⏵ auto mode on\x1b[38;5;246m (shift+tab to cycle)\x1b[39m'; + const frame = (composer: string) => + ['\x1b[38;5;246m✻ Cooked for 2s · done 6:56 PM\x1b[39m', '', RULE, composer, RULE, FOOTER].join('\n'); + const SUGGESTION = '\x1b[39m❯\u00a0\x1b[7mw\x1b[0;2m\x1b[39m\x1b[49mrite tests for add\x1b[0m\x1b[39m\x1b[49m'; + const EMPTY = '\x1b[39m❯\u00a0\x1b[7m \x1b[0m\x1b[39m\x1b[49m'; + const DRAFT = '\x1b[39m❯\u00a0half a draft\x1b[7m \x1b[0m\x1b[39m\x1b[49m'; + const DRAFT_CURSOR_HOME = '\x1b[39m❯\u00a0\x1b[7mh\x1b[0m\x1b[39m\x1b[49malf a draft'; + // A 40-line paste collapses to a chip, drawn plain like typed text. + const PASTED_CHIP = '\x1b[39m❯\u00a0[Pasted text #1 +40 lines]\x1b[7m \x1b[0m\x1b[39m\x1b[49m'; + + it("reads Claude Code's prompt suggestion as empty and a typed draft as text, from a real styled capture", () => { + expect(composerIsEmpty(frame(SUGGESTION), glyph)).toBe(true); + expect(composerIsEmpty(frame(EMPTY), glyph)).toBe(true); + expect(composerIsEmpty(frame(DRAFT), glyph)).toBe(false); + // The cursor cell holds the draft's first letter, but no faint text follows it. + expect(composerIsEmpty(frame(DRAFT_CURSOR_HOME), glyph)).toBe(false); + expect(composerIsEmpty(frame(PASTED_CHIP), glyph)).toBe(false); + }); + + it('reads a suggestion drawn without a cursor cell as empty, and a plain capture of it as text', () => { + expect(composerIsEmpty(frame('❯\u00a0\x1b[2mwrite tests for add\x1b[0m'), glyph)).toBe(true); + expect(composerIsEmpty(frame('❯\u00a0\x1b[2mwrite \x1b[22mtyped\x1b[0m'), glyph)).toBe(false); + // Without the styles nothing tells the two apart, which is why the pane is read with -e. + expect(composerIsEmpty(frame('❯\u00a0write tests for add'), glyph)).toBe(false); + }); + + it("reads text styled with tmux's colon sub-parameters, which stripAnsi leaves behind", () => { + const limit = "You've hit your \x1b[4:3mlimit\x1b[0m · resets 1:40pm (UTC)"; + expect(detectUsageLimitPause(plainFrame(limit), NOW)).not.toBeNull(); + expect(composerIsEmpty(frame('\x1b[4:3m❯\x1b[0m\u00a0'), glyph)).toBe(true); + }); + + it("never takes an extended colour's arguments for the faint or reverse attributes", () => { + expect(composerIsEmpty(frame('❯\u00a0\x1b[38;2;2;7;2mtyped in truecolour\x1b[0m'), glyph)).toBe(false); + expect(composerIsEmpty(frame('❯\u00a0\x1b[38;5;2mtyped in a 256 colour\x1b[0m'), glyph)).toBe(false); + }); +}); + +describe('Read My Mind ignores the nudge', () => { + it('drops the exact keep-alive line and nothing else', () => { + expect(isCapturablePrompt(KEEP_WARM_NUDGE)).toBe(false); + expect(isCapturablePrompt(`${KEEP_WARM_NUDGE}\n`)).toBe(false); + expect(isCapturablePrompt(`why did "${KEEP_WARM_NUDGE}" show up in my session?`)).toBe(true); + expect(isCapturablePrompt('fix the login bug')).toBe(true); + }); +}); + +describe('the two submit clocks on a Session', () => { + it('a nudge moves lastSubmitAt but not lastRealPromptAt, from the write and from the hook', () => { + vi.useFakeTimers(); + try { + vi.setSystemTime(NOW); + const session = new Session({ workingDir: '/tmp' }); + session.write('real prompt\r', { fromUser: true }); + expect(session.lastRealPromptAt).toBe(NOW); + + vi.setSystemTime(NOW + HOUR); + session.beginKeepAliveTurn(KEEP_WARM_TURN_WINDOW_MS); + session.write(`${KEEP_WARM_NUDGE}\r`); + session.markPromptSubmitted(); // its UserPromptSubmit hook + expect(session.lastSubmitAt).toBe(NOW + HOUR); + expect(session.lastRealPromptAt).toBe(NOW); + + // A person's prompt ends the keep-alive window at once and counts again. + vi.setSystemTime(NOW + HOUR + 1000); + session.write('next real prompt\r', { fromUser: true }); + expect(session.keepAliveTurnActive).toBe(false); + expect(session.lastRealPromptAt).toBe(NOW + HOUR + 1000); + + // A prompt typed in an attached tmux client reaches Codeman only through the hook. + vi.setSystemTime(NOW + 2 * HOUR); + session.markPromptSubmitted(); + expect(session.lastRealPromptAt).toBe(NOW + 2 * HOUR); + + // ... even inside a keep-alive window: its hook carries the prompt text, and anything but + // the nudge's own ends the window, so it counts and its stop hook alerts normally. + vi.setSystemTime(NOW + 3 * HOUR); + session.beginKeepAliveTurn(KEEP_WARM_TURN_WINDOW_MS); + session.markPromptSubmitted(KEEP_WARM_NUDGE); + expect(session.keepAliveTurnActive).toBe(true); + expect(session.lastRealPromptAt).toBe(NOW + 2 * HOUR); + vi.setSystemTime(NOW + 3 * HOUR + 5000); + session.markPromptSubmitted('fix the login bug'); + expect(session.keepAliveTurnActive).toBe(false); + expect(session.lastRealPromptAt).toBe(NOW + 3 * HOUR + 5000); + } finally { + vi.useRealTimers(); + } + }); + + /** + * 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 }); + const fresh = new Session({ workingDir: '/tmp' }); + expect(fresh.lastRealPromptAt).toBe(0); + expect(fresh.toState().lastRealPromptAt).toBeUndefined(); + }); + + it('seeds the real-prompt clock from lastSubmitAt for a state.json written before it existed', () => { + expect(new Session({ workingDir: '/tmp', lastSubmitAt: NOW - HOUR }).lastRealPromptAt).toBe(NOW - HOUR); + const both = new Session({ workingDir: '/tmp', lastSubmitAt: NOW, lastRealPromptAt: NOW - HOUR }); + expect(both.lastRealPromptAt).toBe(NOW - HOUR); + }); +}); + +// ─── Service ───────────────────────────────────────────────────────────────── + +class FakeSession implements KeepWarmSessionView { + id = 'sess-1'; + createdAt = NOW - HOUR; + pid: number | null = 4242; + paneExit: unknown = undefined; + parentSessionId: string | undefined = undefined; + promptCache: PromptCacheStatus | null = null; + statusLineExporter = 'injected' as const; + idleCompactionOptOut = true; + keepWarmEnabled: boolean | undefined = undefined; + isWorking = false; + isLimitPaused = false; + lastHumanInputAt = NOW - HOUR; + lastRealPromptAt = NOW - HOUR; + idle = true; + writes: string[] = []; + keepAliveWindows: number[] = []; + writeOk = true; + isIdle(): boolean { + return this.idle; + } + beginKeepAliveTurn(windowMs: number): void { + this.keepAliveWindows.push(windowMs); + } + async writeViaMux(data: string): Promise { + this.writes.push(data); + return this.writeOk; + } +} + +function makePorts(session: FakeSession, over: Partial = {}) { + const published: KeepWarmStatus[] = []; + const ports: KeepWarmPorts = { + getSession: (id) => (id === session.id ? session : undefined), + sessionIds: () => [session.id], + readSettings: async () => ({ keepWarmDefault: false, maxHours: 2 }), + isAutomationActive: () => false, + hasPendingApproval: () => false, + readPane: () => ({ composerEmpty: true, limitShown: false }), + publish: (_id, status) => { + published.push(status); + }, + ...over, + }; + return { ports, published, last: () => published[published.length - 1] }; +} + +/** Let the service's awaited settings read and write settle. */ +const settle = async () => { + for (let i = 0; i < 5; i++) await Promise.resolve(); +}; + +describe('PromptCacheKeepWarmService', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('arms on the cache report, nudges at the due time, and reports what it did', async () => { + const session = new FakeSession(); + session.keepWarmEnabled = true; + session.promptCache = warmCache(NOW + HOUR); + const { ports, last } = makePorts(session); + const service = new PromptCacheKeepWarmService(ports); + + service.schedule(session.id); + await settle(); + expect(last()).toMatchObject({ + enabled: true, + idleCompactionOff: true, + state: 'armed', + nextCheckAt: NOW + HOUR - KEEP_WARM_LEAD_MS, + nudges: 0, + }); + expect(session.writes).toHaveLength(0); + + await vi.advanceTimersByTimeAsync(HOUR - KEEP_WARM_LEAD_MS); + await settle(); + expect(session.writes).toEqual([`${KEEP_WARM_NUDGE}\r`]); + expect(session.keepAliveWindows).toEqual([KEEP_WARM_TURN_WINDOW_MS]); + expect(last()).toMatchObject({ state: 'nudged', nudges: 1, lastNudgeAt: NOW + HOUR - KEEP_WARM_LEAD_MS }); + + // The nudge's own report moves the expiry out; the next evaluation arms on it. + session.promptCache = warmCache(NOW + 2 * HOUR - KEEP_WARM_LEAD_MS); + service.schedule(session.id); + await settle(); + expect(last()).toMatchObject({ state: 'armed', nudges: 1 }); + service.stop(); + }); + + it('reads the composer only when otherwise due, and retries while it holds a draft', async () => { + const session = new FakeSession(); + session.keepWarmEnabled = true; + session.promptCache = warmCache(NOW + KEEP_WARM_LEAD_MS - 1000); + let draft = true; + const reads: number[] = []; + const { ports, last } = makePorts(session, { + readPane: () => { + reads.push(Date.now()); + return { composerEmpty: !draft, limitShown: false }; + }, + }); + const service = new PromptCacheKeepWarmService(ports); + + service.schedule(session.id); + await settle(); + expect(reads).toHaveLength(1); + expect(last()).toMatchObject({ + state: 'waiting', + reason: 'the composer holds text', + nextCheckAt: NOW + KEEP_WARM_RETRY_MS, + }); + expect(session.writes).toHaveLength(0); + + draft = false; + await vi.advanceTimersByTimeAsync(KEEP_WARM_RETRY_MS); + await settle(); + expect(reads).toHaveLength(2); + expect(session.writes).toHaveLength(1); + expect(last()).toMatchObject({ state: 'nudged', nudges: 1 }); + service.stop(); + }); + + it('follows the app-wide default when the session has not chosen, and publishes the effective flag', async () => { + const session = new FakeSession(); + session.promptCache = warmCache(NOW + HOUR); + const { ports, last } = makePorts(session, { readSettings: async () => ({ keepWarmDefault: true, maxHours: 2 }) }); + const service = new PromptCacheKeepWarmService(ports); + service.schedule(session.id); + await settle(); + expect(last()).toMatchObject({ enabled: true, state: 'armed' }); + + session.keepWarmEnabled = false; + service.schedule(session.id); + await settle(); + expect(last()).toMatchObject({ enabled: false, state: 'off' }); + expect(vi.getTimerCount()).toBe(0); + service.stop(); + }); + + it('applies the cap from settings and never arms past it', async () => { + const session = new FakeSession(); + session.keepWarmEnabled = true; + session.lastRealPromptAt = NOW - 2.5 * HOUR; + session.promptCache = warmCache(NOW + HOUR); + const { ports, last } = makePorts(session, { readSettings: async () => ({ keepWarmDefault: false, maxHours: 2 }) }); + const service = new PromptCacheKeepWarmService(ports); + service.schedule(session.id); + await settle(); + expect(last()).toMatchObject({ + state: 'stopped', + reason: expect.stringContaining('window'), + budgetEndsAt: NOW - 0.5 * HOUR, + }); + expect(vi.getTimerCount()).toBe(0); + service.stop(); + }); + + it('keeps a failed write out of the tally and retries after the lead time', async () => { + const session = new FakeSession(); + session.keepWarmEnabled = true; + session.writeOk = false; + session.promptCache = warmCache(NOW + KEEP_WARM_LEAD_MS - 1000); + const { ports, last } = makePorts(session); + const service = new PromptCacheKeepWarmService(ports); + service.schedule(session.id); + await settle(); + expect(session.writes).toHaveLength(1); + expect(last()).toMatchObject({ + state: 'waiting', + nudges: 0, + reason: expect.stringContaining('could not be written'), + }); + service.stop(); + }); + + it('does not nudge a session whose screen shows a usage limit, with auto-resume off', async () => { + const session = new FakeSession(); + session.keepWarmEnabled = true; + session.promptCache = warmCache(NOW + KEEP_WARM_LEAD_MS - 1000); + const { ports, last } = makePorts(session, { readPane: () => ({ composerEmpty: true, limitShown: true }) }); + const service = new PromptCacheKeepWarmService(ports); + await service.schedule(session.id); + expect(session.writes).toHaveLength(0); + expect(last()).toMatchObject({ state: 'stopped', reason: 'usage-limit pause' }); + service.stop(); + }); + + it('does not nudge an agent that exited in a pane that kept its pid', async () => { + const session = new FakeSession(); + session.keepWarmEnabled = true; + session.paneExit = { status: 137 }; + session.promptCache = warmCache(NOW + KEEP_WARM_LEAD_MS - 1000); + const { ports, last } = makePorts(session); + const service = new PromptCacheKeepWarmService(ports); + await service.schedule(session.id); + expect(session.writes).toHaveLength(0); + expect(last()).toMatchObject({ state: 'stopped', reason: 'no live pane' }); + service.stop(); + }); + + it('never arms a timer past what setTimeout honours, however far off the expiry', async () => { + const session = new FakeSession(); + session.keepWarmEnabled = true; + session.promptCache = warmCache(NOW + 30 * 24 * HOUR); + const { ports } = makePorts(session, { + readSettings: async () => ({ keepWarmDefault: false, maxHours: 24 * 60 }), + }); + const spy = vi.spyOn(globalThis, 'setTimeout'); + const service = new PromptCacheKeepWarmService(ports); + await service.schedule(session.id); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy.mock.calls[0][1]).toBe(2 ** 31 - 1); + spy.mockRestore(); + service.stop(); + }); + + it('stop() during the settings read sends nothing, arms nothing and publishes nothing', async () => { + const session = new FakeSession(); + session.keepWarmEnabled = true; + session.promptCache = warmCache(NOW + KEEP_WARM_LEAD_MS - 1000); + let release!: () => void; + const gate = new Promise((r) => (release = r)); + const { ports, published } = makePorts(session, { + readSettings: async () => { + await gate; + return { keepWarmDefault: false, maxHours: 2 }; + }, + }); + const service = new PromptCacheKeepWarmService(ports); + const run = service.schedule(session.id); + service.stop(); + release(); + await run; + expect(session.writes).toHaveLength(0); + expect(published).toHaveLength(0); + expect(vi.getTimerCount()).toBe(0); + }); + + it('remove() while the nudge is being written leaves no timer, tally or status behind', async () => { + const session = new FakeSession(); + session.keepWarmEnabled = true; + session.promptCache = warmCache(NOW + KEEP_WARM_LEAD_MS - 1000); + let release!: (ok: boolean) => void; + session.writeViaMux = async (data: string) => { + session.writes.push(data); + return new Promise((r) => (release = r)); + }; + const { ports, published } = makePorts(session); + const service = new PromptCacheKeepWarmService(ports); + const run = service.schedule(session.id); + await settle(); + expect(session.writes).toHaveLength(1); + service.remove(session.id); + release(true); + await run; + expect(published).toHaveLength(0); + expect(vi.getTimerCount()).toBe(0); + service.stop(); + }); + + it('a cancelled evaluation finishing late does not cancel the one that replaced it', async () => { + const session = new FakeSession(); + session.keepWarmEnabled = true; + session.promptCache = warmCache(NOW + HOUR); + let releaseWrite!: (ok: boolean) => void; + let releaseRead: (() => void) | undefined; + const { ports, published } = makePorts(session, { + readSettings: async () => { + if (releaseRead === undefined) return { keepWarmDefault: false, maxHours: 2 }; + await new Promise((r) => (releaseRead = r)); + return { keepWarmDefault: false, maxHours: 2 }; + }, + }); + const service = new PromptCacheKeepWarmService(ports); + // First evaluation: due, held in its write. + session.promptCache = warmCache(NOW + KEEP_WARM_LEAD_MS - 1000); + session.writeViaMux = async (data: string) => { + session.writes.push(data); + return new Promise((r) => (releaseWrite = r)); + }; + const first = service.schedule(session.id); + await settle(); + service.remove(session.id); + // Second evaluation: not due, held in its settings read. + session.promptCache = warmCache(NOW + HOUR); + releaseRead = () => {}; + // rescheduleAll() drops the kept settings, so this evaluation really waits in its read. + service.rescheduleAll(); + await settle(); + releaseWrite(true); + await first; + releaseRead(); + 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('publishes no window while off, so a real prompt on a session with keep-warm off broadcasts nothing', async () => { + const session = new FakeSession(); + session.keepWarmEnabled = false; + session.promptCache = warmCache(NOW + HOUR); + const { ports, published } = makePorts(session); + const service = new PromptCacheKeepWarmService(ports); + await service.schedule(session.id); + session.lastRealPromptAt = NOW; + await service.schedule(session.id); + expect(published[0]).toMatchObject({ enabled: false, state: 'off' }); + expect(published[0]).not.toHaveProperty('budgetEndsAt'); + expect(published[0]).not.toHaveProperty('nextCheckAt'); + expect(JSON.stringify(published[1])).toBe(JSON.stringify(published[0])); + service.stop(); + }); + + it('retries a minute later when an evaluation throws, since an idle session has no other trigger', async () => { + const session = new FakeSession(); + session.keepWarmEnabled = true; + session.promptCache = warmCache(NOW + KEEP_WARM_LEAD_MS - 1000); + let fail = true; + const { ports, last } = makePorts(session, { + readSettings: async () => { + if (fail) throw new Error('settings.json mid-rewrite'); + return { keepWarmDefault: false, maxHours: 2 }; + }, + }); + const service = new PromptCacheKeepWarmService(ports); + await service.schedule(session.id); + expect(session.writes).toHaveLength(0); + expect(vi.getTimerCount()).toBe(1); + fail = false; + await vi.advanceTimersByTimeAsync(KEEP_WARM_RETRY_MS); + await settle(); + expect(session.writes).toEqual([`${KEEP_WARM_NUDGE}\r`]); + expect(last()).toMatchObject({ state: 'nudged' }); + service.stop(); + }); + + it('runs one follow-up for triggers that land during an evaluation, and resolves after it', async () => { + const session = new FakeSession(); + session.keepWarmEnabled = true; + session.promptCache = warmCache(NOW + KEEP_WARM_LEAD_MS - 1000); + let releaseWrite!: (ok: boolean) => void; + session.writeViaMux = async (data: string) => { + session.writes.push(data); + return new Promise((r) => (releaseWrite = r)); + }; + const { ports, published } = makePorts(session); + const service = new PromptCacheKeepWarmService(ports); + const first = service.schedule(session.id); + await settle(); + expect(session.writes).toHaveLength(1); + // Switched off while the nudge is being written: the toggle's own evaluation must not be lost. + session.keepWarmEnabled = false; + const toggle = service.schedule(session.id); + const settingsChange = service.schedule(session.id); + expect(settingsChange).toBe(toggle); + releaseWrite(true); + await first; + await toggle; + expect(published.map((s) => s.state)).toEqual(['nudged', 'off']); + service.stop(); + }); + + it('remove() drops a follow-up queued behind an evaluation, and a failed read after it arms nothing', async () => { + const session = new FakeSession(); + session.keepWarmEnabled = true; + session.promptCache = warmCache(NOW + KEEP_WARM_LEAD_MS - 1000); + let release!: () => void; + let fail = false; + const { ports, published } = makePorts(session, { + readSettings: async () => { + await new Promise((r) => (release = r)); + if (fail) throw new Error('settings.json mid-rewrite'); + return { keepWarmDefault: false, maxHours: 2 }; + }, + }); + const service = new PromptCacheKeepWarmService(ports); + // The session stays discoverable while it is being deleted, so only remove() can stop these. + const first = service.schedule(session.id); + const followUp = service.schedule(session.id); + service.remove(session.id); + release(); + await first; + await followUp; + await settle(); + expect(session.writes).toHaveLength(0); + expect(published).toHaveLength(0); + expect(vi.getTimerCount()).toBe(0); + + // rescheduleAll() drops the kept settings, so this read really runs, and fails. + fail = true; + const logged: string[] = []; + ports.log = (m) => logged.push(m); + service.rescheduleAll(); + service.remove(session.id); + release(); + await settle(); + expect(logged).toEqual([expect.stringContaining('mid-rewrite')]); + expect(vi.getTimerCount()).toBe(0); + service.stop(); + }); + + it('remove() forgets the nudge count, so an id seen again starts from zero', async () => { + const session = new FakeSession(); + session.keepWarmEnabled = true; + session.promptCache = warmCache(NOW + KEEP_WARM_LEAD_MS - 1000); + const { ports, last } = makePorts(session); + const service = new PromptCacheKeepWarmService(ports); + await service.schedule(session.id); + expect(last()).toMatchObject({ state: 'nudged', nudges: 1 }); + service.remove(session.id); + session.promptCache = warmCache(NOW + HOUR); + await service.schedule(session.id); + expect(last()).toMatchObject({ state: 'armed', nudges: 0 }); + service.stop(); + }); + + it('remove() drops the timer so a deleted session never gets a nudge', async () => { + const session = new FakeSession(); + session.keepWarmEnabled = true; + session.promptCache = warmCache(NOW + HOUR); + const { ports } = makePorts(session); + const service = new PromptCacheKeepWarmService(ports); + service.schedule(session.id); + await settle(); + expect(vi.getTimerCount()).toBe(1); + service.remove(session.id); + expect(vi.getTimerCount()).toBe(0); + await vi.advanceTimersByTimeAsync(2 * HOUR); + expect(session.writes).toHaveLength(0); + }); +}); diff --git a/test/prompt-cache-status.test.ts b/test/prompt-cache-status.test.ts index 6f3e90263..fb7a0105e 100644 --- a/test/prompt-cache-status.test.ts +++ b/test/prompt-cache-status.test.ts @@ -17,14 +17,29 @@ type Cache = { lastMissCauses?: string[]; compactedAt?: number; }; +type KeepWarm = { + enabled: boolean; + idleCompactionOff?: boolean; + state: string; + nextCheckAt?: number; + reason?: string; + nudges: number; + lastNudgeAt?: number; + budgetEndsAt?: number; +}; +type Api = { + format: (c: Cache | null | undefined, now: number, statusLine?: string) => string; + formatKeepWarm: (s: KeepWarm | null | undefined, now: number, statusLine?: string) => string; + keepWarmUnavailable: (statusLine?: string) => boolean; +}; -function loadFormat() { +function loadApi(): Api { const context = vm.createContext({ window: {}, globalThis: {} }); const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8'); vm.runInContext(source, context, { filename: 'constants.js' }); - return (context.window as { CodemanPromptCache: { format: (c: Cache | null | undefined, now: number) => string } }) - .CodemanPromptCache.format as (c: Cache | null | undefined, now: number, statusLine?: string) => string; + return (context.window as { CodemanPromptCache: Api }).CodemanPromptCache; } +const loadFormat = () => loadApi().format; const clock = (ms: number) => new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); const NOW = Date.UTC(2026, 9, 6, 22, 18, 0); @@ -39,6 +54,8 @@ describe('Prompt cache readout', () => { it('says why when the launch carries no statusLine exporter, whatever report is stored', () => { const format = loadFormat(); expect(format(null, NOW, 'collection-off')).toMatch(/^Not collected: Plan Usage was off when this session started/); + // Collection is decided at launch, so the fix is a new session, and the readout says where the switch is. + expect(format(null, NOW, 'collection-off')).toContain('App Settings → Header & Panels, then start a new session'); expect(format({ warm: true, expiresAt: NOW + 60_000 }, NOW, 'collection-off')).toMatch(/^Not collected/); expect(format(null, NOW, 'workspace-statusline')).toMatch(/settings\.local\.json sets its own statusLine/); expect(format(null, NOW, 'remote')).toBe('Not collected for remote and Docker sessions.'); @@ -87,3 +104,158 @@ describe('Prompt cache readout', () => { ); }); }); + +describe('Keep-warm line on the Status row', () => { + const T = NOW + 55 * 60_000; + const END = NOW + 2 * 3_600_000; + + it('says nothing until the service has published, or while switched off', () => { + const { formatKeepWarm } = loadApi(); + expect(formatKeepWarm(null, NOW, 'injected')).toBe(''); + expect(formatKeepWarm({ enabled: false, state: 'off', nudges: 0 }, NOW, 'injected')).toBe(''); + expect(formatKeepWarm({ enabled: false, state: 'off', reason: 'off', nudges: 0 }, NOW, 'injected')).toBe(''); + }); + + it('armed: the due time and when the window closes', () => { + const { formatKeepWarm } = loadApi(); + expect(formatKeepWarm({ enabled: true, state: 'armed', nextCheckAt: T, nudges: 0, budgetEndsAt: END }, NOW)).toBe( + `Keep-alive: next ${clock(T)} · stops ${clock(END)}` + ); + expect( + formatKeepWarm({ enabled: true, state: 'armed', nextCheckAt: T, nudges: 3, budgetEndsAt: NOW - 1 }, NOW) + ).toBe(`Keep-alive: next ${clock(T)} · window closed`); + }); + + it('waiting and sent carry the reason and the count', () => { + const { formatKeepWarm } = loadApi(); + expect( + formatKeepWarm( + { + enabled: true, + state: 'waiting', + reason: 'the composer holds text', + nextCheckAt: T, + nudges: 1, + budgetEndsAt: END, + }, + NOW + ) + ).toBe(`Keep-alive: waiting · the composer holds text · retry ${clock(T)} · stops ${clock(END)}`); + expect(formatKeepWarm({ enabled: true, state: 'nudged', lastNudgeAt: T, nudges: 2, budgetEndsAt: END }, NOW)).toBe( + `Keep-alive: sent ${clock(T)} (2×) · stops ${clock(END)}` + ); + }); + + it('stopped, or off for a reason, names it', () => { + const { formatKeepWarm } = loadApi(); + expect(formatKeepWarm({ enabled: true, state: 'stopped', reason: 'cache is cold', nudges: 0 }, NOW)).toBe( + 'Keep-alive: stopped · cache is cold' + ); + expect( + formatKeepWarm({ enabled: false, state: 'off', reason: 'an agent-spawned session ...', nudges: 0 }, NOW) + ).toBe('Keep-alive: off · an agent-spawned session ...'); + }); + + it("adds nothing on a launch without the exporter: the readout's Not collected sentence already says why", () => { + const { formatKeepWarm } = loadApi(); + const stopped = { enabled: true, state: 'stopped', reason: 'no cache reports on this launch', nudges: 0 }; + for (const statusLine of ['collection-off', 'workspace-statusline', 'remote']) { + expect(formatKeepWarm(stopped, NOW, statusLine)).toBe(''); + } + }); + + it('says idle compaction stays on when the opt-out was not on this launch', () => { + const { formatKeepWarm } = loadApi(); + const armed = { enabled: true, state: 'armed', nextCheckAt: T, nudges: 0 }; + expect(formatKeepWarm({ ...armed, idleCompactionOff: true }, NOW, 'injected')).toBe(`Keep-alive: next ${clock(T)}`); + expect(formatKeepWarm({ ...armed, idleCompactionOff: false }, NOW, 'injected')).toBe( + `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' + ); + }); +}); + +/** + * session-ui.js `updateKeepWarmStatus`, run as shipped against a minimal DOM: the keep-warm line + * on the Status row is hidden when it has nothing to say, and a launch without cache reports + * greys the switch's row and refuses to switch it on, never to switch it off. + */ +describe('Keep-warm switch and its Status line', () => { + /** One Session Options modal: the same elements across every session it is opened on. */ + function makeModal() { + const ui = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8'); + const start = ui.indexOf(' updateKeepWarmStatus(sessionId) {'); + expect(start).toBeGreaterThan(-1); + const update = new Function( + 'document', + 'window', + 'sessionId', + ui.slice(ui.indexOf('{', start) + 1, ui.indexOf('\n },\n', start)) + ); + const rowClasses = new Set(); + const line = { textContent: '', hidden: true }; + const row = { + classList: { toggle: (c: string, on: boolean) => void (on ? rowClasses.add(c) : rowClasses.delete(c)) }, + }; + const box = { checked: false, disabled: false, closest: () => row }; + const elements: Record = { keepWarmStatus: line, modalKeepWarmEnabled: box }; + const document = { getElementById: (id: string) => elements[id] ?? null }; + const window = { CodemanPromptCache: loadApi() }; + return (statusLine: string, keepWarm: KeepWarm | undefined, checked: boolean) => { + box.checked = checked; + const self = { + editingSessionId: 's1', + sessions: new Map([['s1', { statusLineExporter: statusLine, keepWarm }]]), + }; + update.call(self, document, window, 's1'); + return { line: { ...line }, greyed: rowClasses.has('set-row-disabled'), box: { ...box } }; + }; + } + const render = (statusLine: string, keepWarm: KeepWarm | undefined, checked: boolean) => + makeModal()(statusLine, keepWarm, checked); + + it('shows the line whenever it has words, any state, and hides it otherwise', () => { + const waiting = render( + 'injected', + { enabled: true, state: 'waiting', reason: 'the composer holds text', nudges: 0 }, + true + ); + expect(waiting.line.textContent).toContain('Keep-alive: waiting'); + expect(waiting.line.hidden).toBe(false); + expect(waiting.greyed).toBe(false); + expect(waiting.box.disabled).toBe(false); + expect(render('injected', { enabled: false, state: 'off', nudges: 0 }, false).line.hidden).toBe(true); + }); + + it('greys a launch without cache reports, but lets a switch that is on be turned off', () => { + const off = render('collection-off', { enabled: false, state: 'off', nudges: 0 }, false); + expect(off.line.hidden).toBe(true); + expect(off.greyed).toBe(true); + expect(off.box.disabled).toBe(true); + const on = render('collection-off', { enabled: true, state: 'stopped', nudges: 0 }, true); + expect(on.greyed).toBe(true); + expect(on.box.disabled).toBe(false); + }); + + it('undoes what the previous session left on the same modal', () => { + const show = makeModal(); + expect(show('collection-off', { enabled: false, state: 'off', nudges: 0 }, false).box.disabled).toBe(true); + const next = show('injected', { enabled: false, state: 'off', nudges: 0 }, false); + expect(next.box.disabled).toBe(false); + expect(next.greyed).toBe(false); + expect(show('injected', { enabled: true, state: 'armed', nextCheckAt: NOW, nudges: 0 }, true).line.hidden).toBe( + false + ); + const off = show('injected', { enabled: false, state: 'off', nudges: 0 }, false); + expect(off.line.hidden).toBe(true); + expect(off.line.textContent).toBe(''); + }); +}); diff --git a/test/routes/hook-event-routes.test.ts b/test/routes/hook-event-routes.test.ts index a553ea0c8..a6066475e 100644 --- a/test/routes/hook-event-routes.test.ts +++ b/test/routes/hook-event-routes.test.ts @@ -17,6 +17,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import Fastify, { type FastifyInstance } from 'fastify'; import fastifyCookie from '@fastify/cookie'; import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js'; +import { approvalInbox } from '../../src/web/approval-inbox.js'; import { installRouteErrorHandler } from '../../src/web/route-error-handler.js'; import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js'; import { registerHookEventRoutes } from '../../src/web/routes/hook-event-routes.js'; @@ -98,6 +99,103 @@ describe('hook-event-routes', () => { ); }); + /** + * The prompt-cache keep-warm's own turn (prompt-cache-keepwarm.ts) ends with the same stop + * hook and idle prompt as a real one. While the session marks the turn as its keep-alive, + * both open pre-acknowledged: the broadcast says why, and nothing is pushed. + */ + it('opens the stop and idle prompt of a keep-alive turn pre-acknowledged and pushes nothing', async () => { + 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: harness.ctx._sessionId, data: null }, + }); + expect(res.statusCode).toBe(200); + expect(harness.ctx.broadcast).toHaveBeenCalledWith( + `hook:${event}`, + expect.objectContaining({ sessionId: harness.ctx._sessionId, acknowledgedReason: 'cache keep-alive' }) + ); + } + expect(harness.ctx.sendPushNotifications).not.toHaveBeenCalled(); + // The inbox item is what a reload seeds tab alerts from, so it must open acknowledged too. + const item = approvalInbox.getForSession(harness.ctx._sessionId); + approvalInbox.resolveForSession(harness.ctx._sessionId, 'dismissed'); + expect(item?.acknowledgedReason).toBe('cache keep-alive'); + // acknowledgedAt is what approvals-ui.js reads to skip the alert on a reload. + 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', @@ -144,6 +242,27 @@ describe('hook-event-routes', () => { expect(harness.ctx.persistSessionState).toHaveBeenCalledWith(session); }); + /** + * The hook's prompt text is what tells a person typing in an attached tmux client apart + * from the prompt-cache keep-alive (Session.markPromptSubmitted ends the keep-alive window + * for anything but the nudge), so the route must hand it over, and nothing else. + */ + it("hands the prompt_submitted hook's prompt text to the session", async () => { + const session = harness.ctx._session; + for (const data of [ + { session_id: 'conv-1', prompt: 'fix the login bug' }, + { session_id: 'conv-1', prompt: 42 }, + { session_id: 'conv-1' }, + ]) { + await harness.app.inject({ + method: 'POST', + url: '/api/hook-event', + payload: { event: 'prompt_submitted', sessionId: harness.ctx._sessionId, data }, + }); + } + expect(session.submittedPrompts).toEqual(['fix the login bug', undefined, undefined]); + }); + it('records a /clear successor in the chain and persists it, without duplicating a repeat', async () => { const session = harness.ctx._session; const submit = async (conversationId: string) => diff --git a/test/routes/session-routes-keep-warm.test.ts b/test/routes/session-routes-keep-warm.test.ts new file mode 100644 index 000000000..d5bee0d3f --- /dev/null +++ b/test/routes/session-routes-keep-warm.test.ts @@ -0,0 +1,76 @@ +/** + * `POST /api/sessions/:id/keep-warm` (session-routes.ts): the per-session opt-in for the + * prompt-cache keep-warm. Stores the choice, persists and broadcasts the session, asks the + * scheduler to re-evaluate, clears the choice on `null`, and refuses a CLI with no cache + * telemetry. app.inject(), no port. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import Fastify, { type FastifyInstance } from 'fastify'; +import fastifyCookie from '@fastify/cookie'; +import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js'; +import { installRouteErrorHandler } from '../../src/web/route-error-handler.js'; +import { registerSessionRoutes } from '../../src/web/routes/session-routes.js'; + +interface Harness { + app: FastifyInstance; + ctx: MockRouteContext; +} + +async function createHarness(): Promise { + const app = Fastify({ logger: false }); + await app.register(fastifyCookie); + const ctx = createMockRouteContext(); + registerSessionRoutes(app, ctx); + installRouteErrorHandler(app); + await app.ready(); + return { app, ctx }; +} + +describe('POST /api/sessions/:id/keep-warm', () => { + let harness: Harness; + + beforeEach(async () => { + harness = await createHarness(); + }); + + afterEach(async () => { + await harness.app.close(); + }); + + const post = (payload: unknown) => + harness.app.inject({ method: 'POST', url: `/api/sessions/${harness.ctx._sessionId}/keep-warm`, payload }); + + it('stores the choice, persists, broadcasts and re-evaluates', async () => { + const res = await post({ enabled: true }); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body).data.keepWarm).toMatchObject({ enabled: true }); + expect(harness.ctx._session.keepWarmEnabled).toBe(true); + expect(harness.ctx.persistSessionState).toHaveBeenCalled(); + expect(harness.ctx.keepWarm.schedule).toHaveBeenCalledWith(harness.ctx._sessionId); + // The re-evaluation runs first, so the broadcast state and the response carry the new status. + expect(harness.ctx.keepWarm.schedule.mock.invocationCallOrder[0]).toBeLessThan( + harness.ctx.persistSessionState.mock.invocationCallOrder[0] + ); + }); + + it('clears the choice on null so the app-wide default applies again', async () => { + await post({ enabled: false }); + const res = await post({ enabled: null }); + expect(res.statusCode).toBe(200); + expect(harness.ctx._session.keepWarmEnabled).toBeUndefined(); + expect(JSON.parse(res.body).data.keepWarm.enabled).toBeNull(); + }); + + it('rejects a body without enabled', async () => { + expect((await post({})).statusCode).toBe(400); + }); + + it('refuses a CLI that reports no cache telemetry', async () => { + harness.ctx._session.mode = 'shell'; + const res = await post({ enabled: true }); + // This harness has no envelope hook, so the refusal shows in the body, not the status. + expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: 'INVALID_INPUT' }); + expect(harness.ctx._session.keepWarmEnabled).toBeUndefined(); + expect(harness.ctx.keepWarm.schedule).not.toHaveBeenCalled(); + }); +}); diff --git a/test/session-listener-wiring.test.ts b/test/session-listener-wiring.test.ts index cc54ff00f..e6b1a70f4 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));