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

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:
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
| Variable | Option | Default | Meaning |
|---|---|---|---|
CACHE_KEEPWARM_ENABLED | enabled | true | Poke at all; /keepwarm overrides it for one chat |
CACHE_KEEPWARM_MIN_CONTEXT | minContext | 20000 | Smallest main-chat context in tokens worth a poke |
CACHE_KEEPWARM_MAX_POKES | maxPokes | 4 | Pokes per thread on one unchanged wait; an integer of at least 1 |
CACHE_KEEPWARM_LOG_DIR | logDir | empty | Directory for a metadata-only decision log; empty writes none |
CACHE_KEEPWARM_SUBAGENTS | subagents | true | Poke waiting sub-agents, each poke also waking the parent for one turn; the main path is unchanged either way |
CACHE_KEEPWARM_SUBAGENT_MIN_CONTEXT | subagentMinContext | 100000 | Smallest sub-agent context in tokens worth a poke and its parent wake |
CACHE_KEEPWARM_STATUS | status | true | Show 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.
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.
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 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>.
hooks/cache-keepwarm.ts 368 lines1import 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};
368src/options.ts 27 lines1export 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}
27src/ttl.ts 28 lines1export 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}
28src/waits.ts 58 lines1import 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}
58src/schedule.ts 7 lines1import 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]; }
7src/budget.ts 33 lines1import 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) }; }
33src/decision.ts 33 lines1import 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}
33src/control.ts 4 lines1export 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; }
4src/resume.ts 40 lines1import 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}
40src/log.ts 24 lines1import 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; }
24src/compact.ts 16 lines1export 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}
16src/pokes.ts 8 lines1/** 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