SLOPSHOPPER

cache-keepwarm

Protect cached context — Keep main-chat and sub-agent background waits warm with visible pokes, capped per wait, under rewrite-saving budgets.

guardcommandtoaststatuspromptprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cache-keepwarm
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /keepwarm ⎿ cache-keepwarm: cache-keepwarm · on (launch default) · stopped: this Claude Code gives the Stop hook no background_tasks [inve ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ cache-keepwarm: cache-keepwarm · stopped: this Claude Code gives the Stop hook no background_tasks [inventory-un
README

cache-keepwarm

Keeps Claude Code prompt caches warm while work runs in the background, with a visible poke shortly before the cache expires, admitted only while the forecast spend stays under the rewrite it saves. Two independent paths, each with its own state, timer, budget and poke count:

  • Main chat: while it waits on a background agent, shell command, Monitor or workflow, a real prompt submitted into the chat. It shows in the transcript and runs one real turn at the chat's model and effort, thinking included.
  • Waiting sub-agents (on by default): the same message sent to the agent itself. Claude Code then hands the spawning parent, main chat or nested agent, an interim task-notification, which starts one real parent turn per sub-agent poke; the budget forecast counts that turn.

Each poke lists every pending item by kind, real task id, label and elapsed time, and asks the model to stop anything stale or started by accident, else to reply waiting:

Keep-warm poke 1/4: your prompt cache was about to expire, so this message keeps it warm while you wait on:
- agent `a1b2c3`: run the migration tests, pending 4m
If any of these is stale or was started by accident, stop it now. Otherwise reply `waiting` and end your turn.

At most maxPokes pokes go to one thread while its pending items stay the same and nothing else happens; a user prompt, a real turn, an item finishing or a new one appearing starts a fresh count. A reached cap reads poke limit reached 4/4 in the status, /keepwarm and the log.

/keepwarm            state: on or off and why, the current wait, pokes and spend against the allowance
/keepwarm on | off   this chat only; off cancels every armed poke, main and sub-agent; on waits for the next qualifying Stop
/keepwarm status     same as no argument
VariableOptionDefaultMeaning
CACHE_KEEPWARM_ENABLEDenabledtruePoke at all; /keepwarm overrides it for one chat
CACHE_KEEPWARM_MIN_CONTEXTminContext20000Smallest main-chat context in tokens worth a poke
CACHE_KEEPWARM_MAX_POKESmaxPokes4Pokes per thread on one unchanged wait; an integer of at least 1
CACHE_KEEPWARM_LOG_DIRlogDiremptyDirectory for a metadata-only decision log; empty writes none
CACHE_KEEPWARM_SUBAGENTSsubagentstruePoke waiting sub-agents, each poke also waking the parent for one turn; the main path is unchanged either way
CACHE_KEEPWARM_SUBAGENT_MIN_CONTEXTsubagentMinContext100000Smallest sub-agent context in tokens worth a poke and its parent wake
CACHE_KEEPWARM_STATUSstatustrueShow the waiting and stopped status text under the prompt; off paints only problems (bad options, a failed log write), for a status line that renders the session-state JSON instead. /keepwarm status and the JSON are unaffected

A variable beats the option, the option beats the default; a blank variable is absent, booleans read 1, 0, true or false in any case, and a bad variable is named in the status while the default applies.

Session state JSON

Main-session state is published atomically to ${XDG_STATE_HOME:-$HOME/.local/state}/cache-keepwarm/sessions/{sessionId}.json. CACHE_KEEPWARM_STATE_DIR overrides that sessions directory (absolute path). The v1 record carries the Claude PID, update time, enabled/source, state/reason, poke count/cap, next due time and hypothetical protected rewrite cost; never labels, messages or sub-agent views. Node.js 20+ on Claude's PATH is required. Session end deletes the file; readers check PID liveness, never age. Failure logs the full path and error to the plugin debug log without throwing into chat. /keepwarm on|off|status is unchanged. The complete field contract and every reason-to-state mapping are in the public README's Session state JSON section.

Cost and limits

  • A poke is a real turn: the forecast is the previous poke's actual cost on that thread, never less than a floor of the context read, the unwritten tail and the poke written, and a 1,000-token reply. A sub-agent poke adds the parent wake: the parent's last known context × 0.1 + (150 + 1000) × 2 + 1000 × 5 input-token equivalents. Actual usage is charged once, from the poke turn's own usage.
  • A main-chat poke is never sent while a turn runs or the user is typing.
  • A sub-agent poke needs a fresh agent.list showing the agent waiting, re-checked right before the send, plus locally attributed pending background tool ids or the native parentId; an unowned, finished, failed or killed agent is never poked, and a foreground tool call cannot be.
  • The parent wake is accepted, not suppressed: every native task-notification is delivered as Claude Code sends it.
  • Poke counts live in memory per thread and end with the episode; a restarted process starts over.

Install

The repository is public: <https://github.com/rezzminator/cache-keepwarm>. Development is on develop; the marketplace ref stays main until a release, so 0.4.1 is not yet published and the command below installs the latest released version: /plugin install cache-keepwarm --marketplace rezzminator/cache-keepwarm.

It requires CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1. A Claude Code managed by an organization skips the plugin's classic.Stop, session.compact and classic.PostModelSwitch hooks unless the organization lists the plugin. The root README of the repository is the full documentation, including TTL resolution, the budget ledger and every limit: <https://github.com/rezzminator/cache-keepwarm>.

Source 13 files
hooks/cache-keepwarm.ts 368 lines
1import type { EngineInterface, Register, Timer } from 'claude-code';
2import { resolveOptions, type Options } from '../src/options.ts';
3import { resolveTtl, observeTtl, type Ttl, type TtlResolution, type TtlEnv } from '../src/ttl.ts';
4import { fromStop, reconcile, ownedFromTool, warmReceipt, sanitizeLabel, firstSeen, type WaitItem, type EndedItem } from '../src/waits.ts';
5import { TTL_MS, WARM_1H, nextFireAt } from '../src/schedule.ts';
6import { budgetLedger, closeLedger, emptySessionLedger, recordWarm, recordOutcome, warmSpend, pokeForecast, wakeForecast, type Usage, type SessionLedger, type ClosedLedger } from '../src/budget.ts';
7import { decideWarm, type Decision, type ReasonCode } from '../src/decision.ts';
8import { parseControl, effectiveEnabled } from '../src/control.ts';
9import { pickRestore, snapshotSession, sessionKey, sessionPrefix, staleKeys, type Anchor } from '../src/resume.ts';
10import { appendLog, logFileName, logBase, leafRefusal, type LogRow, type LogStat } from '../src/log.ts';
11import { compactFacts, compactOutcome, compactResets } from '../src/compact.ts';
12import { pokeKey, pokesFor, pokeSent, type PokeCount } from '../src/pokes.ts';
13import { pokeMessage, formatWaiting, formatStopped, formatState, sessionState, formatOutcome, type StatusView, type PokeLimit } from '../src/status.ts';
14type Episode = { pending: boolean; items: WaitItem[]; known: boolean; started: number; originalExpiry: number; context: number; model: string; spent: number; warms: number; epoch: number; lastPoke: number | null; failed?: string };
15/** The main chat's poke in flight: its text, the episode it serves and, once its turn started, that turn's id; `entered` once the submit
16 * resolved, which Claude Code does when the poke's turn has started, so an unbound poke binds to the next main step. */
17type Poke = { text: string; episode: Episode; sessionId: string; turnId?: string; entered?: boolean };
18type WarmCharge = { a: SubState; epoch: number; at: number; text: string; wake: number; turnId?: string; episode: NonNullable<SubState['episode']>; sessionId: string; owner?: State };
19type State = { stateDir: string; stateWrites: Promise<void>; stateLast: string; charges: Map<string, WarmCharge>; subagents: Map<string, SubState>; options: Options; config: Record<string, unknown>; ttlEnv: TtlEnv; ttl: TtlResolution; observed: Ttl | null; anchor: Anchor | null; override: boolean | null; busy: boolean; typing: boolean; epoch: number; inFlight: boolean; poke?: Poke; pokes: PokeCount | null; timer?: Timer; tick?: Timer; episode: Episode | null; closing: Episode | null; seen: Map<string, number>; reason: ReasonCode; now: number; nextAt: number; id: string; session: SessionLedger; lastOutcome: ClosedLedger | null; rows: LogRow[]; writes: Promise<void>; stores: Promise<void>; instance: string; logPinned: string | null; logFailed: boolean; logError?: string; inactive?: boolean; ended?: EndedItem; detail?: string };
20async function readOptionEnv($: EngineInterface) {
21  return { CACHE_KEEPWARM_SUBAGENTS: await $.env.get('CACHE_KEEPWARM_SUBAGENTS'), CACHE_KEEPWARM_ENABLED: await $.env.get('CACHE_KEEPWARM_ENABLED'), CACHE_KEEPWARM_MIN_CONTEXT: await $.env.get('CACHE_KEEPWARM_MIN_CONTEXT'), CACHE_KEEPWARM_MAX_POKES: await $.env.get('CACHE_KEEPWARM_MAX_POKES'), CACHE_KEEPWARM_SUBAGENT_MIN_CONTEXT: await $.env.get('CACHE_KEEPWARM_SUBAGENT_MIN_CONTEXT'), CACHE_KEEPWARM_LOG_DIR: await $.env.get('CACHE_KEEPWARM_LOG_DIR'), CACHE_KEEPWARM_STATUS: await $.env.get('CACHE_KEEPWARM_STATUS') };
22}
23async function readTtlEnv($: EngineInterface): Promise<TtlEnv> { return { FORCE_PROMPT_CACHING_5M: await $.env.get('FORCE_PROMPT_CACHING_5M'), CLAUDE_CODE_PROMPT_CACHE_TTL: await $.env.get('CLAUDE_CODE_PROMPT_CACHE_TTL'), CLAUDE_CODE_SUBAGENT_PROMPT_CACHE_TTL: await $.env.get('CLAUDE_CODE_SUBAGENT_PROMPT_CACHE_TTL'), ENABLE_PROMPT_CACHING_1H: await $.env.get('ENABLE_PROMPT_CACHING_1H') }; }
24function enabled(s: State) { return effectiveEnabled(s.options.enabled, s.override); }
25const validUsage = (u: Usage | undefined): u is Usage => !!u && [u.input_tokens, u.output_tokens, u.cache_read_input_tokens, u.cache_creation_input_tokens].every(n => Number.isFinite(n) && n >= 0);
26/** The main chat's count for the wait it stands at: its key is the sorted set of the episode's pending ids. */
27function mainPokes(s: State): PokeLimit { return { count: pokesFor(s.pokes, pokeKey((s.episode?.items ?? []).map(i => i.id))).count, max: s.options.maxPokes }; }
28function view(s: State): StatusView {
29  const e = s.episode;
30  return { enabled: enabled(s), source: s.override === null ? 'launch' : 'session', reason: s.reason, pokes: mainPokes(s), items: (e?.items ?? []).map(i => ({ ...i, firstSeenAt: s.seen.get(i.id) ?? s.now })), now: s.now, warms: e?.warms ?? 0, nextInMs: s.nextAt - s.now, ttl: s.ttl.ttl, ttlSource: s.ttl.source, context: s.anchor?.context ?? null, ledger: e ? budgetLedger({ context: e.context, ttl: s.ttl.ttl, spent: e.spent, warms: e.warms }) : null, session: s.session, lastOutcome: s.lastOutcome, warnings: [...(s.options.bad.length ? ['bad options: ' + s.options.bad.join(', ')] : []), ...(s.logError ? [s.logError] : [])] };
31}
32function paint($: EngineInterface, s: State) {
33  const v = view(s), e = s.episode;
34  let text: string | undefined;
35  if (enabled(s) && !s.busy && s.options.status) {
36    if (s.reason === 'eligible' && e) text = formatWaiting({ ...v, pokes: mainPokes(s), reasonCode: s.reason });
37    else if (s.reason === 'small') text = formatStopped({ reason: 'small', context: s.anchor?.context, minContext: s.options.minContext, reasonCode: s.reason });
38    else if (s.reason === 'poke-limit') text = formatStopped({ reason: 'poke-limit', pokes: mainPokes(s), reasonCode: s.reason });
39    else if (s.reason === 'budget') text = formatStopped({ reason: 'budget', warms: e?.warms ?? 0, reasonCode: s.reason });
40    else if (s.reason === 'ended' && s.ended) text = formatStopped({ reason: 'ended', item: s.ended.item, detail: s.ended.ended, reasonCode: s.reason });
41    else if (s.reason === 'poke-failed' || s.reason === 'timer-failed') text = formatStopped({ reason: s.reason === 'poke-failed' ? 'error' : 'timer', detail: s.detail, reasonCode: s.reason });
42    else if (s.reason !== 'idle' && s.reason !== 'nothing-pending' && s.reason !== 'resumed') text = formatStopped({ reason: 'decision', detail: s.reason, reasonCode: s.reason });
43  }
44  if (v.warnings?.length) text = [text, ...v.warnings].filter(Boolean).join(' · ');
45  $.ui.status(text); publishState($, s);
46}
47/** Serialize immutable main-session snapshots; filesystem failures belong in the plugin debug log, never the chat. */
48function publishState($: EngineInterface, s: State, remove = false) {
49  if (!s.id || !s.stateDir) return;
50  const path = `${s.stateDir}/${s.id}.json`, data = sessionState(view(s), s.id);
51  const key = remove ? '' : JSON.stringify({ ...data, updatedAt: 0 });
52  if (!remove && s.stateLast === key) return;
53  s.stateLast = key;
54  const argv = ['node', `${$.plugin.root}/hooks/state-file.mjs`, remove ? 'delete' : 'write', s.stateDir, s.id];
55  s.stateWrites = s.stateWrites.then(async () => {
56    const updatedAt = await $.clock.now();
57    const result = await $.process.run([...argv, JSON.stringify({ ...data, updatedAt })], { timeoutMs: 10000 });
58    if (result.exitCode !== 0) throw new Error(`exit ${result.exitCode}: ${result.stderr} ${result.stdout}`);
59  }).catch(error => { s.stateLast = ''; $.ui.log(`cache-keepwarm state ${path}: ${String(error)}`, { to: 'debug' }); });
60}
61function stopTimers(s: State) { s.timer?.cancel(); s.tick?.cancel(); s.timer = undefined; s.tick = undefined; }
62/** Ends the episode. Its poke counter goes with it; a poke whose turn never started is no longer this plugin's to charge. */
63function cancel($: EngineInterface, s: State, reason: ReasonCode, closing = false) {
64  ++s.epoch; stopTimers(s); s.nextAt = 0;
65  if (closing && s.episode?.warms) s.closing ??= s.episode;
66  if (s.poke && !s.poke.turnId) { s.poke.episode.pending = false; s.poke = undefined; s.inFlight = false; }
67  s.episode = null; s.pokes = null; s.reason = reason; $.ui.status(undefined); publishState($, s);
68}
69async function persist($: EngineInterface, s: State) {
70  if (!s.id) return;
71  const key = sessionKey(s.id, s.instance), data = snapshotSession({ sessionId: s.id, savedAt: s.now, anchor: s.anchor, ledger: s.session });
72  s.stores = s.stores.then(() => $.store.set(key, data)).catch(error => { $.ui.toast(`cache-keepwarm · store write failed (${String(error)})`); });
73  await s.stores;
74}
75async function log($: EngineInterface, s: State, event: string, reason = s.reason, usage?: Usage, episode = s.episode, row?: LogRow, extra?: Partial<LogRow>) {
76  s.rows = appendLog(s.rows, { ...(row ?? { at: s.now, ...(s.anchor ? { anchorAt: s.anchor.at } : {}), event, epoch: episode?.epoch ?? s.epoch, items: episode?.items ?? [], ttl: s.ttl.ttl, ttlSource: s.ttl.source, context: episode?.context ?? s.anchor?.context ?? null, spent: episode?.spent ?? 0, reason, ...(usage ? { usage } : {}), ...(episode ? { ledger: budgetLedger({ context: episode.context, ttl: s.ttl.ttl, spent: episode.spent, warms: episode.warms }), warms: episode.warms, pokes: { count: pokesFor(s.pokes, pokeKey(episode.items.map(i => i.id))).count, max: s.options.maxPokes } } : {}), session: s.session }), ...extra });
77  if (!s.options.logDir || !s.id) return;
78  const logId = s.id, dir = s.options.logDir.replace(/\/$/, ''), name = logFileName(s.id, s.instance), text = s.rows.map(r => JSON.stringify(r)).join('\n') + '\n';
79  s.writes = s.writes.then(() => writeLog($, s, logId, dir, name, text)).catch(error => { logFault($, s, logId, `log write failed (${String(error)})`); });
80  await s.writes;
81}
82function logFault($: EngineInterface, s: State, logId: string, message: string) {
83  if (s.id !== logId || s.logFailed) return;
84  s.logFailed = true; s.logError = message; $.ui.toast(`cache-keepwarm · ${message}`); if (!s.inactive) paint($, s);
85}
86async function statOrNull($: EngineInterface, path: string, resolve: boolean): Promise<LogStat | null> {
87  try { return await $.fs.stat(path, { resolve }); }
88  catch (error) { if (/ENOENT/.test(String(error))) return null; throw error; }
89}
90async function writeLog($: EngineInterface, s: State, logId: string, dir: string, name: string, text: string) {
91  const base = logBase({ dir, stat: await statOrNull($, dir, true), pinned: s.logPinned });
92  if (!base.ok) { logFault($, s, logId, `log write refused (${base.reason})`); return; }
93  const path = `${base.base}/${name}`, refusal = leafRefusal(await statOrNull($, path, false));
94  if (refusal) { logFault($, s, logId, `log write refused (${refusal})`); return; }
95  s.logPinned = base.pinned; await $.fs.write(path, text);
96}
97async function restore($: EngineInterface, s: State, prune: boolean) {
98  try {
99    const entries: { key: string; value: unknown }[] = [];
100    for (const key of await $.store.keys()) if (key.startsWith('session:')) entries.push({ key, value: await $.store.get(key) });
101    const stale = new Set(staleKeys(entries, { now: s.now }));
102    if (prune) for (const key of stale) await $.store.delete(key);
103    const prefix = sessionPrefix(s.id), restored = pickRestore(entries.filter(e => e.key.startsWith(prefix) && !stale.has(e.key)).map(e => e.value), { sessionId: s.id, now: s.now });
104    if (restored.kind === 'restored') { s.anchor = restored.anchor; s.session = restored.ledger; s.reason = 'resumed'; }
105    else if (restored.kind === 'rejected') $.ui.toast(`cache-keepwarm · resume refused (${restored.reason})`);
106  } catch (error) { $.ui.toast(`cache-keepwarm · restore failed (${String(error)})`); }
107}
108function queueLog($: EngineInterface, s: State, event: string) { void log($, s, event).catch(error => { $.ui.toast(`cache-keepwarm · logging failed (${String(error)})`); }); }
109function decision(s: State, epoch: number, fault?: 'poke-failed' | 'timer-failed'): Decision {
110  const e = s.episode, ledger = e ? budgetLedger({ context: e.context, ttl: s.ttl.ttl, spent: e.spent, warms: e.warms }) : null;
111  return decideWarm({ fault: fault ?? (e?.failed ? 'poke-failed' : undefined), enabled: enabled(s), busy: s.busy, typing: s.typing, currentEpoch: s.epoch === epoch, inFlight: s.inFlight, known: e?.known ?? false, context: s.anchor?.context ?? null, minContext: s.options.minContext, lastSendAt: s.anchor?.at ?? null, now: s.now, ttl: s.ttl.ttl, warm1h: WARM_1H, items: (e?.items ?? []).map(i => ({ id: i.id, expectedBackAt: null, wake: ['agent', 'command', 'monitor', 'workflow'].includes(i.kind) ? 'yes' : 'unknown' })), ...(s.ended ? { endedId: s.ended.item.id } : {}), pokes: mainPokes(s), spent: e?.spent ?? 0, forecast: pokeForecast({ context: s.anchor?.context ?? 0, tail: s.anchor?.tail ?? 0, previous: e?.lastPoke ?? null }), saved: ledger?.allowance ?? 0 });
112}
113function refuse($: EngineInterface, s: State, d: Decision) { stopTimers(s); s.reason = d.reason; s.detail = d.reason === 'poke-failed' ? s.episode?.failed ?? s.detail : d.itemId; paint($, s); }
114async function arm($: EngineInterface, s: State, epoch: number) {
115  const now = await $.clock.now(); if (s.epoch !== epoch) return; s.now = now;
116  const d = decision(s, epoch); s.reason = d.reason;
117  queueLog($, s, 'decision');
118  if (d.action !== 'warm' || !s.anchor) { refuse($, s, d); await s.stateWrites; return; }
119  s.nextAt = nextFireAt({ lastSendAt: s.anchor.at, ttl: s.ttl.ttl });
120  try {
121    s.timer = $.clock.after(Math.max(0, s.nextAt - s.now), () => { void fire($, s, epoch); });
122    if (!s.tick) s.tick = $.clock.every(60000, () => { void refresh($, s, epoch); });
123    paint($, s); await s.stateWrites;
124  } catch (error) { s.reason = decision(s, epoch, 'timer-failed').reason; s.detail = String(error); stopTimers(s); paint($, s); await log($, s, 'stop'); }
125}
126async function refresh($: EngineInterface, s: State, epoch: number) {
127  try { const now = await $.clock.now(); if (s.epoch !== epoch) return; s.now = now; paint($, s); }
128  catch (error) { if (s.epoch === epoch) { s.reason = decision(s, epoch, 'timer-failed').reason; s.detail = String(error); stopTimers(s); paint($, s); await log($, s, 'stop'); } }
129}
130/** A poke that never entered: no poke was sent, so its count is given back, and the episode stops on the failure without retrying. */
131async function failPoke($: EngineInterface, s: State, e: Episode, text: string, before: PokeCount | null, detail: string) {
132  if (s.poke?.text === text && !s.poke.turnId) { s.poke = undefined; s.inFlight = false; e.pending = false; }
133  e.failed = detail;
134  if (s.episode !== e) return;
135  s.pokes = before; s.reason = 'poke-failed'; s.detail = detail; stopTimers(s); paint($, s); await log($, s, 'stop');
136}
137async function fire($: EngineInterface, s: State, epoch: number) {
138  const e = s.episode;
139  if (!e || s.epoch !== epoch) return;
140  s.timer = undefined;
141  try {
142    s.now = await $.clock.now(); if (s.epoch !== epoch) return;
143    const draft = await $.prompt.read(); if (s.epoch !== epoch) return; s.typing = draft.text.length > 0;
144    try { const agents = await $.agent.list(); if (s.epoch !== epoch) return; const refined = reconcile(e.items, agents); e.items = refined.items; s.ended = refined.ended[0]; }
145    catch (error) { if (s.epoch !== epoch) return; $.ui.toast(`cache-keepwarm · agent-list-failed (${String(error)})`); queueLog($, s, 'agent-list-failed'); }
146    const now = await $.clock.now(); if (s.epoch !== epoch) return; s.now = now;
147    const d = decision(s, epoch); s.reason = d.reason; queueLog($, s, 'decision');
148    if (d.action !== 'warm') { refuse($, s, d); await s.stateWrites; return; }
149    const before = s.pokes; s.pokes = pokeSent(s.pokes, pokeKey(e.items.map(i => i.id)));
150    const text = pokeMessage({ n: s.pokes.count, max: s.options.maxPokes, now: s.now, items: e.items.map(i => ({ ...i, firstSeenAt: s.seen.get(i.id) ?? s.now })) });
151    s.poke = { text, episode: e, sessionId: s.id }; s.inFlight = true; e.pending = true; queueLog($, s, 'admit'); s.reason = 'in-flight'; paint($, s);
152    // A real prompt in the main chat, framed as this plugin's message; its turn settles at `turn.complete`, which re-arms.
153    let result: Awaited<ReturnType<EngineInterface['prompt']['submit']>>;
154    try { result = await $.prompt.submit({ text }); }
155    catch (error) { await failPoke($, s, e, text, before, `threw: ${String(error).replace(/^Error: /, '')}`); return; }
156    if (typeof result.drop === 'string') await failPoke($, s, e, text, before, `dropped: ${result.drop}`);
157    else if (s.poke?.text === text && !s.poke.turnId) s.poke.entered = true;
158  } catch (error) { if (s.epoch === epoch) { s.reason = decision(s, epoch, 'poke-failed').reason; s.detail = `admission failed: ${String(error)}`; stopTimers(s); paint($, s); await log($, s, 'stop'); } }
159}
160/** The poke's own turn ended: its real usage is charged once, and the wait continues unless the turn failed. */
161async function settlePoke($: EngineInterface, s: State, p: Poke, usage: Usage | undefined, reason: string) {
162  const ep = p.episode; s.poke = undefined; s.inFlight = false; ep.pending = false;
163  if (p.sessionId !== s.id) return;
164  s.now = await $.clock.now();
165  if (!validUsage(usage)) ep.failed = 'usage unknown';
166  else { const spend = warmSpend(usage); ep.spent += spend; ep.warms++; ep.lastPoke = spend; s.session = recordWarm(s.session, usage); await log($, s, 'poke', reason === 'answer' ? 'eligible' : 'poke-failed', usage, ep); }
167  if (reason !== 'answer') ep.failed ??= reason;
168  await persist($, s);
169  if (s.episode === ep && (s.reason === 'in-flight' || ep.failed)) await arm($, s, s.epoch);
170}
171type SubEpisode = { started: number; context: number; spent: number; warms: number; wakes: number; lastPoke: number | null };
172type SubState = { id: string; epoch: number; anchor: Anchor | null; observed: Ttl | null; ttl: TtlResolution; owned: Map<string, WaitItem>; seen: Map<string, number>; pokes: PokeCount | null; parentContext: number | null; episode: SubEpisode | null; timer?: Timer; guard?: Timer; pending?: { text: string; turnId?: string }; requestTurn?: string };
173const subKey = (a: SubState) => pokeKey([...a.owned.keys()]);
174const subLimited = (s: State, a: SubState) => !a.episode && !!a.pokes && a.pokes.key === subKey(a) && a.pokes.count >= s.options.maxPokes;
175function subStatus(s: State): string {
176  const agents = [...s.subagents.values()].filter(a => a.episode || subLimited(s, a));
177  return ` · sub-agents ${agents.length}` + agents.map(a => { const label = sanitizeLabel(a.owned.values().next().value?.label ?? 'your background work'), c = pokesFor(a.pokes, subKey(a)).count; return a.episode ? ` · ${label}: ${a.episode.warms} warms, pokes ${c}/${s.options.maxPokes}, spent ≈ ${Math.round(a.episode.spent / 100) / 10}k-eq` : ` · ${label}: poke limit reached ${c}/${s.options.maxPokes}`; }).join('');
178}
179function subState(s: State, id: string): SubState {
180  let a = s.subagents.get(id);
181  if (!a) { a = { id, epoch: 0, anchor: null, observed: null, ttl: resolveTtl(s.ttlEnv, null, 'subagent'), owned: new Map(), seen: new Map(), pokes: null, parentContext: null, episode: null }; s.subagents.set(id, a); }
182  return a;
183}
184function own(a: SubState, item: WaitItem, now: number) { a.owned.set(item.id, item); if (!a.seen.has(item.id)) a.seen.set(item.id, now); }
185function endSub(s: State, a: SubState, forget = false) { ++a.epoch; a.timer?.cancel(); a.guard?.cancel(); a.timer = undefined; a.guard = undefined; a.pending = undefined; a.episode = null; a.requestTurn = undefined; if (forget) { a.owned.clear(); a.seen.clear(); a.anchor = null; a.pokes = null; } }
186/** Real activity in a sub-agent: its wait ends and its poke counter starts over. */
187function activeSub(s: State, a: SubState) { endSub(s, a); a.pokes = null; }
188function departSubs(s: State) { const owner = { ...s, inactive: true }; for (const c of s.charges.values()) if (c.sessionId === s.id && !c.owner) c.owner = owner; }
189function endSubs(s: State) { for (const a of s.subagents.values()) endSub(s, a, true); s.subagents.clear(); }
190async function logSub($: EngineInterface, s: State, a: SubState, event: string, reason: ReasonCode, usage?: Usage, e = a.episode) {
191  await log($, s, event, reason, usage, null, { kind: 'subagent', agentId: a.id, at: s.now, event, reason, epoch: a.epoch, items: [...a.owned.values()], ttl: a.ttl.ttl, ttlSource: a.ttl.source, context: e?.context ?? a.anchor?.context ?? null, ...(a.anchor ? { anchorAt: a.anchor.at } : {}), spent: e?.spent ?? 0, warms: e?.warms ?? 0, pokes: { count: pokesFor(a.pokes, subKey(a)).count, max: s.options.maxPokes }, ...(usage ? { usage } : {}), ...(e ? { ledger: budgetLedger({ context: e.context, ttl: a.ttl.ttl, spent: e.spent, warms: e.warms }) } : {}) });
192}
193/** The parent turn a sub-agent poke wakes: Claude Code hands the spawning parent an interim notification for it. */
194function subWake(s: State, a: SubState): number { return wakeForecast({ parentContext: a.parentContext ?? s.anchor?.context ?? a.anchor?.context ?? 0 }); }
195function subDecision(s: State, a: SubState, epoch: number, status: string): Decision {
196  const e = a.episode, anchor = a.anchor;
197  return decideWarm({ agentStatus: status, enabled: enabled(s) && s.options.subagents, busy: false, typing: false, currentEpoch: a.epoch === epoch, inFlight: s.charges.has(a.id), known: true, context: anchor?.context ?? null, minContext: s.options.subagentMinContext, lastSendAt: anchor?.at ?? null, now: s.now, ttl: a.ttl.ttl, warm1h: WARM_1H, items: [...a.owned.values()].map(i => ({ id: i.id, expectedBackAt: null, wake: 'yes' })), pokes: { count: pokesFor(a.pokes, subKey(a)).count, max: s.options.maxPokes }, spent: (e?.spent ?? 0) + (e?.wakes ?? 0), forecast: pokeForecast({ context: anchor?.context ?? 0, tail: anchor?.tail ?? 0, previous: e?.lastPoke ?? null }) + subWake(s, a), saved: e ? budgetLedger({ context: e.context, ttl: a.ttl.ttl, spent: e.spent, warms: e.warms }).allowance : 0 });
198}
199async function subRoster($: EngineInterface, s: State, a: SubState, epoch: number) {
200  const agents = await $.agent.list();
201  if (a.epoch !== epoch || s.subagents.get(a.id) !== a) return 'unknown';
202  for (const child of agents) if (child.parentId === a.id && !['completed', 'failed', 'killed'].includes(child.status)) own(a, { id: child.id, kind: 'agent', label: sanitizeLabel(child.description) }, s.now);
203  const refined = reconcile([...a.owned.values()], agents, true); a.owned = new Map(refined.items.map(i => [i.id, i]));
204  const self = agents.find(agent => agent.id === a.id), parent = self?.parentId ? s.subagents.get(self.parentId)?.anchor : undefined;
205  a.parentContext = parent?.context ?? null;
206  return self?.status ?? 'unknown';
207}
208async function armSub($: EngineInterface, s: State, a: SubState) {
209  if (!a.episode || a.pending || a.timer) return;
210  const epoch = a.epoch;
211  try {
212    const status = await subRoster($, s, a, epoch); s.now = await $.clock.now(); if (a.epoch !== epoch) return;
213    const d = subDecision(s, a, epoch, status); await logSub($, s, a, 'decision', d.reason); if (a.epoch !== epoch) return;
214    if (d.action !== 'warm' || !a.anchor) { endSub(s, a); return; }
215    a.timer = $.clock.after(Math.max(0, nextFireAt({ lastSendAt: a.anchor.at, ttl: a.ttl.ttl }) - s.now), () => { void fireSub($, s, a, epoch); });
216  } catch (error) { $.ui.toast(`cache-keepwarm · sub-agent admission failed (${String(error)})`); if (a.epoch === epoch) { await logSub($, s, a, 'stop', 'inventory-unknown'); if (a.epoch === epoch) endSub(s, a); } }
217}
218async function fireSub($: EngineInterface, s: State, a: SubState, epoch: number) {
219  if (a.epoch !== epoch || !a.episode) return; a.timer = undefined;
220  const before = a.pokes;
221  try {
222    // The roster is checked again at the send boundary, not reused from arming.
223    const status = await subRoster($, s, a, epoch); s.now = await $.clock.now(); if (a.epoch !== epoch) return;
224    const d = subDecision(s, a, epoch, status);
225    if (d.action !== 'warm') { await logSub($, s, a, 'stop', d.reason); if (a.epoch === epoch) endSub(s, a); return; }
226    a.pokes = pokeSent(a.pokes, subKey(a));
227    const text = pokeMessage({ n: a.pokes.count, max: s.options.maxPokes, now: s.now, items: [...a.owned.values()].map(i => ({ ...i, firstSeenAt: a.seen.get(i.id) ?? s.now })) });
228    a.pending = { text }; s.charges.set(a.id, { a, epoch, at: s.now, text, wake: subWake(s, a), episode: a.episode!, sessionId: s.id });
229    a.guard = $.clock.every(60000, () => { if (a.epoch === epoch && a.pending) { $.ui.toast('cache-keepwarm · sub-agent poke did not settle; episode stopped'); endSub(s, a); } });
230    void logSub($, s, a, 'admit', d.reason).catch(error => { $.ui.toast(`cache-keepwarm · sub-agent log failed (${String(error)})`); });
231    const sent = await $.session.send({ to: { agentId: a.id }, text });
232    if (!sent.isDelivered && a.epoch === epoch) { s.charges.delete(a.id); a.pokes = before; $.ui.toast(`cache-keepwarm · sub-agent send refused (${sent.reason})`); await logSub($, s, a, 'stop', 'poke-failed'); if (a.epoch === epoch) endSub(s, a); }
233  } catch (error) { if (s.charges.get(a.id)?.epoch === epoch && !s.charges.get(a.id)?.turnId) { s.charges.delete(a.id); a.pokes = before; } $.ui.toast(`cache-keepwarm · sub-agent send failed (${String(error)})`); if (a.epoch === epoch) { await logSub($, s, a, 'stop', 'poke-failed'); if (a.epoch === epoch) endSub(s, a); } }
234}
235async function completeSub($: EngineInterface, s: State, a: SubState, turnId: string, usage: Usage | undefined, ok: boolean) {
236  const charge = s.charges.get(a.id);
237  if (charge?.turnId === turnId) {
238    s.charges.delete(a.id);
239    const e = charge.episode, active = a.epoch === charge.epoch && s.subagents.get(a.id) === a && !!a.pending;
240    const owner = charge.owner ?? s; owner.now = s.now;
241    if (active) { a.guard?.cancel(); a.guard = undefined; a.pending = undefined; }
242    if (!validUsage(usage)) { await logSub($, owner, a, 'stop', 'usage-unknown', undefined, e); if (active && a.epoch === charge.epoch) endSub(s, a); return; }
243    const spend = warmSpend(usage); e.spent += spend; e.warms++; e.lastPoke = spend; e.wakes += charge.wake; owner.session = recordWarm(owner.session, usage);
244    await logSub($, owner, a, 'warm', ok ? 'eligible' : 'poke-failed', usage, e); await persist($, owner);
245    if (!active || a.epoch !== charge.epoch) return;
246    if (!ok || !a.anchor || usage.cache_read_input_tokens < e.context * 0.9) { await logSub($, s, a, 'stop', ok ? 'cache-cold' : 'poke-failed'); if (a.epoch === charge.epoch) endSub(s, a); return; }
247  } else if (!a.episode && a.anchor && a.requestTurn === turnId && a.owned.size && enabled(s) && s.options.subagents) {
248    a.episode = { started: s.now, context: a.anchor.context, spent: 0, warms: 0, wakes: 0, lastPoke: null };
249  } else return;
250  await armSub($, s, a);
251}
252
253async function toggle($: EngineInterface, s: State, on: boolean) { endSubs(s); s.override = on; cancel($, s, on ? 'idle' : 'off'); s.typing = false; s.now = await $.clock.now(); await log($, s, on ? 'on' : 'cancel'); paint($, s); await s.stateWrites; }
254export const register: Register = (on, options) => {
255  const s: State = { stateDir: '', stateWrites: Promise.resolve(), stateLast: '', charges: new Map(), subagents: new Map(), options: resolveOptions(options, {}), config: options, ttlEnv: {}, ttl: { ttl: '5m', source: 'assumed' }, observed: null, anchor: null, override: null, busy: false, typing: false, epoch: 0, inFlight: false, pokes: null, episode: null, closing: null, seen: new Map(), reason: 'idle', now: 0, nextAt: 0, id: '', session: emptySessionLedger(), lastOutcome: null, rows: [], writes: Promise.resolve(), stores: Promise.resolve(), instance: crypto.randomUUID(), logPinned: null, logFailed: false };
256  on('session.start', async ($, e, next) => {
257    departSubs(s); endSubs(s); cancel($, s, 'idle'); s.poke = undefined; s.inFlight = false; s.override = null; s.anchor = null; s.observed = null; s.busy = false; s.typing = false; s.closing = null; s.seen.clear(); s.session = emptySessionLedger(); s.lastOutcome = null; s.rows = []; s.logFailed = false; s.logError = undefined;
258    s.options = resolveOptions(options, await readOptionEnv($)); s.ttlEnv = await readTtlEnv($); s.ttl = resolveTtl(s.ttlEnv, null); s.id = await $.session.id(); s.now = await $.clock.now();
259    s.stateDir = (await $.env.get('CACHE_KEEPWARM_STATE_DIR'))?.trim() || `${(await $.env.get('XDG_STATE_HOME'))?.trim() || `${await $.env.get('HOME')}/.local/state`}/cache-keepwarm/sessions`; s.stateLast = '';
260    $.command.register({ name: 'keepwarm', description: 'Control warming — /keepwarm [on|off|status] for this chat only; no argument shows state and spend.', argumentHint: '[on|off|status]', immediate: true });
261    await restore($, s, true);
262    if (s.options.bad.length) $.ui.toast(`cache-keepwarm · bad options: ${s.options.bad.join(', ')}`);
263    await log($, s, 'LOADED'); paint($, s); await s.stateWrites; return next(e);
264  });
265  on('prompt.submit', async ($, e, next) => {
266    // This plugin's own poke is not activity: its turn runs, and the wait continues after it.
267    if (e.origin?.kind === 'plugin' && s.poke && e.text === s.poke.text) { stopTimers(s); s.busy = true; return next(e); }
268    cancel($, s, 'busy', true); s.busy = true; s.typing = false; queueLog($, s, 'cancel'); return next(e);
269  });
270  on('prompt.edit', async ($, e, next) => { cancel($, s, 'typing'); s.typing = true; queueLog($, s, 'cancel'); return next(e); });
271  on('prompt.change', async ($, e, next) => { if (e.text.length > 0) { cancel($, s, 'typing'); s.typing = true; queueLog($, s, 'cancel'); } return next(e); });
272  on('session.receive', async ($, e, next) => { if (e.agentId) { const a = s.subagents.get(e.agentId); if (a && !warmReceipt([{ role: 'user', text: e.text, toolUses: [] }], s.charges.get(a.id)?.text ?? '').own) { activeSub(s, a); if (e.origin?.kind === 'task-notification') { const id = /<task-id>([^<]+)<\/task-id>/.exec(e.text)?.[1]; if (id) a.owned.delete(id); } } } if (!e.agentId) { cancel($, s, 'busy', true); s.busy = true; queueLog($, s, 'cancel'); } return next(e); });
273  on('turn.step', async function* ($, e, next) {
274    if (e.agentId) {
275      const charge = s.charges.get(e.agentId), a = charge?.turnId === e.turnId ? charge.a : subState(s, e.agentId), epoch = a.epoch, sentAt = await $.clock.now(), previous = a.anchor;
276      a.requestTurn = e.turnId; const result = yield* next(e), u = result.usage;
277      if (a.epoch !== epoch || s.subagents.get(a.id) !== a) return result;
278      if (u && u.cache_read_input_tokens + u.cache_creation_input_tokens > 0) {
279        if (previous) a.observed = observeTtl({ gapMs: sentAt - previous.at, read: u.cache_read_input_tokens, prevContext: previous.context }) ?? a.observed;
280        a.ttl = resolveTtl(s.ttlEnv, a.observed, 'subagent');
281        a.anchor = { at: sentAt, context: u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens, tail: u.output_tokens, model: u.model, ttl: a.ttl.ttl, ttlSource: a.ttl.source };
282      } else a.anchor = null;
283      s.now = await $.clock.now(); await logSub($, s, a, 'step', a.anchor ? 'eligible' : 'usage-unknown'); return result;
284    }
285    // A step of the poke's own turn re-anchors the wait; any other main step is real activity and ends it.
286    const poking = !!s.poke && (s.poke.turnId === e.turnId || !s.poke.turnId && !!s.poke.entered);
287    if (poking) { s.poke!.turnId = e.turnId; stopTimers(s); } else cancel($, s, 'busy', true);
288    s.busy = true; s.typing = false;
289    const epoch = s.epoch, id = s.id, sentAt = await $.clock.now(), previous = s.anchor, result = yield* next(e), u = result.usage;
290    if (s.epoch !== epoch || s.id !== id) return result;
291    if (s.closing && !poking) { const closed = s.closing; s.closing = null; s.lastOutcome = closeLedger({ ledger: budgetLedger({ context: closed.context, ttl: s.ttl.ttl, spent: closed.spent, warms: closed.warms }), waitStartedAt: closed.started, originalExpiryAt: closed.originalExpiry, returnedAt: sentAt, usage: u, settled: !closed.pending, samePrefix: u ? u.model === closed.model && u.cache_read_input_tokens >= closed.context * 0.9 : null }); s.session = recordOutcome(s.session, s.lastOutcome); $.ui.toast(formatOutcome(s.lastOutcome), { timeoutMs: 10000 }); }
292    if (u && u.cache_read_input_tokens + u.cache_creation_input_tokens > 0) { if (previous) s.observed = observeTtl({ gapMs: sentAt - previous.at, read: u.cache_read_input_tokens, prevContext: previous.context }) ?? s.observed; s.ttl = resolveTtl(s.ttlEnv, s.observed); s.anchor = { at: sentAt, context: u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens, tail: u.output_tokens, model: u.model, ttl: s.ttl.ttl, ttlSource: s.ttl.source }; } else s.anchor = null;
293    s.now = await $.clock.now(); await persist($, s); await log($, s, 'step'); return result;
294  });
295  on('turn.start', async ($, e, next) => {
296    if (e.agentId) {
297      const charge = s.charges.get(e.agentId), a = s.subagents.get(e.agentId) ?? charge?.a ?? subState(s, e.agentId);
298      if (charge && warmReceipt([{ role: 'user', text: e.text, toolUses: [] }], charge.text).own && !charge.turnId) { charge.turnId = e.turnId; if (a.pending) a.pending.turnId = e.turnId; }
299      else if (charge?.turnId !== e.turnId) activeSub(s, a);
300    } else if (s.poke && !s.poke.turnId && e.text.includes(s.poke.text)) { s.poke.turnId = e.turnId; stopTimers(s); s.busy = true; }
301    return next(e);
302  });
303  on('tool.call', async ($, e, next) => {
304    const result = await next(e);
305    if (e.agentId && !result.isError && !result.deny) { const item = ownedFromTool(e.tool, e, result.result); if (item) { s.now = await $.clock.now(); own(subState(s, e.agentId), item, s.now); } }
306    if (e.tool === 'TaskStop' && typeof e.task_id === 'string' && !result.isError && !result.deny) for (const a of s.subagents.values()) { if (a.owned.has(e.task_id)) { a.owned.delete(e.task_id); activeSub(s, a); } }
307    return result;
308  });
309  on('classic.SubagentStop', async ($, e, next) => {
310    const a = s.subagents.get(e.agent_id); if (a) { const snapshot = fromStop(e.background_tasks, s.now); if (snapshot.known) a.owned = new Map([...a.owned].filter(([id]) => snapshot.items.some(i => i.id === id))); }
311    return next(e);
312  });
313  on('turn.complete', async ($, e, next) => {
314    const result = await next(e);
315    if (!e.agentId) { if (s.poke?.turnId === e.turnId) await settlePoke($, s, s.poke, e.usage, e.reason); return result; }
316    const charge = s.charges.get(e.agentId), a = charge?.a ?? s.subagents.get(e.agentId);
317    if (a) {
318      s.now = await $.clock.now();
319      // A native send can skip this plugin's own start/step hooks. Completion is universal; bind it to the actual receipt.
320      if (charge && !charge.turnId) {
321        let messages;
322        try { messages = await $.session.messages({ agentId: e.agentId }); }
323        catch (error) { $.ui.toast(`cache-keepwarm · sub-agent receipt failed (${String(error)})`); endSub(s, a); return result; }
324        if (!Array.isArray(messages)) { $.ui.toast(`cache-keepwarm · sub-agent receipt refused (${messages.deny})`); endSub(s, a); }
325        else if (warmReceipt(messages, charge.text).own) {
326          charge.turnId = e.turnId;
327          if (a.epoch === charge.epoch && e.usage) a.anchor = { at: charge.at, context: e.usage.input_tokens + e.usage.cache_read_input_tokens + e.usage.cache_creation_input_tokens, tail: e.usage.output_tokens, model: e.usage.model, ttl: a.ttl.ttl, ttlSource: a.ttl.source };
328        }
329      }
330      await completeSub($, s, a, e.turnId, e.usage, e.reason === 'answer');
331    }
332    return result;
333  });
334  on('classic.Stop', async ($, e, next) => {
335    const previous = s.episode; stopTimers(s); const epoch = ++s.epoch; s.busy = false; s.typing = false;
336    s.now = await $.clock.now(); if (s.epoch !== epoch) return next(e);
337    const snapshot = fromStop(e.background_tasks, s.now); if (snapshot.known) s.seen = firstSeen(snapshot.items, s.seen, s.now); s.ended = snapshot.ended?.[0]; s.ttlEnv = await readTtlEnv($); s.ttl = resolveTtl(s.ttlEnv, s.observed);
338    const draft = await $.prompt.read(); if (s.epoch !== epoch) return next(e); s.typing = draft.text.length > 0;
339    // The same episode continues across its pokes' Stops, keeping its spend, its last poke and any failure; a changed id set is a fresh key.
340    if (previous) Object.assign(previous, { items: snapshot.items, known: snapshot.known, epoch });
341    s.episode = previous ?? { pending: false, items: snapshot.items, known: snapshot.known, started: s.now, originalExpiry: (s.anchor?.at ?? s.now) + TTL_MS[s.ttl.ttl], context: s.anchor?.context ?? 0, model: s.anchor?.model ?? '', spent: 0, warms: 0, epoch, lastPoke: null };
342    await arm($, s, epoch); return next(e);
343  });
344  on('session.compact', async ($, e, next) => {
345    const c = compactFacts(e);
346    if (compactResets(c)) { endSubs(s); cancel($, s, 'idle'); s.anchor = null; s.observed = null; s.closing = null; await persist($, s); }
347    // Logged once the compaction answers: a vetoed one (`skip`) or a failed one installs nothing, and the live harness reads the outcome.
348    let result: Awaited<ReturnType<typeof next>>;
349    try { result = await next(e); } catch (error) { await log($, s, 'compact', s.reason, undefined, s.episode, undefined, { ...c, outcome: 'failed' }); throw error; }
350    await log($, s, 'compact', s.reason, undefined, s.episode, undefined, { ...c, outcome: compactOutcome(result) }); return result;
351  });
352  on('classic.PostModelSwitch', async ($, e, next) => { endSubs(s); cancel($, s, 'idle'); s.anchor = null; s.observed = null; s.closing = null; await persist($, s); await log($, s, 'model-switch'); return next(e); });
353  on('session.end', async ($, e, next) => {
354    departSubs(s); endSubs(s); cancel($, s, 'idle'); s.poke = undefined; s.inFlight = false; s.closing = null; s.override = null; publishState($, s, true); await s.stateWrites; await persist($, s);
355    s.anchor = null; s.seen.clear(); s.id = ''; s.session = emptySessionLedger(); s.busy = false; s.typing = false; s.lastOutcome = null; s.observed = null; s.rows = []; s.logFailed = false; s.logError = undefined; s.ttl = resolveTtl(s.ttlEnv, null);
356    const result = await next(e);
357    if (e.reason === 'clear' || e.reason === 'resume') {
358      s.id = result.sessionId; s.now = await $.clock.now(); s.stateLast = '';
359      if (e.reason === 'resume') {
360        await restore($, s, false);
361      }
362      paint($, s); await s.stateWrites;
363    }
364    return result;
365  });
366  on('command.run', { command: 'keepwarm' }, async ($, e) => { const action = parseControl(e.args); if (action === 'invalid') return { text: 'Usage: /keepwarm [on|off|status]' }; if (action !== 'status') await toggle($, s, action === 'on'); s.now = await $.clock.now(); return { text: formatState(view(s)) + subStatus(s) }; });
367};
368
src/options.ts 27 lines
1export type OptionEnv = Partial<Record<'CACHE_KEEPWARM_ENABLED' | 'CACHE_KEEPWARM_MIN_CONTEXT' | 'CACHE_KEEPWARM_MAX_POKES' | 'CACHE_KEEPWARM_SUBAGENT_MIN_CONTEXT' | 'CACHE_KEEPWARM_LOG_DIR' | 'CACHE_KEEPWARM_SUBAGENTS' | 'CACHE_KEEPWARM_STATUS', string>>;
2export interface Options { enabled: boolean; minContext: number; maxPokes: number; subagentMinContext: number; logDir: string; subagents: boolean; status: boolean; bad: string[] }
3export const ENV = { enabled: 'CACHE_KEEPWARM_ENABLED', minContext: 'CACHE_KEEPWARM_MIN_CONTEXT', maxPokes: 'CACHE_KEEPWARM_MAX_POKES', subagentMinContext: 'CACHE_KEEPWARM_SUBAGENT_MIN_CONTEXT', logDir: 'CACHE_KEEPWARM_LOG_DIR', subagents: 'CACHE_KEEPWARM_SUBAGENTS', status: 'CACHE_KEEPWARM_STATUS' } as const;
4export function resolveOptions(options: Record<string, unknown>, env: OptionEnv): Options {
5  const defaults = { enabled: true, minContext: 20000, maxPokes: 4, subagentMinContext: 100000, logDir: '', subagents: true, status: true };
6  const result: Options = { ...defaults, bad: [] };
7  for (const key of Object.keys(defaults) as (keyof typeof defaults)[]) {
8    const name = ENV[key], raw = env[name];
9    const fromEnv = typeof raw === 'string' && raw.trim() !== '';
10    const value = fromEnv ? raw.trim() : options[key];
11    if (value === undefined) continue;
12    let parsed: unknown = value;
13    if (key === 'enabled' || key === 'subagents' || key === 'status') {
14      if (typeof value === 'string' && /^(1|0|true|false)$/i.test(value)) parsed = /^(1|true)$/i.test(value);
15      if (typeof parsed !== 'boolean') parsed = undefined;
16    } else if (key === 'logDir') {
17      if (typeof value !== 'string') parsed = undefined;
18    } else {
19      parsed = typeof value === 'number' || typeof value === 'string' ? Number(value) : NaN;
20      if (!Number.isSafeInteger(parsed) || Number(parsed) < (key === 'subagentMinContext' ? 0 : 1)) parsed = undefined;
21    }
22    if (parsed === undefined) { result.bad.push(fromEnv ? name : key); continue; }
23    Object.assign(result, { [key]: parsed });
24  }
25  return result;
26}
27
src/ttl.ts 28 lines
1export type Ttl = '5m' | '1h';
2export type TtlSource = 'force-5m' | 'env' | 'observed' | 'env-1h' | 'assumed';
3export type TtlEnv = Partial<Record<'FORCE_PROMPT_CACHING_5M' | 'CLAUDE_CODE_PROMPT_CACHE_TTL' | 'CLAUDE_CODE_SUBAGENT_PROMPT_CACHE_TTL' | 'ENABLE_PROMPT_CACHING_1H', string>>;
4export interface TtlResolution { ttl: Ttl; source: TtlSource }
5export function resolveTtl(env: TtlEnv, observed: Ttl | null, kind?: 'subagent'): TtlResolution {
6  if (kind === 'subagent') {
7    const explicit = subagentTtl(env);
8    return explicit.source === 'assumed' && observed ? { ttl: observed, source: 'observed' } : explicit;
9  }
10  if (env.FORCE_PROMPT_CACHING_5M === '1') return { ttl: '5m', source: 'force-5m' };
11  if (env.CLAUDE_CODE_PROMPT_CACHE_TTL === '5m' || env.CLAUDE_CODE_PROMPT_CACHE_TTL === '1h') return { ttl: env.CLAUDE_CODE_PROMPT_CACHE_TTL, source: 'env' };
12  if (observed) return { ttl: observed, source: 'observed' };
13  if (env.ENABLE_PROMPT_CACHING_1H === '1') return { ttl: '1h', source: 'env-1h' };
14  return { ttl: '5m', source: 'assumed' };
15}
16/** The TTL a sub-agent's request writes: 5m unless the environment names 1h (read from the Claude Code 2.1.302 source). A
17 * `subagentPromptCacheTtl` setting is not visible to a plugin and reads as 5m, which warms early, never late. */
18export function subagentTtl(env: TtlEnv): TtlResolution {
19  if (env.FORCE_PROMPT_CACHING_5M === '1') return { ttl: '5m', source: 'force-5m' };
20  if (env.CLAUDE_CODE_SUBAGENT_PROMPT_CACHE_TTL === '5m' || env.CLAUDE_CODE_SUBAGENT_PROMPT_CACHE_TTL === '1h') return { ttl: env.CLAUDE_CODE_SUBAGENT_PROMPT_CACHE_TTL, source: 'env' };
21  if (env.ENABLE_PROMPT_CACHING_1H === '1') return { ttl: '1h', source: 'env-1h' };
22  return { ttl: '5m', source: 'assumed' };
23}
24export function observeTtl({ gapMs, read, prevContext }: { gapMs: number; read: number; prevContext: number }): Ttl | null {
25  if (![gapMs, read, prevContext].every(Number.isFinite) || prevContext <= 0 || read < 0 || gapMs < 330000 || gapMs > 3300000) return null;
26  return read / prevContext >= 0.9 ? '1h' : '5m';
27}
28
src/waits.ts 58 lines
1import type { AgentInfo, ClassicHookInputs } from 'claude-code';
2export type TaskSummary = NonNullable<ClassicHookInputs['Stop']['background_tasks']>[number];
3export interface WaitItem { id: string; kind: string; label: string }
4export interface EndedItem { item: WaitItem; ended: 'completed' | 'failed' | 'killed' }
5export interface WaitSnapshot { known: boolean; items: WaitItem[]; ended?: EndedItem[] }
6export function sanitizeLabel(text: string): string {
7  const clean = text.replace(/[\u0000-\u0008\u000e-\u001f\u007f-\u009f]/g, '').replace(/\s+/g, ' ').trim();
8  const chars = Array.from(clean);
9  return chars.length > 40 ? chars.slice(0, 40).join('') + '…' : clean;
10}
11export function fromStop(tasks: readonly TaskSummary[] | undefined, _now: number): WaitSnapshot {
12  if (!Array.isArray(tasks)) return { known: false, items: [] };
13  const items: WaitItem[] = [], ended: EndedItem[] = [];
14  for (const task of tasks) {
15    const kind = task.type === 'subagent' ? 'agent' : task.type === 'shell' ? 'command' : task.type;
16    const item = { id: task.id, kind, label: sanitizeLabel(task.type === 'shell' ? task.command || task.description : task.description) };
17    if (task.status === 'completed' || task.status === 'failed' || task.status === 'killed') ended.push({ item, ended: task.status });
18    else if (!items.some(other => other.id === item.id)) items.push(item);
19  }
20  return { known: true, items, ...(ended.length ? { ended } : {}) };
21}
22export function reconcile(items: readonly WaitItem[], agents: readonly Pick<AgentInfo, 'id' | 'status'>[], pendingOnly = false): { items: WaitItem[]; ended: EndedItem[] } {
23  const kept: WaitItem[] = [], ended: EndedItem[] = [];
24  const statuses = new Map(agents.map(agent => [agent.id, agent.status]));
25  for (const item of items) {
26    const status = item.kind === 'agent' ? statuses.get(item.id) : undefined;
27    if (status === 'completed' || status === 'failed' || status === 'killed') ended.push({ item, ended: status });
28    else if (!pendingOnly || item.kind !== 'agent' || status === 'pending' || status === 'running' || status === 'waiting') kept.push(item);
29  }
30  return { items: kept, ended };
31}
32export function firstSeen(items: readonly WaitItem[], previous: ReadonlyMap<string, number>, now: number): Map<string, number> {
33  return new Map(items.map(item => [item.id, Math.min(previous.get(item.id) ?? now, now)]));
34}
35
36export function ownedFromTool(tool: string, input: Record<string, unknown>, result: unknown): WaitItem | null {
37  if (!result || typeof result !== 'object') return null;
38  const r = result as Record<string, unknown>;
39  if (r.error || r.status === 'completed' || r.backgroundEndsWithFinalResponse) return null;
40  let id: unknown, kind: string, label: unknown;
41  if (tool === 'Bash') { id = r.backgroundTaskId; kind = 'command'; label = input.command; }
42  else if (tool === 'Agent') { id = r.agentId ?? r.agent_id ?? r.taskId; kind = 'agent'; label = input.description ?? r.description; }
43  else if (tool === 'Skill' && r.background === true) { id = r.agentId; kind = 'agent'; label = r.commandName; }
44  else if (tool === 'Monitor') { id = r.taskId; kind = 'monitor'; label = 'Monitor'; }
45  else if (tool === 'Workflow') { id = r.taskId; kind = 'workflow'; label = r.workflowName ?? 'Workflow'; }
46  else return null;
47  return typeof id === 'string' && id.length > 0 ? { id, kind, label: sanitizeLabel(typeof label === 'string' ? label : 'your background work') } : null;
48}
49
50/** Whether the last user message is this plugin's own poke, raw or as Claude Code wraps a coordinator send; tools may follow it. */
51export function warmReceipt(messages: readonly { role: string; text: string; toolUses: readonly unknown[] }[], expected: string): { own: boolean } {
52  const index = messages.findLastIndex(m => m.role === 'user');
53  if (index < 0) return { own: false };
54  const text = messages[index]!.text;
55  const wrapped = `The coordinator sent a message while you were working:\n${expected}\n\nAddress this before completing your current task.`;
56  return { own: text === expected || text === wrapped };
57}
58
src/schedule.ts 7 lines
1import type { Ttl } from './ttl.ts';
2export const WARM_1H = true;
3export const SAFETY_MS = 15_000;
4export const TTL_MS = { '5m': 300_000, '1h': 3_600_000 } as const;
5export function nextFireAt({ lastSendAt, ttl }: { lastSendAt: number; ttl: Ttl }): number { return lastSendAt + TTL_MS[ttl] * 0.8; }
6export function isLate({ lastSendAt, ttl, now }: { lastSendAt: number; ttl: Ttl; now: number }): boolean { return now >= lastSendAt + TTL_MS[ttl]; }
7
src/budget.ts 33 lines
1import type { ModelUsage } from 'claude-code';
2import type { Ttl } from './ttl.ts';
3export type Usage = Pick<ModelUsage, 'input_tokens' | 'output_tokens' | 'cache_read_input_tokens' | 'cache_creation_input_tokens'>;
4export interface BudgetLedger { context: number; ttl: Ttl; spent: number; warms: number; allowance: number; protecting: number; remaining: number }
5export interface ClosedLedger { elapsedMs: number; readPercent: number | null; netSaved: number | null; reason: 'estimated' | 'early' | 'miss' | 'unknown' }
6export interface SessionLedger { spent: number; warms: number; episodes: number; estimatedNet: number; unknown: number }
7const rounded = (n: number): number => Math.round(n * 1e6) / 1e6;
8export function rewriteSaved({ context, ttl }: { context: number; ttl: Ttl }): number { return rounded(context * (ttl === '5m' ? 1.15 : 1.9)); }
9export function warmSpend(usage: Usage): number { return rounded(usage.cache_read_input_tokens * 0.1 + usage.cache_creation_input_tokens * 2 + usage.input_tokens + usage.output_tokens * 5); }
10/** A poke is a real turn at the thread's own effort, thinking included: the observed spend of this thread's previous poke, never under a
11 * conservative floor — the context read, the unwritten tail plus the poke (150) written, and a 1,000-token reply. */
12export function pokeForecast({ context, tail, previous }: { context: number; tail: number; previous: number | null }): number { return rounded(Math.max(context * 0.1 + (tail + 150) * 2 + 1000 * 5, previous ?? 0)); }
13/** A sub-agent poke also wakes its parent: Claude Code hands the parent an interim notification, one real parent turn per poke. */
14export function wakeForecast({ parentContext }: { parentContext: number }): number { return rounded(parentContext * 0.1 + (150 + 1000) * 2 + 1000 * 5); }
15export function allow({ spent, forecast, saved }: { spent: number; forecast: number; saved: number }): boolean { return [spent, forecast, saved].every(n => Number.isFinite(n) && n >= 0) && spent + forecast <= saved; }
16export function budgetLedger({ context, ttl, spent, warms }: { context: number; ttl: Ttl; spent: number; warms: number }): BudgetLedger {
17  const allowance = rewriteSaved({ context, ttl });
18  return { context, ttl, spent, warms, allowance, protecting: rounded(context * (ttl === '5m' ? 1.25 : 2)), remaining: rounded(allowance - spent) };
19}
20export function closeLedger(input: { ledger: BudgetLedger; waitStartedAt: number; originalExpiryAt: number; returnedAt: number; usage: Usage | null; samePrefix: boolean | null; settled?: boolean }): ClosedLedger {
21  const { ledger, usage } = input, elapsedMs = Math.max(0, input.returnedAt - input.waitStartedAt);
22  const valid = usage !== null && [usage.input_tokens, usage.output_tokens, usage.cache_read_input_tokens, usage.cache_creation_input_tokens].every(n => Number.isFinite(n) && n >= 0);
23  const total = valid ? usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens : 0;
24  const readPercent = valid && total > 0 ? usage.cache_read_input_tokens / total * 100 : null;
25  if (readPercent === null || input.samePrefix !== true || input.settled === false) return { elapsedMs, readPercent, netSaved: null, reason: 'unknown' };
26  if (input.returnedAt < input.originalExpiryAt) return { elapsedMs, readPercent, netSaved: -ledger.spent, reason: 'early' };
27  if (readPercent < 90) return { elapsedMs, readPercent, netSaved: -ledger.spent, reason: 'miss' };
28  return { elapsedMs, readPercent, netSaved: rounded(ledger.allowance - ledger.spent), reason: 'estimated' };
29}
30export function emptySessionLedger(): SessionLedger { return { spent: 0, warms: 0, episodes: 0, estimatedNet: 0, unknown: 0 }; }
31export function recordWarm(session: SessionLedger, usage: Usage): SessionLedger { return { ...session, spent: rounded(session.spent + warmSpend(usage)), warms: session.warms + 1 }; }
32export function recordOutcome(session: SessionLedger, outcome: ClosedLedger): SessionLedger { return { ...session, episodes: session.episodes + 1, estimatedNet: rounded(session.estimatedNet + (outcome.netSaved ?? 0)), unknown: session.unknown + (outcome.netSaved === null ? 1 : 0) }; }
33
src/decision.ts 33 lines
1import type { Ttl } from './ttl.ts';
2export type ReasonCode = 'agent-not-waiting' | 'eligible' | 'off' | 'busy' | 'typing' | 'stale' | 'in-flight' | 'inventory-unknown' | 'nothing-pending' | 'ended' | 'usage-unknown' | 'usage-invalid' | 'small' | '1h-unproven' | 'cache-cold' | 'expiry-safety' | 'wake-unknown' | 'cannot-wake' | 'poke-limit' | 'imminent' | 'budget' | 'marginal' | 'poke-failed' | 'timer-failed' | 'resumed' | 'idle';
3export interface DecisionItem { id: string; expectedBackAt: number | null; wake: 'yes' | 'no' | 'unknown' }
4export interface DecisionInput { agentStatus?: string; fault?: 'poke-failed' | 'timer-failed'; enabled: boolean; busy: boolean; typing: boolean; currentEpoch: boolean; inFlight: boolean; known: boolean; context: number | null; minContext: number; lastSendAt: number | null; now: number; ttl: Ttl; warm1h: boolean; items: readonly DecisionItem[]; endedId?: string; pokes: { count: number; max: number }; spent: number; forecast: number; saved: number }
5export interface Decision { action: 'warm' | 'skip' | 'stop'; reason: ReasonCode; items: string[]; itemId?: string }
6export function decideWarm(input: DecisionInput): Decision {
7  const stop = (reason: ReasonCode, action: Decision['action'] = 'stop', itemId?: string): Decision => ({ action, reason, items: [], ...(itemId ? { itemId } : {}) });
8  if (!input.currentEpoch) return stop('stale');
9  if (!input.enabled) return stop('off');
10  if (input.agentStatus !== undefined && input.agentStatus !== 'waiting') return stop('agent-not-waiting');
11  if (input.busy) return stop('busy');
12  if (input.typing) return stop('typing');
13  if (input.fault) return stop(input.fault);
14  if (input.inFlight) return stop('in-flight', 'skip');
15  if (!input.known) return stop('inventory-unknown');
16  if (!input.items.length) return stop(input.endedId ? 'ended' : 'nothing-pending', 'stop', input.endedId);
17  if (input.context === null || input.lastSendAt === null) return stop('usage-unknown');
18  if (![input.context, input.lastSendAt, input.now, input.spent, input.forecast, input.saved, input.minContext].every(n => Number.isFinite(n) && n >= 0) || input.now < input.lastSendAt || !Number.isSafeInteger(input.pokes.count) || input.pokes.count < 0 || !Number.isSafeInteger(input.pokes.max) || input.pokes.max < 1) return stop('usage-invalid');
19  if (input.pokes.count >= input.pokes.max) return stop('poke-limit');
20  if (input.context < input.minContext) return stop('small');
21  if (input.ttl === '1h' && !input.warm1h) return stop('1h-unproven');
22  const expiry = input.lastSendAt + (input.ttl === '5m' ? 300000 : 3600000);
23  if (input.now >= expiry) return stop('cache-cold');
24  if (input.now >= expiry - 15000) return stop('expiry-safety');
25  const waking = input.items.filter(item => item.wake === 'yes');
26  if (!waking.length) return stop(input.items.some(item => item.wake === 'unknown') ? 'wake-unknown' : 'cannot-wake');
27  const needed = waking.filter(item => item.expectedBackAt === null || item.expectedBackAt > expiry);
28  if (!needed.length) return stop('imminent', 'skip');
29  if (input.spent + input.forecast > input.saved) return stop('budget');
30  if (input.saved - input.spent - input.forecast + 1e-9 < input.forecast * 0.1) return stop('marginal');
31  return { action: 'warm', reason: 'eligible', items: needed.map(item => item.id) };
32}
33
src/control.ts 4 lines
1export type ControlAction = 'on' | 'off' | 'status' | 'invalid';
2export function parseControl(args: string): ControlAction { const word = args.trim(); return word === '' || word === 'status' ? 'status' : word === 'on' || word === 'off' ? word : 'invalid'; }
3export function effectiveEnabled(launch: boolean, override: boolean | null): boolean { return override ?? launch; }
4
src/resume.ts 40 lines
1import type { Ttl, TtlSource } from './ttl.ts';
2import type { SessionLedger } from './budget.ts';
3export interface Anchor { at: number; context: number; tail: number; model: string; ttl: Ttl; ttlSource: TtlSource }
4export interface SavedSession { version: 1; sessionId: string; savedAt: number; anchor: Anchor | null; ledger: SessionLedger }
5export const RESTORE_HORIZON_MS = 7 * 86_400_000;
6export type RestoreResult = { kind: 'absent' } | { kind: 'restored'; anchor: Anchor | null; ledger: SessionLedger; savedAt: number } | { kind: 'rejected'; reason: 'session-mismatch' | 'invalid-record' | 'future-anchor' };
7const record = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v);
8export function restoreAnchor(value: unknown, { sessionId, now }: { sessionId: string; now: number }): RestoreResult {
9  if (value === undefined) return { kind: 'absent' };
10  const reject = (reason: 'session-mismatch' | 'invalid-record' | 'future-anchor'): RestoreResult => ({ kind: 'rejected', reason });
11  if (!record(value) || value.version !== 1 || typeof value.sessionId !== 'string' || typeof value.savedAt !== 'number' || !Number.isFinite(value.savedAt) || value.savedAt < 0 || !record(value.ledger)) return reject('invalid-record');
12  if (value.sessionId !== sessionId) return reject('session-mismatch');
13  const l = value.ledger;
14  if (!['spent', 'warms', 'episodes', 'estimatedNet', 'unknown'].every(k => typeof l[k] === 'number' && Number.isFinite(l[k])) || ['spent', 'warms', 'episodes', 'unknown'].some(k => Number(l[k]) < 0) || ['warms', 'episodes', 'unknown'].some(k => !Number.isSafeInteger(l[k]))) return reject('invalid-record');
15  const a = value.anchor;
16  if (a !== null) {
17    if (!record(a) || !['at', 'context', 'tail'].every(k => typeof a[k] === 'number' && Number.isFinite(a[k]) && Number(a[k]) >= 0) || Number(a.context) <= 0 || typeof a.model !== 'string' || !a.model || !['5m', '1h'].includes(String(a.ttl)) || !['force-5m', 'env', 'observed', 'env-1h', 'assumed'].includes(String(a.ttlSource))) return reject('invalid-record');
18    if (Number(a.at) > now) return reject('future-anchor');
19  }
20  const saved = snapshotSession({ sessionId, savedAt: value.savedAt, anchor: a as unknown as Anchor | null, ledger: l as unknown as SessionLedger });
21  return { kind: 'restored', anchor: saved.anchor, ledger: saved.ledger, savedAt: saved.savedAt };
22}
23export function snapshotSession({ sessionId, savedAt, anchor, ledger }: { sessionId: string; savedAt: number; anchor: Anchor | null; ledger: SessionLedger }): SavedSession {
24  return { version: 1, sessionId, savedAt, anchor: anchor ? { at: anchor.at, context: anchor.context, tail: anchor.tail, model: anchor.model, ttl: anchor.ttl, ttlSource: anchor.ttlSource } : null, ledger: { spent: ledger.spent, warms: ledger.warms, episodes: ledger.episodes, estimatedNet: ledger.estimatedNet, unknown: ledger.unknown } };
25}
26export function sessionPrefix(sessionId: string): string { return `session:${encodeURIComponent(sessionId)}:`; }
27export function sessionKey(sessionId: string, instance: string): string { return sessionPrefix(sessionId) + instance; }
28export function pickRestore(values: readonly unknown[], at: { sessionId: string; now: number }): RestoreResult {
29  const results = values.map(value => restoreAnchor(value, at));
30  const restored = results.filter((r): r is Extract<RestoreResult, { kind: 'restored' }> => r.kind === 'restored').sort((a, b) => b.savedAt - a.savedAt)[0];
31  return restored ?? results.find(r => r.kind === 'rejected') ?? { kind: 'absent' };
32}
33export function staleKeys(entries: readonly { key: string; value: unknown }[], { now }: { now: number }): string[] {
34  return entries.filter(({ key, value }) => {
35    if (!key.startsWith('session:')) return false;
36    const r = restoreAnchor(value, { sessionId: record(value) && typeof value.sessionId === 'string' ? value.sessionId : '', now: Number.MAX_SAFE_INTEGER });
37    return r.kind !== 'restored' || r.savedAt < now - RESTORE_HORIZON_MS;
38  }).map(({ key }) => key);
39}
40
src/log.ts 24 lines
1import type { Ttl, TtlSource } from './ttl.ts';
2import type { BudgetLedger, SessionLedger, Usage } from './budget.ts';
3import type { CompactOutcome, CompactTrigger } from './compact.ts';
4export interface LogRow { kind?: 'subagent'; agentId?: string; trigger?: CompactTrigger; outcome?: CompactOutcome; at: number; anchorAt?: number; event: string; epoch: number; items: readonly { id: string; kind: string }[]; ttl: Ttl; ttlSource: TtlSource; context: number | null; usage?: Usage; spent: number; reason?: string; warms?: number; pokes?: { count: number; max: number }; ledger?: BudgetLedger; session?: SessionLedger }
5export function appendLog(rows: readonly LogRow[], row: LogRow): LogRow[] {
6  return [...rows, row].slice(-500).map(r => ({ at: r.at, ...(r.kind === 'subagent' && r.agentId ? { kind: r.kind, agentId: r.agentId } : {}), ...(r.trigger === undefined ? {} : { trigger: r.trigger, ...(r.kind !== 'subagent' && r.agentId ? { agentId: r.agentId } : {}), ...(r.outcome ? { outcome: r.outcome } : {}) }), ...(r.anchorAt === undefined ? {} : { anchorAt: r.anchorAt }), event: r.event, epoch: r.epoch, items: r.items.map(({ id, kind }) => ({ id, kind })), ttl: r.ttl, ttlSource: r.ttlSource, context: r.context, spent: r.spent,
7    ...(r.reason === undefined ? {} : { reason: r.reason }), ...(r.warms === undefined ? {} : { warms: r.warms }), ...(r.pokes ? { pokes: { count: r.pokes.count, max: r.pokes.max } } : {}),
8    ...(r.usage ? { usage: { input_tokens: r.usage.input_tokens, output_tokens: r.usage.output_tokens, cache_read_input_tokens: r.usage.cache_read_input_tokens, cache_creation_input_tokens: r.usage.cache_creation_input_tokens } } : {}),
9    ...(r.ledger ? { ledger: { context: r.ledger.context, ttl: r.ledger.ttl, spent: r.ledger.spent, warms: r.ledger.warms, allowance: r.ledger.allowance, protecting: r.ledger.protecting, remaining: r.ledger.remaining } } : {}),
10    ...(r.session ? { session: { spent: r.session.spent, warms: r.session.warms, episodes: r.session.episodes, estimatedNet: r.session.estimatedNet, unknown: r.session.unknown } } : {}),
11  }));
12}
13export interface LogStat { kind: 'file' | 'dir' | 'other'; isLink: boolean; realPath?: string }
14export type LogBase = { ok: true; base: string; pinned: string | null } | { ok: false; reason: 'log directory moved' | 'log directory is not a directory' | 'log directory unresolved' };
15export function logFileName(sessionId: string, instance: string): string { return `${encodeURIComponent(sessionId)}.${encodeURIComponent(instance)}.jsonl`; }
16export function logBase({ dir, stat, pinned }: { dir: string; stat: LogStat | null; pinned: string | null }): LogBase {
17  if (!stat) return pinned === null ? { ok: true, base: dir, pinned: null } : { ok: false, reason: 'log directory moved' };
18  if (stat.kind !== 'dir') return { ok: false, reason: 'log directory is not a directory' };
19  if (!stat.realPath) return { ok: false, reason: 'log directory unresolved' };
20  if (pinned !== null && stat.realPath !== pinned) return { ok: false, reason: 'log directory moved' };
21  return { ok: true, base: stat.realPath, pinned: stat.realPath };
22}
23export function leafRefusal(stat: LogStat | null): string | null { return stat && (stat.isLink || stat.kind !== 'file') ? 'log target is a link or not a regular file' : null; }
24
src/compact.ts 16 lines
1export type CompactTrigger = 'manual' | 'auto' | 'plugin' | 'precompute' | 'unknown';
2export type CompactOutcome = 'installed' | 'skipped' | 'failed';
3export interface CompactFacts { trigger: CompactTrigger; agentId?: string; outcome?: CompactOutcome }
4const TRIGGERS: readonly string[] = ['manual', 'auto', 'plugin', 'precompute'];
5/** The metadata of one `session.compact` the log keeps: its trigger (an unrecognised one reads `unknown`) and, for a sub-agent's or a fork's own loop, its agentId. Never the instructions or the messages. */
6export function compactFacts(input: { trigger?: unknown; agentId?: unknown }): CompactFacts {
7  const trigger = typeof input.trigger === 'string' && TRIGGERS.includes(input.trigger) ? input.trigger as CompactTrigger : 'unknown';
8  return { trigger, ...(typeof input.agentId === 'string' && input.agentId ? { agentId: input.agentId } : {}) };
9}
10/** Whether a compaction invalidates the send anchors: `precompute` installs nothing (its result waits for the next compaction), so the prefix and every anchor stand; every other trigger, an unknown one included, resets. */
11export function compactResets(facts: CompactFacts): boolean { return facts.trigger !== 'precompute'; }
12/** What a compaction came to, read from what `next(e)` resolved to: a `skip` string (a plugin's veto, a classic PreCompact block) installs nothing. */
13export function compactOutcome(result: unknown): CompactOutcome {
14  return typeof (result as { skip?: unknown } | null | undefined)?.skip === 'string' ? 'skipped' : 'installed';
15}
16
src/pokes.ts 8 lines
1/** One thread's poke count for one wait: `key` is the sorted set of its pending item ids. */
2export interface PokeCount { key: string; count: number }
3export function pokeKey(ids: readonly string[]): string { return [...new Set(ids)].sort().join('\n'); }
4/** The count a wait stands at: the thread's counter while its key holds; a changed id set, or a counter real activity cleared (`null`), is a fresh key at zero. */
5export function pokesFor(counter: PokeCount | null, key: string): PokeCount { return counter?.key === key ? counter : { key, count: 0 }; }
6/** The counter after one more poke sent for this wait. A poke and its own reply are never activity, so they never clear it. */
7export function pokeSent(counter: PokeCount | null, key: string): PokeCount { return { key, count: pokesFor(counter, key).count + 1 }; }
8