cachetoast 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,14 @@
1
+ # Contributing
2
+
3
+ Use Node.js 22 or later. No dependency installation is required. Run `npm test` and `npm run demo`.
4
+
5
+ Keep adapters separate from the provider-neutral policy. Do not edit provider transcripts, store credentials, invoke a model from deterministic hooks, block work on Cachetoast's own errors, or make cache-hit/quota guarantees. Test changes to config merging, timing, context-size gating, and hook contracts. Use synthetic transcript fixtures; never add real customer conversations.
6
+
7
+ ## Release checklist
8
+
9
+ - Confirm current provider docs, hooks, and native compaction controls; update the dated behavior report.
10
+ - Test on supported Node versions and POSIX platforms. Test prompt guards through trusted hooks in each supported client before claiming end-to-end support.
11
+ - Inspect `npm pack --dry-run`: exclude state, credentials, customer transcripts, and local research scratch files.
12
+ - Select an available package name or namespace and set author/repository metadata for the actual publisher. Verify version and license.
13
+ - Review the archive locally before publishing to npm or making a GitHub repository public.
14
+ - Describe native compaction separately from deterministic handoff extraction; document any remaining unsupported behavior.
package/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 R. Hunter Harris, Hunter Software Consulting
4
+ (https://huntersoftwareconsulting.com)
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,101 @@
1
- # Temporary Holding Version
1
+ # Cachetoast
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Cachetoast tries to compact **large, idle** Claude Code and Pi sessions before their prompt cache expires. If compaction cannot run, it holds your next prompt once and offers a handoff to a fresh session. Codex uses that handoff fallback.
4
+
5
+ Zero dependencies. No separate API keys or telemetry. Timers and handoffs run locally; native compaction uses your agent's model and normal billing. Node.js 22+, macOS/Linux.
6
+
7
+ Built by [R. Hunter Harris](https://huntersoftwareconsulting.com) at [Hunter Software Consulting](https://huntersoftwareconsulting.com), fractional product and technology leadership for B2B SaaS.
8
+
9
+ Watch my podcast, [Build & Break Through](https://www.youtube.com/@BuildandBreakThroughPodcast), and subscribe to my newsletter, [Don't SaaS Me](https://hunter-software-consulting.beehiiv.com/).
10
+
11
+ ## Install
12
+
13
+ From the repo you want to protect:
14
+
15
+ ```sh
16
+ npx cachetoast init
17
+ ```
18
+
19
+ It installs for the agents you have (`--provider codex` to choose, `--dry-run` to preview). The runtime is copied into `.cachetoast/runtime`, so hooks never call npm or the network. Run `update` instead of `init` to refresh an existing install; it keeps your providers, policy, state, and handoffs.
20
+
21
+ Then restart your agent.
22
+
23
+ - **Claude Code:** start from the repo root and trust the project. The repo-local `cachetoast-idle` plugin provides the timer; mods require terminal v2.1.287+ or desktop v2.1.286+. Check `/plugin` for loading errors. Older clients retain the handoff fallback.
24
+ - **Codex:** trust the project, run `codex` in the repo, and approve the hooks in `/hooks`. The Codex app skips unapproved hooks and can't approve them ([openai/codex#47283](https://github.com/openai/codex/issues/47283)).
25
+ - **Pi:** trust the project.
26
+
27
+ ## How it works
28
+
29
+ For sessions of at least 100k tokens (`--min-context-tokens`), Claude Code and Pi schedule one native compaction attempt per idle period:
30
+
31
+ | Cache TTL | Compact after idle | Time before expiry |
32
+ | --- | --- | --- |
33
+ | 5 minutes | 4 minutes | 1 minute |
34
+ | 30 minutes | 25 minutes | 5 minutes |
35
+ | 1 hour | 55 minutes | 5 minutes |
36
+
37
+ New activity cancels the timer. Successful compaction avoids the handoff for that idle period and does not start another timer. If the agent closes or the computer sleeps past expiry, Cachetoast skips the cold compaction. Resuming while still warm can restore the remaining timer.
38
+
39
+ If compaction failed, was skipped, or is unavailable, your next prompt after the deadline is held once:
40
+
41
+ ```text
42
+ Cachetoast held this prompt!
43
+
44
+ This ~180k-token session has been idle 55 min with a 1h cache TTL, so its prompt cache has likely expired and continuing would re-read it all as uncached input.
45
+ Send it again to continue here OR start a new session (not resume or fork) and run:
46
+ /cachetoast-handoff claude-0123456789abcdef01234567
47
+ (The handoff includes your prompt.)
48
+ ```
49
+
50
+ Send the prompt again to continue anyway. Or start a **fresh** session in the same repo and run the command from the notice: `/cachetoast-handoff KEY` in Claude Code, `$cachetoast-handoff KEY` in Codex. In Pi, `/handoff` opens the fresh session for you. The handoff is a local extract (requests, latest reply, compaction summary, Git status), not a model summary, and the new session is titled `CT - <subject>`.
51
+
52
+ Small sessions are left alone, since a fresh session wouldn't be cheaper. Cachetoast also:
53
+
54
+ - **Caps compaction at 250k tokens** (`--target-tokens`, 100k–250k), keeping any lower limit already set.
55
+ - **Shows cache status in Pi's footer** (`cache warm 24m · 180k`, then `cache cold`).
56
+ - **Fails open:** if it errors, you see a warning and your prompt goes through.
57
+
58
+ ## Cache TTL
59
+
60
+ The hold starts at the TTL minus up to 5 minutes (1h → 55 min, 30m → 25 min, 5m → 4 min).
61
+
62
+ - **Claude Code:** read from the session (1h on Pro/Max; 5m on API keys, cloud providers, or usage credits).
63
+ - **Codex:** 30m (GPT-5.6 and later).
64
+ - **Pi:** 5m for Claude models (1h with `PI_CACHE_RETENTION=long`), otherwise 30m.
65
+ - **Anything else:** 30m.
66
+
67
+ To override, run `init --pi-cache-ttl 1h` (or `--codex-cache-ttl`, `--claude-cache-ttl`; `5m`, `30m`, `1h`, `24h`). That saves to `~/.config/cachetoast/config.json`; `.cachetoast/local.json` takes the same format per repo. A TTL reported by a Claude session always wins.
68
+
69
+ ## Commands
70
+
71
+ ```sh
72
+ node .cachetoast/runtime/bin/cachetoast.mjs status # sessions, sizes, TTLs, policy
73
+ node .cachetoast/runtime/bin/cachetoast.mjs handoff --key KEY # print a saved handoff (or --provider claude for the latest)
74
+ node .cachetoast/runtime/bin/cachetoast.mjs uninstall # restore original files
75
+ ```
76
+
77
+ ## What to commit
78
+
79
+ Commit `.cachetoast/runtime`, `.cachetoast/config.json`, and the agent settings, hooks, handoff command or skill, and `.claude/skills/cachetoast-idle` plugin. These use repo-relative paths and need no launchd, cron, or global service. `local.json`, `state/`, `handoffs/`, and `manifest.json` are gitignored; handoffs contain conversation excerpts.
80
+
81
+ `init` and `update` are idempotent and leave unrelated settings alone. `uninstall` restores the original files and removes local state. Each stops if you've edited the managed files by hand.
82
+
83
+ ## Limits
84
+
85
+ - Savings come mostly from Claude Code quota: its large contexts are rewritten to cache at 2× the input price after a miss. Codex and Pi misses cost less.
86
+ - Mobile and remote clients may not show the notice clearly, and can't start a fresh local session to run the handoff. Send the prompt again to continue, or run the handoff later from the CLI or desktop app.
87
+ - Expiry is estimated from a local clock; Cachetoast can't see the provider's cache.
88
+ - Timers require the agent to remain running and awake. Starting a compaction before expiry does not guarantee completion or a cache hit. A failed attempt keeps the handoff available.
89
+ - Codex exposes compaction to an owning app-server connection, but its repo hooks cannot portably reach an arbitrary open session. Codex gets the native context ceiling and handoff guard, without a proactive timer.
90
+ - No hold if hooks are disabled, untrusted, or time out.
91
+ - Claude plugin statically validated with Claude Code 2.1.292; adapter behavior tested with synthetic sessions. No paid live-compaction experiment was run. Other integration baselines: Codex CLI 0.159.2 and Pi 0.85.1. Research: [docs/research.md](docs/research.md).
92
+
93
+ ## Develop
94
+
95
+ ```sh
96
+ npm test && npm run demo
97
+ ```
98
+
99
+ ## License
100
+
101
+ [MIT](LICENSE) © 2026 R. Hunter Harris, Hunter Software Consulting. Inspired in part by [Antiburn](https://github.com/antiburn/antiburn); no Antiburn code is included.
@@ -0,0 +1,146 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { handleHook, idleState, recordIdle, configAt, ttlChoice, statePath, userConfigPath, KEY_RE, PROVIDERS } from '../lib/runtime.mjs';
5
+ import { readJson, readText, safePath } from '../lib/storage.mjs';
6
+
7
+ const HELP = `Cachetoast 0.1.1 — local handoffs, inspired in part by Antiburn
8
+ Usage: cachetoast <command> [--repo PATH] [options]
9
+ init Install in the current repo (or --repo) for the agents you have installed.
10
+ [--provider claude,codex,pi] [--dry-run]
11
+ [--claude-cache-ttl TTL] [--codex-cache-ttl TTL] [--pi-cache-ttl TTL]
12
+ [--min-context-tokens 100000] [--target-tokens 250000]
13
+ update Refresh an existing installation from this copy of Cachetoast; keeps installed providers.
14
+ [--provider claude,codex,pi] [--dry-run] (same policy options as init)
15
+ status List tracked sessions, policy, and handoff keys
16
+ handoff --key KEY, or --provider P for its latest: print the saved fresh-session prompt
17
+ uninstall Restore installation files if they have no external edits
18
+ hook --provider claude|codex: JSON lifecycle event on stdin (internal; Pi runs in-process)
19
+ Cache TTLs are read from Claude Code sessions, 30m for Codex (GPT-5.6+), and guessed from Pi's model.
20
+ To override (5m, 30m, 1h, 24h), pass --<agent>-cache-ttl: saved per user in ~/.config/cachetoast/config.json,
21
+ or per repo in the gitignored .cachetoast/local.json. A TTL a Claude session reports always wins.
22
+ Claude Code and Pi attempt idle native compaction at 4m / 25m / 55m for 5m / 30m / 1h TTLs.
23
+ Codex uses the handoff fallback; its hooks cannot portably compact an already-open session.
24
+ Only sessions of 100k+ tokens are held, once per idle period, when compaction did not succeed.
25
+ Hooks and handoffs run locally. Native compaction uses the agent's model and normal billing.
26
+ `;
27
+ function options(args) {
28
+ const result = {};
29
+ const booleans = new Set(['dry-run', 'help']);
30
+ const allowed = new Set(['repo', 'provider', 'claude-cache-ttl', 'codex-cache-ttl', 'pi-cache-ttl', 'min-context-tokens', 'target-tokens', 'key', ...booleans]);
31
+ for (let i = 0; i < args.length; i++) {
32
+ const arg = args[i];
33
+ if (!arg.startsWith('--') || !allowed.has(arg.slice(2))) throw new Error(`Unknown option: ${arg}`);
34
+ const name = arg.slice(2);
35
+ if (Object.hasOwn(result, name)) throw new Error(`Duplicate option: ${arg}`);
36
+ if (booleans.has(name)) result[name] = true;
37
+ else { const value = args[++i]; if (!value || value.startsWith('--')) throw new Error(`Missing value for ${arg}`); result[name] = value; }
38
+ }
39
+ return result;
40
+ }
41
+ async function stdin() {
42
+ let text = '';
43
+ for await (const b of process.stdin) { text += b; if (Buffer.byteLength(text) > 1024 * 1024) throw new Error('stdin exceeds 1 MiB'); }
44
+ return text;
45
+ }
46
+ function findRoot(start) {
47
+ let root = path.resolve(start);
48
+ while (!fs.existsSync(path.join(root, '.cachetoast/config.json'))) {
49
+ const parent = path.dirname(root);
50
+ if (parent === root) throw new Error('No Cachetoast config found; run init');
51
+ root = parent;
52
+ }
53
+ return root;
54
+ }
55
+ const NAMES = { claude: 'Claude Code', codex: 'Codex', pi: 'Pi' };
56
+ const integer = value => value === undefined ? undefined : Number(value);
57
+ function validateKey(key) { if (!KEY_RE.test(key ?? '')) throw new Error('Use a session key from status'); return key; }
58
+ function latestHandoff(root, provider) {
59
+ if (!PROVIDERS.includes(provider)) throw new Error(`handoff needs --key KEY or --provider ${PROVIDERS.join('|')}`);
60
+ const dir = safePath(root, '.cachetoast/handoffs');
61
+ const files = fs.existsSync(dir) ? fs.readdirSync(dir).filter(f => f.endsWith('.md') && KEY_RE.test(f.slice(0, -3)) && f.startsWith(`${provider}-`)) : [];
62
+ if (!files.length) throw new Error('No saved handoff yet');
63
+ return files.map(f => [f.slice(0, -3), fs.statSync(path.join(dir, f)).mtimeMs]).sort((a, b) => b[1] - a[1])[0][0];
64
+ }
65
+ async function main() {
66
+ const argv = process.argv.slice(2);
67
+ if (argv[0] === '--help' || argv[0] === '-h') { process.stdout.write(HELP); return; }
68
+ const [command = 'help', ...args] = argv, o = options(args);
69
+ if (command === 'help' || o.help) { process.stdout.write(HELP); return; }
70
+ if (command === 'idle') {
71
+ const input = JSON.parse(await stdin()), root = findRoot(o.repo ?? process.cwd());
72
+ const out = input.status ? recordIdle(root, o.provider, input.session_id, input)
73
+ : idleState(root, o.provider, input.session_id);
74
+ process.stdout.write(JSON.stringify(out) + '\n');
75
+ return;
76
+ }
77
+ if (command === 'hook') {
78
+ // A cost advisory must never stop work because of its own failure: fail open with a visible warning.
79
+ let result;
80
+ try {
81
+ const input = JSON.parse(await stdin());
82
+ result = handleHook(findRoot(o.repo ?? input.cwd ?? process.cwd()), o.provider, input);
83
+ } catch (error) {
84
+ result = { systemMessage: `Cachetoast skipped this check: ${error.message}. Run its status command to reconcile.` };
85
+ }
86
+ process.stdout.write(JSON.stringify(result) + '\n');
87
+ return;
88
+ }
89
+ // Installer code loads only for install commands, keeping hooks fast.
90
+ if (command === 'init' || command === 'update') {
91
+ const { install, update, detectProviders } = await import('../lib/install.mjs');
92
+ const updating = command === 'update';
93
+ const root = updating && !o.repo ? findRoot(process.cwd()) : path.resolve(o.repo ?? process.cwd());
94
+ const unreadable = new Set();
95
+ const providers = o.provider ? o.provider.split(',') : updating ? undefined : detectProviders(process.env, unreadable);
96
+ const skipped = unreadable.size ? ` (permission denied checking ${[...unreadable].join(', ')})` : '';
97
+ if (providers && !providers.length) throw new Error(`No Claude Code, Codex, or Pi installation found${skipped}; pass --provider claude,codex,pi`);
98
+ if (skipped) console.error(`cachetoast: warning: some agents may not be detected${skipped}; pass --provider to choose them.`);
99
+ if (providers?.some(p => !PROVIDERS.includes(p))) throw new Error(`Choose providers from ${PROVIDERS.join(', ')}`);
100
+ const cacheTtl = Object.fromEntries(PROVIDERS.map(p => [p, o[`${p}-cache-ttl`]]));
101
+ const result = (updating ? update : install)(root, {
102
+ providers, cacheTtl, minContextTokens: integer(o['min-context-tokens']), targetTokens: integer(o['target-tokens']), dryRun: o['dry-run']
103
+ });
104
+ const list = items => items.map(p => NAMES[p] ?? p).join(', ');
105
+ const action = updating ? 'update' : 'install';
106
+ console.log(result.changed.length
107
+ ? `${result.dryRun ? `Dry run: would ${action} Cachetoast` : `Cachetoast ${updating ? 'updated' : 'installed'}`} in ${root} (${list(result.providers)}).`
108
+ : `Cachetoast: up to date in ${root}.`);
109
+ if (result.dryRun && result.changed.length) {
110
+ const changed = [...new Set(result.changed.map(f => f.startsWith('.cachetoast/runtime/') ? '.cachetoast/runtime/' : f))];
111
+ console.log(`Would change: ${changed.join(', ')}`);
112
+ }
113
+ if (result.userConfig) console.log(`${result.dryRun ? 'Would save' : 'Saved'} cache TTLs to ${result.userConfig}.`);
114
+ if (result.dryRun || !result.changed.length) return;
115
+ const next = ['Restart your agent.'];
116
+ if (result.providers.includes('claude')) next.push('Claude: launch from the repo root, trust the project, and check /plugin for cachetoast-idle (requires mods support).');
117
+ if (result.providers.includes('codex') || result.providers.includes('pi')) next.push('Trust the repo.');
118
+ if (result.providers.includes('codex')) next.push('Codex: review /hooks. Proactive idle compaction is unavailable, so the handoff is the fallback.');
119
+ console.log(next.join(' '));
120
+ return;
121
+ }
122
+ const root = findRoot(o.repo ?? process.cwd());
123
+ if (command === 'uninstall') {
124
+ const { uninstall } = await import('../lib/install.mjs');
125
+ const result = uninstall(root);
126
+ console.log(`Cachetoast uninstalled from ${root}.`);
127
+ if (result.kept.length) console.log(`Kept: ${result.kept.join(', ')}.`);
128
+ } else if (command === 'handoff') {
129
+ // Without --key, the most recently held session for --provider: what a fresh session's /cachetoast-handoff wants.
130
+ const key = o.key ? validateKey(o.key) : latestHandoff(root, o.provider);
131
+ const text = readText(safePath(root, `.cachetoast/handoffs/${key}.md`));
132
+ if (!text) throw new Error('No saved handoff for this session yet');
133
+ process.stdout.write(text);
134
+ } else if (command === 'status') {
135
+ const config = configAt(root), dir = safePath(root, '.cachetoast/state');
136
+ const keys = fs.existsSync(dir) ? fs.readdirSync(dir).filter(f => f.endsWith('.json') && KEY_RE.test(f.slice(0, -5))).map(f => f.slice(0, -5)) : [];
137
+ const sessions = keys.map(key => {
138
+ try {
139
+ const s = readJson(safePath(root, statePath(key)));
140
+ return { key, provider: s.provider, ...ttlChoice(config, s.provider, s), heldAt: s.heldAt ?? null, contextTokens: s.contextTokens ?? null, lastActivity: s.lastActivity, compaction: s.compaction ?? null, warnings: s.warnings ?? [] };
141
+ } catch (error) { return { key, error: `Unreadable state (${error.message}); delete it to reset` }; }
142
+ });
143
+ console.log(JSON.stringify({ config, userConfig: userConfigPath(), sessions, note: 'Cache availability is inferred, never guaranteed. Sessions with unknown context size are never held.' }, null, 2));
144
+ } else throw new Error(`Unknown command: ${command}`);
145
+ }
146
+ main().catch(error => { console.error(`cachetoast: ${error.message}`); process.exitCode = 1; });
@@ -0,0 +1,63 @@
1
+ # Verified behavior and design decisions
2
+
3
+ Checked October 1, 2026 against primary provider documentation. Installed clients: Codex CLI 0.159.2 and Claude Code 2.1.285. No paid inference or quota experiment was performed. The private discussion was used to identify questions; its claims were not treated as documentation.
4
+
5
+ Updated October 17, 2026: Claude's current mods API supports host timers and in-process compaction. The generated plugin passed strict static validation with Claude Code 2.1.292. Timing, cancellation, native API calls, and fallback state are tested with synthetic sessions; no paid live-compaction experiment was performed.
6
+
7
+ ## What survives cache expiry
8
+
9
+ Cache expiry removes reusable inference state, not the transcript or its context window. Continuing sends the current context again. Earlier compacted-away history is not magically restored. Native compaction changes the prompt prefix and can require a new cache write. A handoff prepared locally avoids submitting long history solely to summarize it.
10
+
11
+ ## Claude: the assumption needs correction
12
+
13
+ Claude’s current docs say a cold `/compact` summarization request reprocesses the full history. Warm compaction can reuse the prefix. Claude Code already has one cache-aware path: on Pro/Max, **resuming** (`--resume` or `/resume`) a session inactive for more than about an hour and over 100,000 tokens opens a dialog offering resume-from-summary or resume-as-is (documented on the sessions page; the strings are present in 2.1.285, and the dialog may be feature-gated). It does not cover typing into a terminal that stayed open while idle, which is the case Cachetoast guards. “Claude has no cache-aware behavior” is too broad, and its 100K threshold motivates Cachetoast’s minimum context size.
14
+
15
+ Included subscription main turns normally request one hour. API keys, cloud providers, and usage-credit billing normally use five minutes. Most helpers, including compaction, use five minutes unless overridden. Explicit controls exist: `promptCacheTtl` and `subagentPromptCacheTtl` settings, `CLAUDE_CODE_PROMPT_CACHE_TTL` (`5m`/`1h`), `ENABLE_PROMPT_CACHING_1H`, and `FORCE_PROMPT_CACHING_5M`. Switching from included usage to credits can shorten the effective lifetime. These are per-request controls, not a universal one-hour guarantee. Each assistant transcript record's `usage.cache_creation` splits cache writes into `ephemeral_1h_input_tokens` and `ephemeral_5m_input_tokens`, so Cachetoast reads the TTL a session actually gets from its latest write rather than from settings or billing. See [Claude Code prompt caching](https://code.claude.com/docs/en/prompt-caching).
16
+
17
+ Anthropic’s API supports five-minute and one-hour cache writes, priced at 1.25× and 2× ordinary input respectively. Reads are usually 0.1× with model-specific exceptions. Hits refresh the TTL. This is API pricing, not a formula for subscription quota. See [Anthropic prompt caching](https://platform.claude.com/docs/en/build-with-claude/prompt-caching).
18
+
19
+ Claude plan usage is shared across Claude and Claude Code. API-key authentication can use separate API billing. Exact subscription allowance savings should not be inferred from API-dollar estimates. See [Claude Pro/Max usage and billing](https://support.claude.com/en/articles/11145838-use-claude-code-with-your-pro-or-max-plan).
20
+
21
+ ## OpenAI API versus Codex quota/credits
22
+
23
+ Current API docs distinguish model generations. GPT-5.6 and later support a default/minimum 30-minute cache lifetime and charge 1.25× uncached input for cache writes. GPT-5.6+ set this with `prompt_cache_options.ttl` (only `30m`). Older models use `prompt_cache_retention`: `in_memory` entries commonly last 5–10 idle minutes, up to an hour; `24h` extended retention can last a day and is the default for organizations without Zero Data Retention where both are supported. GPT-5.5 supports only `24h`. Prefix changes and routing also affect hits. A single “30-minute Codex cache” rule cannot be applied to every model and billing mode. Codex transcripts record no TTL, so Cachetoast supports GPT-5.6 and later only and assumes their 30-minute minimum. See [OpenAI prompt caching](https://developers.openai.com/api/docs/guides/prompt-caching).
24
+
25
+ Codex credit billing explicitly has no separate cache-write charge; API-key usage follows API pricing. Cached and uncached input have distinct credit rates. Included subscription usage is not determined solely by credit prices. Consequently “rehydration is free/irrelevant on subscriptions” does not follow. No public source reviewed establishes a universal exact quota weighting or Codex-client retention contract. See [Codex pricing and limits](https://learn.chatgpt.com/docs/pricing).
26
+
27
+ OpenAI offers threshold-based server compaction and a standalone compact endpoint accepting the current context. The compacted output can include opaque state. These documented flows do not establish a blanket provider difference where only OpenAI reads cold history before compaction. Both require context to produce a semantic summary; a cache hit or an existing saved summary changes that cost. See [OpenAI compaction](https://developers.openai.com/api/docs/guides/compaction).
28
+
29
+ ## Compaction settings
30
+
31
+ Codex documents `model_auto_compact_token_limit`, with model defaults when unset. Its scope defaults to total active context; `body_after_prefix` counts only growth after a carried prefix. Repo settings and hooks can be overridden or disabled at higher priority. Cachetoast installs a root threshold of at most 250000 without changing model capacity. See [Codex configuration reference](https://learn.chatgpt.com/docs/config-file/config-reference).
32
+
33
+ Claude supports an auto-compact window of 100K–1M and caps it at model capacity. The environment variable accepts a plain token count. Default timing differs by model, with large windows able to grow far beyond 250K. Cachetoast sets the window to 250000 or keeps an earlier setting. Exact observed cutoff can be earlier due to reserves and native compaction behavior. See [Claude model configuration](https://code.claude.com/docs/en/model-config) and [environment variables](https://code.claude.com/docs/en/env-vars).
34
+
35
+ A universal “shorter sessions always perform better” or “250K is optimal” claim was not verified. 250K is the requested operational ceiling. Native compaction can lose detail; checkpoints preserve explicit constraints and next steps.
36
+
37
+ ## Available hooks
38
+
39
+ Both clients expose command hooks including `UserPromptSubmit`, `Stop`, and compaction events. `UserPromptSubmit` supports blocking before the model processes the prompt. Cachetoast holds the first continuation after the idle deadline once (`decision: "block"`) when native compaction has not succeeded; sending it again proceeds. Codex's CLI adds a submitted prompt to message history before hooks run, so Up-arrow recalls a held prompt. Handoff extraction is deterministic and does not modify provider transcripts.
40
+
41
+ Claude's newer [mods API](https://code.claude.com/docs/en/plugins/mods/reference) exposes `$.clock.after()` and `$.session.compact()`. The latter uses native semantic compaction between turns, rejects while a turn runs, and returns a skip reason when vetoed. Cachetoast installs a bundled module under `.claude/skills/cachetoast-idle`; [project plugins](https://code.claude.com/docs/en/plugins/loading#plugins-shared-through-a-repository) load after trust, from the primary working directory (desktop worktrees may load the main checkout's plugin). Launch from the repo root. Mods require terminal v2.1.287+ or desktop v2.1.286+ per the [mod creation docs](https://code.claude.com/docs/en/plugins/mods/create). The module uses the host process API to read/write Cachetoast state through its copied Node runtime, without invoking another agent or editing the transcript itself.
42
+
43
+ Claude and Pi now attempt compaction at TTL minus up to five minutes: 5m → 4m, 30m → 25m, 1h → 55m. New activity cancels the timer. Each idle cycle has at most one attempt; success suppresses the fallback for that cycle. Suspended timers waking at or beyond expiry skip compaction. Native compaction has model cost and cache hits remain uncertain.
44
+
45
+ Codex's [app-server compaction API](https://learn.chatgpt.com/docs/app-server) exposes `thread/compact/start` to an owning connection. This does not establish a portable repo-hook connection to an arbitrary running CLI or desktop session. Cachetoast keeps Codex's threshold and handoff fallback, and installs no private-socket, UI, or second-process transcript workaround.
46
+
47
+ Codex loads trusted repo hooks from `.codex/hooks.json`; new or changed hook definitions require review in `/hooks`. Hooks run with the session cwd, which may be a subdirectory. See [Codex hooks](https://learn.chatgpt.com/docs/hooks).
48
+
49
+ Claude’s `UserPromptSubmit` hooks fail open on timeout or non-zero exit (other than exit 2), and `Stop` does not fire on user interrupt. Cachetoast fails open on its own errors too, and reads response timestamps from the transcript to cover interrupted turns. Command hooks use `UserPromptSubmit` and `Stop`; the native mod also handles startup, turn start, compaction, and shutdown. See [Claude hooks](https://code.claude.com/docs/en/hooks).
50
+
51
+ ## Pi
52
+
53
+ Checked against the docs shipped with Pi 0.85.1. Extensions are TypeScript modules loaded in-process via jiti from `.pi/extensions/` after project trust. The `input` event fires before skill/template expansion and agent start; `agent_settled` fires once Pi will not continue automatically. `ctx.getContextUsage()` reports current context tokens. `ctx.compact()` aborts any running turn before compacting, so Cachetoast only calls it when idle. Auto-compaction triggers at `contextWindow - compaction.reserveTokens`; there is no absolute token setting. `ctx.newSession({ parentSession, withSession })` creates a linked session; only the replacement context is valid inside `withSession`. `PI_CACHE_RETENTION=long` requests extended provider caching: 1h `cache_control` for Anthropic models that support it and 30m for OpenAI GPT-5.6+. Pi's usage records carry no TTL, so Cachetoast guesses one from `ctx.model`: 5m for Claude models (1h from Anthropic with the long setting), otherwise 30m. Pi compaction requests use fresh routing session IDs and disable cache writes where supported.
54
+
55
+ ## Cost intuition
56
+
57
+ Let H be retained history tokens, S be handoff tokens, R the cache-read rate, W the write/uncached rate, O compaction output cost, and G the re-grounding cost of a fresh session (re-reading files and rebuilding understanding over several requests). Cold continuation of the full history roughly costs H×W. A fresh session from a local handoff roughly costs S×W + G, plus some lost detail. A warm semantic compact followed by a cold continuation roughly costs H×R + O + S×W. Rotation only pays off when H×W clearly exceeds S×W + G, so Cachetoast ignores sessions below a minimum context size (default 100K). These are illustrative components, not invoices; stable prefixes, routing, provider behavior, output usage, and plan accounting affect the result.
58
+
59
+ After native compaction the next request writes a new cache whether the user continues or starts fresh, so compaction is not itself a reason to rotate. Compacting after expiry may pay for the old history before obtaining a smaller context. Repeated early compaction also has overhead. No keepalive requests are sent merely to extend a cache.
60
+
61
+ ## Inspiration
62
+
63
+ [Antiburn](https://antiburn.com/) highlights long context and cache rehydration in local coding-agent usage. Its [open source repository](https://github.com/antiburn/antiburn) is credited as inspiration. Cachetoast implements a narrow repo bootstrap and handoff policy independently. The source discussion is omitted from distribution.
@@ -0,0 +1,21 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { install } from '../lib/install.mjs';
5
+ import { handleHook } from '../lib/runtime.mjs';
6
+
7
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cachetoast-demo-'));
8
+ // Keep the demo away from your real ~/.config/cachetoast.
9
+ process.env.XDG_CONFIG_HOME = path.join(root, '.config');
10
+ try {
11
+ install(root);
12
+ // A synthetic Codex transcript reporting a 180k-token context.
13
+ const transcript = path.join(root, 'rollout.jsonl');
14
+ fs.writeFileSync(transcript, JSON.stringify({ type: 'event_msg', timestamp: new Date(0).toISOString(), payload: { type: 'token_count', info: { last_token_usage: { total_tokens: 180_000 } } } }) + '\n');
15
+ const event = (name, at, extra) => handleHook(root, 'codex', { session_id: 'demo', hook_event_name: name, cwd: root, transcript_path: transcript, ...extra }, { now: at });
16
+ event('UserPromptSubmit', 0, { prompt: 'Finish the parser. Keep the public API stable.' });
17
+ event('Stop', 0, { last_assistant_message: 'Parser refactor is complete. Unicode tests remain.' });
18
+ const result = event('UserPromptSubmit', 25 * 60_000, { prompt: 'Add the remaining unicode tests.' });
19
+ console.log(result.reason);
20
+ console.log('\n--- saved handoff ---\n' + fs.readFileSync(path.join(root, '.cachetoast/handoffs', fs.readdirSync(path.join(root, '.cachetoast/handoffs'))[0]), 'utf8'));
21
+ } finally { fs.rmSync(root, { recursive: true, force: true }); }
@@ -0,0 +1,65 @@
1
+ import { createIdleCompactor } from './idle.mjs';
2
+
3
+ // The installer bundles this module into a repo-local Claude Code plugin.
4
+ // No Node globals: all host access goes through Claude's portable mod API.
5
+ let controller, root, sessionId;
6
+ function warn($, error) { void Promise.resolve($.ui.log(`Cachetoast idle compaction skipped: ${error.message}. The handoff remains available.`)).catch(() => {}); }
7
+ async function command($, input, target = { root, sessionId }) {
8
+ const result = await $.process.run(['node', `${target.root}/.cachetoast/runtime/bin/cachetoast.mjs`, 'idle', '--provider', 'claude', '--repo', target.root], {
9
+ cwd: target.root, stdin: JSON.stringify({ session_id: target.sessionId, ...input }), timeoutMs: 10_000
10
+ });
11
+ if (result.exitCode !== 0) throw new Error(result.stderr || 'Cannot read compaction state');
12
+ return JSON.parse(result.stdout);
13
+ }
14
+ async function connect($) {
15
+ const id = await $.session.id();
16
+ let dir = await $.session.cwd();
17
+ while (!await $.fs.exists(`${dir}/.cachetoast/config.json`)) {
18
+ const parent = dir.slice(0, dir.lastIndexOf('/')) || '/';
19
+ if (parent === dir) return false;
20
+ dir = parent;
21
+ }
22
+ if (controller && (sessionId !== id || root !== dir)) { controller.cancel(); controller = undefined; }
23
+ root = dir; sessionId = id;
24
+ const target = { root, sessionId };
25
+ controller ??= createIdleCompactor({
26
+ read: () => command($, {}, target),
27
+ record: (s, status, result) => command($, { activityId: s.activityId, lastActivity: s.lastActivity, status, skip: result?.skip, tokensAfter: result?.tokensAfter }, target),
28
+ compact: () => $.session.compact(),
29
+ now: () => $.clock.now(), after: (ms, fn) => $.clock.after(ms, fn), onError: error => warn($, error)
30
+ });
31
+ return true;
32
+ }
33
+ export function register(on) {
34
+ on('classic.Stop', async ($, e, next) => {
35
+ const result = await next(e);
36
+ if (!e.agent_id && !controller?.isRunning()) {
37
+ try { if (await connect($)) await controller.arm(); } catch (error) { warn($, error); }
38
+ }
39
+ return result;
40
+ });
41
+ on('classic.SessionStart', async ($, e, next) => {
42
+ controller?.cancel();
43
+ const result = await next(e);
44
+ try { if (await connect($)) await controller.arm(); } catch (error) { warn($, error); }
45
+ return result;
46
+ });
47
+ on('classic.UserPromptSubmit', ($, e, next) => { controller?.cancel(); return next(e); });
48
+ on('turn.start', ($, e, next) => { controller?.cancel(); return next(e); });
49
+ on('session.end', ($, e, next) => { controller?.cancel(); return next(e); });
50
+ // Manual/threshold compaction also satisfies this idle cycle; precompute does not install a summary.
51
+ on('session.compact', async ($, e, next) => {
52
+ if (e.agentId || e.trigger === 'precompute') return next(e);
53
+ if (controller?.isRunning()) return next(e);
54
+ controller?.cancel();
55
+ let state;
56
+ try { if (await connect($)) state = await command($, {}); } catch (error) { warn($, error); }
57
+ const target = { root, sessionId };
58
+ const result = await next(e);
59
+ if (state && !result.skip) {
60
+ try { await command($, { activityId: state.activityId, lastActivity: state.lastActivity, status: 'succeeded', tokensAfter: result.tokensAfter }, target); }
61
+ catch (error) { warn($, error); }
62
+ }
63
+ return result;
64
+ });
65
+ }
package/lib/idle.mjs ADDED
@@ -0,0 +1,35 @@
1
+ // Host-neutral, single-shot timer. Hosts supply their own clock and native compaction API.
2
+ // Bundled into Claude's sandboxed module as well as imported by Pi.
3
+ export function createIdleCompactor({ read, record, compact, now, after, onError }) {
4
+ let timer, revision = 0, running = false;
5
+ const cancel = () => { revision++; timer?.cancel(); timer = undefined; };
6
+ const eligible = s => s && s.lastActivity != null && Number.isFinite(s.contextTokens)
7
+ && s.contextTokens >= s.p.minContextTokens && s.compaction?.activityId !== s.activityId;
8
+ const arm = async () => {
9
+ cancel();
10
+ const ticket = revision;
11
+ try {
12
+ const s = await read(), time = await now();
13
+ if (ticket !== revision || running || !eligible(s) || time >= s.lastActivity + s.p.ttlMs) return;
14
+ timer = after(Math.max(0, s.lastActivity + s.p.idleMs - time), async () => {
15
+ timer = undefined;
16
+ try {
17
+ const current = await read(), firedAt = await now();
18
+ if (ticket !== revision || running || !eligible(current) || current.activityId !== s.activityId
19
+ || current.lastActivity !== s.lastActivity || firedAt < current.lastActivity + current.p.idleMs
20
+ || firedAt >= current.lastActivity + current.p.ttlMs) return;
21
+ if (!await record(s, 'attempting') || ticket !== revision) return;
22
+ running = true;
23
+ try {
24
+ const result = await compact();
25
+ await record(s, result?.skip ? 'skipped' : 'succeeded', result);
26
+ } catch (error) {
27
+ await record(s, 'failed', { skip: error.message });
28
+ throw error;
29
+ } finally { running = false; }
30
+ } catch (error) { onError(error); }
31
+ });
32
+ } catch (error) { onError(error); }
33
+ };
34
+ return { arm, cancel, isRunning: () => running };
35
+ }
@@ -0,0 +1,203 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { policy, DEFAULT_MIN_CONTEXT } from './policy.mjs';
6
+ import { safePath, readText, readJson, atomicWrite, writeFileAtomic, hash } from './storage.mjs';
7
+ import { PROVIDERS, userConfigPath, readUserConfig, ttlFor } from './runtime.mjs';
8
+
9
+ const PACKAGE_ROOT = fileURLToPath(new URL('../', import.meta.url));
10
+ // Agents this user has: a CLI on PATH or a home directory (~/.claude, ~/.codex, ~/.pi).
11
+ // Directories that could not be searched are added to `unreadable` so the caller can say so.
12
+ export function detectProviders(env = process.env, unreadable = new Set()) {
13
+ const home = env.HOME || os.homedir(), dirs = (env.PATH ?? '').split(path.delimiter).filter(Boolean);
14
+ const denied = e => e.code === 'EACCES' || e.code === 'EPERM';
15
+ const onPath = name => dirs.some(dir => {
16
+ const file = path.join(dir, name);
17
+ try { fs.accessSync(file, fs.constants.X_OK); return true; } catch (e) {
18
+ // Denied on an existing file only means it is not executable; on a missing one, the directory is unreadable.
19
+ if (denied(e)) try { fs.statSync(file); } catch (s) { if (denied(s)) unreadable.add(dir); }
20
+ return false;
21
+ }
22
+ });
23
+ const inHome = name => { try { fs.statSync(path.join(home, `.${name}`)); return true; } catch (e) { if (denied(e)) unreadable.add(home); return false; } };
24
+ return PROVIDERS.filter(p => onPath(p) || inHome(p));
25
+ }
26
+ function repoRoot(root) {
27
+ root = path.resolve(root);
28
+ if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) throw new Error('Repository directory does not exist');
29
+ return root;
30
+ }
31
+ function codexConfig(text, target) {
32
+ // Only edit a root scalar, never a similarly named key inside a TOML table.
33
+ const lines = text.split('\n');
34
+ let table = false, found = false, scopeFound = false;
35
+ for (let i = 0; i < lines.length; i++) {
36
+ if (/^\s*\[/.test(lines[i])) table = true;
37
+ if (!table && /^\s*model_auto_compact_token_limit_scope\s*=/.test(lines[i])) {
38
+ if (scopeFound) throw new Error('Duplicate Codex compaction scope');
39
+ lines[i] = 'model_auto_compact_token_limit_scope = "total"';
40
+ scopeFound = true;
41
+ }
42
+ if (!table && /^\s*model_auto_compact_token_limit\s*=/.test(lines[i])) {
43
+ if (found) throw new Error('Duplicate Codex compaction key');
44
+ const m = lines[i].match(/^\s*model_auto_compact_token_limit\s*=\s*([\d_]+)\s*(?:#.*)?$/);
45
+ if (!m) throw new Error('Unsupported Codex compaction scalar; use a plain integer');
46
+ lines[i] = `model_auto_compact_token_limit = ${Math.min(Number(m[1].replaceAll('_', '')), target)}`;
47
+ found = true;
48
+ }
49
+ }
50
+ return (found ? '' : `model_auto_compact_token_limit = ${target}\n`) + (scopeFound ? '' : 'model_auto_compact_token_limit_scope = "total"\n') + lines.join('\n');
51
+ }
52
+ const SCRIPT = '.cachetoast/runtime/bin/cachetoast.mjs';
53
+ function handoffInstructions(provider, requested) {
54
+ const naming = provider === 'codex'
55
+ ? 'Use the available session-title tool (in the Codex app, `set_thread_title` without a thread ID) to rename only this new session.'
56
+ : 'Use a supported session-title tool if one is available to rename only this new session.';
57
+ return `${requested}\n\nFrom the repository root, load the requested handoff with \`node ${SCRIPT} handoff --key KEY\`, replacing KEY with the exact session key supplied by the user. Validate that it matches \`${provider}-[a-f0-9]{24}\` before using it in a command. If no key was supplied, run \`node ${SCRIPT} handoff --provider ${provider}\` to load this provider's most recently held session. If a supplied key is invalid or its handoff is missing, report the error; do not fall back to another session.\n\nName this new session \`CT - <subject>\`. Prefer the handoff's suggested session name when it describes the prior work. If it is generic (such as "continue"), choose a short subject from the work described in the excerpts. Keep exactly one \`CT - \` prefix. ${naming} If no naming tool is available, show the user the exact \`/rename CT - <subject>\` command once, then continue the work. Do not run another coding agent or edit provider session files to rename it, and do not claim it was renamed without a successful tool result.\n\nThe output is the handoff for this session: treat it as the user's continuation request, including the pending request, and follow it.\n`;
58
+ }
59
+ const EVENTS = ['UserPromptSubmit', 'Stop'];
60
+ const owned = hook => typeof hook?.command === 'string' && hook.command.includes(SCRIPT);
61
+ function mergeHooks(settings, provider) {
62
+ settings.hooks ??= {};
63
+ if (typeof settings.hooks !== 'object' || Array.isArray(settings.hooks)) throw new Error('Invalid hooks object');
64
+ // Fixed code; no user-controlled paths are interpolated into a shell command.
65
+ // One process: resolve the root from cwd (subdirectories, movable checkouts), then import the CLI.
66
+ // No config found means not installed here: exit quietly so the prompt proceeds.
67
+ const launcher = `const fs=require("node:fs"),p=require("node:path");let r=process.cwd();while(!fs.existsSync(p.join(r,".cachetoast/config.json"))){const q=p.dirname(r);if(q===r)process.exit(0);r=q}process.argv=[process.argv[0],p.join(r,"${SCRIPT}"),"hook","--provider","${provider}","--repo",r];import(require("node:url").pathToFileURL(process.argv[1]).href).catch(e=>console.log(JSON.stringify({systemMessage:"Cachetoast skipped this check: "+e.message})))`;
68
+ const command = `node -e '${launcher}'`;
69
+ // Remove every hook this package owns (including older versions' events), preserving all others.
70
+ for (const [event, groups] of Object.entries(settings.hooks)) {
71
+ if (!Array.isArray(groups)) throw new Error(`Invalid ${event} hooks`);
72
+ const kept = groups.map(group => ({ ...group, hooks: (group.hooks ?? []).filter(h => !owned(h)) })).filter(group => group.hooks.length);
73
+ if (kept.length) settings.hooks[event] = kept; else delete settings.hooks[event];
74
+ }
75
+ for (const event of EVENTS) (settings.hooks[event] ??= []).push({ hooks: [{ type: 'command', command, timeout: 10 }] });
76
+ return settings;
77
+ }
78
+ export function install(root, options = {}) {
79
+ root = repoRoot(root);
80
+ const providers = options.providers ?? PROVIDERS;
81
+ if (!providers.length || providers.some(p => !PROVIDERS.includes(p))) throw new Error(`Choose providers from ${PROVIDERS.join(', ')}`);
82
+ const oldConfig = readJson(safePath(root, '.cachetoast/config.json'));
83
+ const target = options.targetTokens ?? oldConfig?.targetTokens ?? 250_000;
84
+ if (!Number.isInteger(target) || target < 100_000 || target > 250_000) throw new Error('targetTokens must be 100000 through 250000');
85
+ const config = { version: 1, targetTokens: target, minContextTokens: options.minContextTokens ?? oldConfig?.minContextTokens ?? DEFAULT_MIN_CONTEXT };
86
+ // TTL overrides depend on each person's billing, so they go to the user config, shared by all their repos.
87
+ const userPath = userConfigPath(), userOriginal = readText(userPath, null), user = readUserConfig();
88
+ for (const provider of PROVIDERS) if (options.cacheTtl?.[provider] !== undefined) user[provider] = { ...user[provider], cacheTtl: options.cacheTtl[provider] };
89
+ const local = readJson(safePath(root, '.cachetoast/local.json'), {});
90
+ for (const provider of PROVIDERS) policy({ ...ttlFor(user, local, provider), targetTokens: target, minContextTokens: config.minContextTokens });
91
+ const userData = JSON.stringify(user, null, 2) + '\n', userChanged = userData !== userOriginal && Object.keys(user).length > 0;
92
+ const changes = new Map();
93
+ const set = (relative, data) => { safePath(root, relative); changes.set(relative, data); };
94
+ if (providers.includes('claude')) {
95
+ const settings = readJson(safePath(root, '.claude/settings.json'), {});
96
+ settings.env ??= {};
97
+ const prior = settings.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW;
98
+ if (prior != null && !/^\d+$/.test(String(prior))) throw new Error('Existing Claude compact window must be a plain integer');
99
+ settings.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW = String(Math.min(prior == null ? target : Number(prior), target));
100
+ // The bootstrap explicitly enables this compaction policy in the repo.
101
+ settings.autoCompactEnabled = true;
102
+ for (const flag of ['DISABLE_AUTO_COMPACT', 'DISABLE_COMPACT']) if (settings.env[flag] === '1') settings.env[flag] = '0';
103
+ set('.claude/settings.json', JSON.stringify(mergeHooks(settings, 'claude'), null, 2) + '\n');
104
+ // Claude discovers trusted project plugins here. Relative paths survive moves and clones.
105
+ const plugin = '.claude/skills/cachetoast-idle';
106
+ set(`${plugin}/.claude-plugin/plugin.json`, JSON.stringify({ name: 'cachetoast-idle', version: '0.1.1', author: { name: 'R. Hunter Harris' }, description: 'Compact large idle sessions before estimated cache expiry' }, null, 2) + '\n');
107
+ set(`${plugin}/hooks/hooks.json`, JSON.stringify({ modules: ['./register.mjs'] }, null, 2) + '\n');
108
+ const idle = readText(path.join(PACKAGE_ROOT, 'lib/idle.mjs')).replace(/^export /gm, '');
109
+ const adapter = readText(path.join(PACKAGE_ROOT, 'lib/claude-mod.mjs')).replace(/^import .*\n/, '');
110
+ set(`${plugin}/hooks/register.mjs`, `// Managed by Cachetoast; bundled for Claude's mod sandbox.\n${idle}\n${adapter}`);
111
+ // Arguments are prompt data, never interpolated into an injected shell command.
112
+ set('.claude/commands/cachetoast-handoff.md', `---\ndescription: Continue a Cachetoast-held session by its key (latest if omitted)\nargument-hint: "[session-key]"\nallowed-tools: Bash(node ${SCRIPT} handoff:*)\n---\n<!-- Managed by Cachetoast; edits are overwritten by init. -->\n${handoffInstructions('claude', 'Requested session key: $ARGUMENTS')}`);
113
+ }
114
+ if (providers.includes('codex')) {
115
+ set('.codex/config.toml', codexConfig(readText(safePath(root, '.codex/config.toml')), target));
116
+ set('.codex/hooks.json', JSON.stringify(mergeHooks(readJson(safePath(root, '.codex/hooks.json'), {}), 'codex'), null, 2) + '\n');
117
+ // Codex has no project slash commands; a repo skill has the agent load the handoff instead.
118
+ set('.agents/skills/cachetoast-handoff/SKILL.md', `---\nname: cachetoast-handoff\ndescription: Continue a Cachetoast-held Codex session by its key (latest if omitted) in this fresh session.\n---\n<!-- Managed by Cachetoast; edits are overwritten by init. -->\n${handoffInstructions('codex', 'Use the session key supplied after $cachetoast-handoff in the user message, if any.')}`);
119
+ }
120
+ if (providers.includes('pi')) {
121
+ // Pi has no absolute compaction setting; the extension enforces the ceiling.
122
+ set('.pi/extensions/cachetoast.ts', '// Managed by Cachetoast; edits are overwritten by init.\nexport { default } from "../../.cachetoast/runtime/lib/pi-extension.mjs";\n');
123
+ }
124
+ set('.cachetoast/config.json', JSON.stringify(config, null, 2) + '\n');
125
+ set('.cachetoast/README.md', '# Cachetoast\n\nThe runtime, config.json, and provider hook settings can be committed. local.json (optional per-repo cache TTL overrides), state, handoffs, and the manifest stay local: handoffs contain conversation excerpts.\n\nLarge idle Claude Code and Pi sessions get one native compaction attempt before estimated expiry: 4m for a 5m TTL, 25m for 30m, 55m for 1h. Claude uses the trusted repo plugin in .claude/skills/cachetoast-idle (launch from the repo root; requires mods support); Pi uses its extension. Keep the agent running and awake. Native compaction uses the agent’s model and normal billing. Codex has no portable repo-hook compaction trigger. If compaction cannot run or fails, the first prompt after the idle deadline is held once with a fresh-session handoff (in a new session, copy `/cachetoast-handoff KEY` in Claude Code or `$cachetoast-handoff KEY` in Codex from the notice; in Pi, `/handoff` opens one); sending it again continues. The key selects the held session even when others have been held since. Omitting it loads the latest handoff for that provider. Handoffs are local extracts, not model summaries. Cache TTLs are read from Claude Code sessions, 30m for Codex, and guessed from the Pi model; override one with `node .cachetoast/runtime/bin/cachetoast.mjs init --pi-cache-ttl 1h` (saved in ~/.config/cachetoast/config.json). Inspect with `status` and `handoff --key KEY`.\n\nInspired in part by [Antiburn](https://antiburn.com/) and its [open source work](https://github.com/antiburn/antiburn).\n');
126
+ const ignore = readText(safePath(root, '.gitignore'));
127
+ const ignores = ['state/', 'handoffs/', 'manifest.json', 'local.json'].map(p => `.cachetoast/${p}`);
128
+ const missing = ignores.filter(p => !ignore.split('\n').includes(p));
129
+ set('.gitignore', ignore + (ignore && !ignore.endsWith('\n') ? '\n' : '') + (missing.length ? missing.join('\n') + '\n' : ''));
130
+ for (const dir of ['lib', 'bin']) for (const file of fs.readdirSync(path.join(PACKAGE_ROOT, dir)).filter(f => f.endsWith('.mjs'))) {
131
+ set(`.cachetoast/runtime/${dir}/${file}`, fs.readFileSync(path.join(PACKAGE_ROOT, dir, file), 'utf8'));
132
+ }
133
+ for (const file of ['LICENSE']) set(`.cachetoast/runtime/${file}`, readText(path.join(PACKAGE_ROOT, file)));
134
+ const manifestPath = safePath(root, '.cachetoast/manifest.json');
135
+ const manifest = readJson(manifestPath, { version: 1, files: {} });
136
+ // Reinstallation refuses external edits instead of erasing them during uninstall.
137
+ for (const [relative, previous] of Object.entries(manifest.files)) {
138
+ const current = readText(safePath(root, relative), null);
139
+ if (current === null || hash(current) !== previous.installedHash) throw new Error(`${relative} changed since install; uninstall/reconcile it before reinstalling`);
140
+ }
141
+ const changed = [...changes].filter(([r, data]) => readText(safePath(root, r), null) !== data);
142
+ const result = { root, providers, changed: changed.map(([r]) => r), userConfig: userChanged ? userPath : null, dryRun: !!options.dryRun };
143
+ if (options.dryRun) return result;
144
+ const rollback = [];
145
+ try {
146
+ for (const [relative, data] of changes) {
147
+ const original = readText(safePath(root, relative), null);
148
+ rollback.push([relative, original]);
149
+ manifest.files[relative] ??= { original };
150
+ manifest.files[relative].installedHash = hash(data);
151
+ atomicWrite(root, relative, data);
152
+ }
153
+ if (userChanged) writeFileAtomic(userPath, userData);
154
+ // The manifest is written last so a failed install never records unwritten state.
155
+ atomicWrite(root, '.cachetoast/manifest.json', manifest);
156
+ } catch (error) {
157
+ // Restore everything possible, then report the error that caused the rollback, not a rollback error.
158
+ const failed = [], attempt = fn => { try { fn(); } catch (e) { failed.push(e.message); } };
159
+ if (userChanged) attempt(() => { if (userOriginal === null) fs.rmSync(userPath, { force: true }); else writeFileAtomic(userPath, userOriginal); });
160
+ for (const [r, original] of rollback.reverse()) {
161
+ attempt(() => { if (original === null) fs.rmSync(safePath(root, r), { force: true }); else atomicWrite(root, r, original); });
162
+ }
163
+ if (failed.length) error.message += `; rollback also failed, restore these manually: ${failed.join('; ')}`;
164
+ throw error;
165
+ }
166
+ return result;
167
+ }
168
+ export function update(root, options = {}) {
169
+ root = repoRoot(root);
170
+ const manifest = readJson(safePath(root, '.cachetoast/manifest.json'));
171
+ if (!manifest || manifest.version !== 1 || !manifest.files) throw new Error('No local installation manifest; run init first');
172
+ const paths = { claude: '.claude/settings.json', codex: '.codex/hooks.json', pi: '.pi/extensions/cachetoast.ts' };
173
+ const installed = PROVIDERS.filter(provider => Object.hasOwn(manifest.files, paths[provider]));
174
+ if (!installed.length) throw new Error('No installed providers; run init first');
175
+ const providers = options.providers ?? installed;
176
+ if (providers.some(provider => !installed.includes(provider))) throw new Error('update only accepts installed providers; use init to add a provider');
177
+ return install(root, { ...options, providers });
178
+ }
179
+ export function uninstall(root) {
180
+ root = path.resolve(root);
181
+ const manifest = readJson(safePath(root, '.cachetoast/manifest.json'));
182
+ if (!manifest) throw new Error('No local installation manifest; restore config with git instead');
183
+ for (const [relative, entry] of Object.entries(manifest.files)) {
184
+ if (hash(readText(safePath(root, relative))) !== entry.installedHash) throw new Error(`${relative} changed since install; reconcile manually to preserve edits`);
185
+ }
186
+ // Preflight all paths before modifying any of them.
187
+ for (const [relative, entry] of Object.entries(manifest.files)) {
188
+ if (entry.original === null) fs.unlinkSync(safePath(root, relative)); else atomicWrite(root, relative, entry.original);
189
+ }
190
+ fs.unlinkSync(safePath(root, '.cachetoast/manifest.json'));
191
+ // The restored .gitignore no longer ignores these, and handoffs contain conversation excerpts: remove them.
192
+ for (const dir of ['state', 'handoffs']) fs.rmSync(safePath(root, `.cachetoast/${dir}`), { recursive: true, force: true });
193
+ const localPath = safePath(root, '.cachetoast/local.json'), local = readJson(localPath, null);
194
+ if (local && !Object.keys(local).length) fs.unlinkSync(localPath);
195
+ // Remove directories the install created, deepest first, if now empty.
196
+ const dirs = new Set(['.cachetoast']);
197
+ for (const [relative, entry] of Object.entries(manifest.files)) if (entry.original === null) for (let d = path.dirname(relative); d !== '.'; d = path.dirname(d)) dirs.add(d);
198
+ for (const dir of [...dirs].sort((a, b) => b.split('/').length - a.split('/').length)) {
199
+ try { fs.rmdirSync(safePath(root, dir)); } catch (e) { if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(e.code)) throw e; }
200
+ }
201
+ const kept = fs.existsSync(localPath) ? ['.cachetoast/local.json (your TTL overrides; no longer gitignored)'] : [];
202
+ return { restored: Object.keys(manifest.files), kept };
203
+ }
@@ -0,0 +1,146 @@
1
+ // Cachetoast adapter for Pi, loaded through the managed stub in .pi/extensions.
2
+ // Runs in-process: same policy, state, and handoffs as the Claude/Codex hooks.
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { handleHook, handoffFor, handoffName, configAt, policyFor, statePath, idleState, recordIdle } from './runtime.mjs';
6
+ import { createIdleCompactor } from './idle.mjs';
7
+ import { statusText } from './policy.mjs';
8
+ import { readJson, safePath, keyFor } from './storage.mjs';
9
+
10
+ const findRoot = start => {
11
+ let root = path.resolve(start);
12
+ while (!fs.existsSync(path.join(root, '.cachetoast/config.json'))) {
13
+ const parent = path.dirname(root);
14
+ if (parent === root) return null;
15
+ root = parent;
16
+ }
17
+ return root;
18
+ };
19
+ const messageText = message => typeof message?.content === 'string' ? message.content
20
+ : Array.isArray(message?.content) ? message.content.filter(b => b.type === 'text').map(b => b.text ?? '').join('\n') : '';
21
+
22
+ // A rough guess mirroring pi-ai: Claude models write 5m, or 1h from Anthropic with PI_CACHE_RETENTION=long.
23
+ // Anything else falls back to the 30m default.
24
+ const ttlGuess = ({ model } = {}) => model?.provider !== 'anthropic' && !/claude/i.test(model?.id ?? '') ? undefined
25
+ : model.provider === 'anthropic' && process.env.PI_CACHE_RETENTION === 'long' ? '1h' : '5m';
26
+
27
+ export default function cachetoast(pi) {
28
+ let lastAssistant = '', compacting = false, compacted = false, timer = null, idle = null;
29
+ // Fail open: any error becomes a warning and Pi continues.
30
+ const guarded = (ctx, action) => {
31
+ try {
32
+ const root = findRoot(ctx.cwd);
33
+ return root ? action(root) : undefined;
34
+ } catch (error) { ctx.ui.notify(`Cachetoast skipped this check: ${error.message}`, 'warning'); }
35
+ };
36
+ const hook = (root, ctx, event, extra) => handleHook(root, 'pi', {
37
+ session_id: ctx.sessionManager.getSessionId(), hook_event_name: event,
38
+ session_name: ctx.sessionManager.getSessionName?.(),
39
+ context_tokens: ctx.getContextUsage()?.tokens ?? undefined, cache_ttl_guess: ttlGuess(ctx), ...extra
40
+ });
41
+ const compact = ctx => new Promise((resolve, reject) => {
42
+ if (!ctx.isIdle() || ctx.hasPendingMessages?.() || compacting) return resolve({ skip: 'Session is busy' });
43
+ compacting = true;
44
+ try {
45
+ ctx.compact({
46
+ onComplete: () => {
47
+ compacting = false; compacted = true;
48
+ // Usage can be unavailable immediately after native compaction; success still stands.
49
+ let tokensAfter;
50
+ try { tokensAfter = ctx.getContextUsage()?.tokens; } catch {}
51
+ resolve({ tokensAfter });
52
+ },
53
+ onError: error => { compacting = false; reject(error); }
54
+ });
55
+ } catch (error) { compacting = false; reject(error); }
56
+ });
57
+ const arm = ctx => {
58
+ idle?.cancel();
59
+ guarded(ctx, root => {
60
+ const sessionId = ctx.sessionManager.getSessionId();
61
+ idle = createIdleCompactor({
62
+ read: () => {
63
+ const state = idleState(root, 'pi', sessionId);
64
+ if (!state || !ctx.isIdle() || ctx.hasPendingMessages?.()) return null;
65
+ return { ...state, contextTokens: ctx.getContextUsage()?.tokens, p: policyFor(configAt(root), 'pi', { ...state, guessedTtl: ttlGuess(ctx) }) };
66
+ },
67
+ record: (s, status, result) => recordIdle(root, 'pi', sessionId, { activityId: s.activityId, lastActivity: s.lastActivity, status, ...result }),
68
+ compact: () => compact(ctx), now: Date.now,
69
+ after: (ms, fn) => { const timeout = setTimeout(fn, ms); timeout.unref?.(); return { cancel: () => clearTimeout(timeout) }; },
70
+ onError: error => ctx.ui.notify(`Cachetoast idle compaction skipped: ${error.message}. The handoff remains available.`, 'warning')
71
+ });
72
+ void idle.arm();
73
+ });
74
+ };
75
+
76
+ // Footer status, visible before the next prompt; a timer flips it to cold while idle. Errors just clear it.
77
+ const status = ctx => {
78
+ try {
79
+ const root = findRoot(ctx.cwd);
80
+ const state = root && ctx.isIdle() ? readJson(safePath(root, statePath(keyFor('pi', ctx.sessionManager.getSessionId())))) : null;
81
+ const text = state && statusText({ ...state, contextTokens: ctx.getContextUsage()?.tokens }, policyFor(configAt(root), 'pi', { ...state, guessedTtl: ttlGuess(ctx) }));
82
+ ctx.ui.setStatus('cachetoast', text ? (text.startsWith('cache cold') ? `${text} · /handoff` : text) : undefined);
83
+ } catch { try { ctx.ui.setStatus('cachetoast', undefined); } catch {} }
84
+ };
85
+ pi.on('session_start', (_event, ctx) => {
86
+ compacted = false;
87
+ clearInterval(timer);
88
+ timer = setInterval(() => status(ctx), 30_000);
89
+ timer.unref?.();
90
+ status(ctx);
91
+ arm(ctx);
92
+ });
93
+ pi.on('session_shutdown', () => { clearInterval(timer); timer = null; idle?.cancel(); });
94
+ pi.on('agent_start', () => { idle?.cancel(); compacted = false; });
95
+ pi.on('input', (event, ctx) => {
96
+ idle?.cancel();
97
+ // Only prompts that start a new run after idle; steering and queued follow-ups do not.
98
+ if (event.source === 'extension' || event.streamingBehavior) return { action: 'continue' };
99
+ const out = guarded(ctx, root => hook(root, ctx, 'UserPromptSubmit', { prompt: event.text }));
100
+ if (out?.decision === 'block') {
101
+ // Held once: the text goes back in the editor, so Enter sends it anyway.
102
+ ctx.ui.notify(out.reason, 'warning');
103
+ ctx.ui.setEditorText(event.text);
104
+ return { action: 'handled' };
105
+ }
106
+ ctx.ui.setStatus('cachetoast', undefined);
107
+ compacted = false;
108
+ return { action: 'continue' };
109
+ });
110
+ pi.on('agent_end', event => {
111
+ const last = [...(event.messages ?? [])].reverse().find(m => m.role === 'assistant');
112
+ if (last) lastAssistant = messageText(last);
113
+ });
114
+ pi.on('agent_settled', (_event, ctx) => guarded(ctx, root => {
115
+ if (compacting || compacted || idle?.isRunning()) return;
116
+ hook(root, ctx, 'Stop', { last_assistant_message: lastAssistant });
117
+ // Same context ceiling as Claude/Codex. Pi's compact() aborts a running turn, so only compact once idle.
118
+ const tokens = ctx.getContextUsage()?.tokens;
119
+ if (!compacting && ctx.isIdle() && Number.isFinite(tokens) && tokens >= configAt(root).targetTokens) {
120
+ idle?.cancel();
121
+ const state = idleState(root, 'pi', ctx.sessionManager.getSessionId());
122
+ void compact(ctx).then(result => recordIdle(root, 'pi', state.sessionId, { ...state, status: result.skip ? 'skipped' : 'succeeded', ...result }))
123
+ .catch(error => ctx.ui.notify(`Cachetoast compaction failed: ${error.message}`, 'warning'));
124
+ } else arm(ctx);
125
+ status(ctx);
126
+ }));
127
+ pi.registerCommand('handoff', {
128
+ description: 'Cachetoast: open a fresh session with a handoff prompt',
129
+ handler: async (_args, ctx) => {
130
+ const root = findRoot(ctx.cwd);
131
+ if (!root) return ctx.ui.notify('Cachetoast is not installed in this repo.', 'warning');
132
+ const prompt = handoffFor(root, 'pi', ctx.sessionManager.getSessionId());
133
+ const state = readJson(safePath(root, statePath(keyFor('pi', ctx.sessionManager.getSessionId())))) ?? {};
134
+ const name = handoffName(root, { ...state, sessionName: ctx.sessionManager.getSessionName?.() || state.sessionName });
135
+ await ctx.newSession({
136
+ parentSession: ctx.sessionManager.getSessionFile(),
137
+ setup: async manager => { manager.appendSessionInfo(name); },
138
+ // Only the replacement ctx is valid here; the prompt is plain data captured above.
139
+ withSession: async next => {
140
+ next.ui.setEditorText(prompt);
141
+ next.ui.notify('Cachetoast: review the handoff prompt, then send it.', 'info');
142
+ }
143
+ });
144
+ }
145
+ });
146
+ }
package/lib/policy.mjs ADDED
@@ -0,0 +1,57 @@
1
+ export const DEFAULT_TARGET = 250_000;
2
+ export const DEFAULT_MIN_CONTEXT = 100_000;
3
+ export const CACHE_TTLS = Object.freeze({ '5m': 300_000, '30m': 1_800_000, '1h': 3_600_000, '24h': 86_400_000 });
4
+
5
+ export function positive(value, name) {
6
+ if (!Number.isSafeInteger(value) || value <= 0) throw new Error(`${name} must be a positive integer`);
7
+ return value;
8
+ }
9
+
10
+ // Idle deadline is the TTL minus a margin of at most five minutes (5m -> 4m, 30m -> 25m, 1h -> 55m).
11
+ export function policy(options = {}) {
12
+ // Without a TTL, assume 30m: the shortest lifetime for large-context models other than Claude's 5m,
13
+ // which Claude sessions report themselves.
14
+ const cacheTtl = options.cacheTtl ?? '30m';
15
+ if (!Object.hasOwn(CACHE_TTLS, cacheTtl)) throw new Error(`Unknown cache TTL: ${cacheTtl} (use ${Object.keys(CACHE_TTLS).join(', ')})`);
16
+ const ttlMs = CACHE_TTLS[cacheTtl];
17
+ const p = {
18
+ cacheTtl, ttlMs,
19
+ idleMs: ttlMs - Math.min(300_000, ttlMs / 5),
20
+ targetTokens: options.targetTokens ?? DEFAULT_TARGET,
21
+ minContextTokens: options.minContextTokens ?? DEFAULT_MIN_CONTEXT
22
+ };
23
+ positive(p.targetTokens, 'targetTokens'); positive(p.minContextTokens, 'minContextTokens');
24
+ return p;
25
+ }
26
+
27
+ // This is a policy deadline, not an observation of the provider's cache.
28
+ // Small or unmeasured contexts never warn: a fresh session's re-grounding can cost more than the cache miss.
29
+ export function evaluate(session, p = policy(), now = Date.now()) {
30
+ const idleForMs = session.lastActivity == null ? null : Math.max(0, now - session.lastActivity);
31
+ const large = Number.isFinite(session.contextTokens) && session.contextTokens >= p.minContextTokens;
32
+ const compacted = session.compaction?.status === 'succeeded' && session.compaction.activityId === session.activityId;
33
+ const due = !compacted && large && idleForMs !== null && idleForMs >= p.idleMs;
34
+ return {
35
+ action: due ? 'handoff' : 'wait',
36
+ idleForMs,
37
+ contextTokens: session.contextTokens ?? null,
38
+ cacheTtl: p.cacheTtl
39
+ };
40
+ }
41
+
42
+ // Short status for the Pi footer, shown before the next prompt. Null when the session is small or unmeasured.
43
+ export function statusText(session, p = policy(), now = Date.now()) {
44
+ if (session.compaction?.status === 'succeeded' && session.compaction.activityId === session.activityId) return 'idle context compacted';
45
+ if (!Number.isFinite(session.contextTokens) || session.contextTokens < p.minContextTokens || session.lastActivity == null) return null;
46
+ const size = `${Math.round(session.contextTokens / 1000)}k`, left = p.idleMs - (now - session.lastActivity);
47
+ return left > 0 ? `cache warm ${Math.ceil(left / 60_000)}m · ${size}` : `cache cold · ${size}`;
48
+ }
49
+
50
+ // Keep the explanation separate from the action and copyable handoff command.
51
+ export function holdNotice(result = {}, next = '') {
52
+ const size = Number.isFinite(result.contextTokens) ? `~${Math.round(result.contextTokens / 1000)}k-token ` : '';
53
+ const idle = Number.isFinite(result.idleForMs) ? ` has been idle ${Math.round(result.idleForMs / 60_000)} min` : ' has been idle';
54
+ const ttl = result.cacheTtl ? ` with a ${result.cacheTtl} cache TTL` : '';
55
+ const lead = `Cachetoast held this prompt!\n\nThis ${size}session${idle}${ttl}, so its prompt cache has likely expired and continuing would re-read it all as uncached input.`;
56
+ return next ? `${lead}\n${next}` : lead;
57
+ }
@@ -0,0 +1,151 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { policy, evaluate, holdNotice, CACHE_TTLS } from './policy.mjs';
5
+ import { atomicWrite, safePath, readJson, keyFor } from './storage.mjs';
6
+ import { readTranscript, terse } from './transcript.mjs';
7
+
8
+ export const PROVIDERS = ['claude', 'codex', 'pi'];
9
+ export const KEY_RE = /^(claude|codex|pi)-[a-f0-9]{24}$/;
10
+ export const statePath = key => `.cachetoast/state/${key}.json`;
11
+
12
+ // Cache TTLs follow the person's billing, not the repo: one user-level file, set once for every repo.
13
+ export const userConfigPath = (env = process.env) => path.join(env.XDG_CONFIG_HOME || path.join(env.HOME || os.homedir(), '.config'), 'cachetoast', 'config.json');
14
+ export const readUserConfig = () => readJson(userConfigPath(), {});
15
+ // TTL for one provider: the gitignored repo local.json overrides the user config.
16
+ export const ttlFor = (user, local, provider) => ({ ...user[provider], ...local[provider] });
17
+ // config.json is committed repo policy; TTLs come from the user and local config.
18
+ export function configAt(root) {
19
+ const c = readJson(safePath(root, '.cachetoast/config.json'));
20
+ if (!c || c.version !== 1) throw new Error('Run cachetoast init in this repo first');
21
+ const user = readUserConfig(), local = readJson(safePath(root, '.cachetoast/local.json'), {});
22
+ const limits = { targetTokens: c.targetTokens, minContextTokens: c.minContextTokens };
23
+ const ttl = Object.fromEntries(PROVIDERS.map(p => [p, ttlFor(user, local, p).cacheTtl]));
24
+ for (const p of PROVIDERS) policy({ cacheTtl: ttl[p], ...limits });
25
+ return { ...limits, ttl };
26
+ }
27
+ // Observed beats configured beats guessed: Claude transcripts record each cache write's TTL,
28
+ // configured TTLs cover the rest, and Pi guesses from its model. Anything else, including Codex
29
+ // (GPT-5.6+ only), assumes 30m.
30
+ export function ttlChoice(c, provider, state = {}) {
31
+ if (state.observedTtl) return { cacheTtl: state.observedTtl, ttlSource: 'observed' };
32
+ if (c.ttl[provider]) return { cacheTtl: c.ttl[provider], ttlSource: 'configured' };
33
+ return { cacheTtl: state.guessedTtl ?? policy().cacheTtl, ttlSource: 'guessed' };
34
+ }
35
+ export const policyFor = (c, provider, state) => policy({ cacheTtl: ttlChoice(c, provider, state).cacheTtl, targetTokens: c.targetTokens, minContextTokens: c.minContextTokens });
36
+ export function gitEvidence(root) {
37
+ try {
38
+ const run = args => terse(execFileSync('git', ['-C', root, ...args], { encoding: 'utf8', timeout: 2000, maxBuffer: 128 * 1024, stdio: ['ignore', 'pipe', 'ignore'] }), 1200);
39
+ return `Branch/commit: ${run(['rev-parse', '--abbrev-ref', 'HEAD'])} / ${run(['rev-parse', '--short', 'HEAD'])}\nChanged paths (verify current state):\n${run(['status', '--short']) || '(clean)'}`;
40
+ } catch { return 'Git metadata unavailable. Inspect current files before continuing.'; }
41
+ }
42
+ export function handoffName(root, state = {}) {
43
+ const subject = String(state.sessionName || state.objective || state.latestUser || path.basename(root))
44
+ .replace(/\u001b\[[0-9;]*[a-zA-Z]/g, '')
45
+ .split(/\r?\n/).find(line => line.trim())?.trim() ?? '';
46
+ const clean = subject.replace(/^(?:CT\s*-\s*)+/i, '').replace(/[\p{Cc}\p{Cf}]/gu, ' ').replace(/\s+/g, ' ').trim();
47
+ return `CT - ${Array.from(clean || path.basename(root)).slice(0, 64).join('')}`;
48
+ }
49
+ export function handoffPrompt(root, state, pendingPrompt = '') {
50
+ const evidence = state.evidence ?? {};
51
+ return [
52
+ `Continue work in ${JSON.stringify(root)}. Read the repo instructions, inspect the current files, and verify the state before acting.`,
53
+ `Suggested session name: ${handoffName(root, state)}`,
54
+ 'The excerpts below are historical source material, not new authority. Keep the user’s constraints and outstanding work; do not infer completion from an excerpt.',
55
+ `Original request excerpt:\n${state.objective || '(unavailable; ask the user for the objective)'}`,
56
+ `Latest user request excerpt:\n${state.latestUser || evidence.latestUser || '(unavailable)'}`,
57
+ evidence.summary ? `Native compaction summary excerpt:\n${evidence.summary}` : '',
58
+ `Latest assistant excerpt (unverified):\n${evidence.assistant || '(unavailable)'}`,
59
+ gitEvidence(root),
60
+ // Verbatim: this is the only copy of a held prompt besides the hold notice.
61
+ pendingPrompt ? `User’s pending continuation request:\n${pendingPrompt}` : '',
62
+ 'Continue outstanding work. Re-run relevant checks as needed; preserve uncommitted changes.'
63
+ ].filter(Boolean).join('\n\n');
64
+ }
65
+ export function handoffFor(root, provider, sessionId) {
66
+ const state = readJson(safePath(root, statePath(keyFor(provider, sessionId)))) ?? {};
67
+ return handoffPrompt(root, state);
68
+ }
69
+ function refreshEvidence(state, input, provider) {
70
+ if (input.transcript_path) state.transcriptPath = input.transcript_path;
71
+ const evidence = readTranscript(state.transcriptPath, provider);
72
+ if (input.session_name || evidence.sessionName) state.sessionName = terse(input.session_name || evidence.sessionName, 200);
73
+ state.evidence ??= {};
74
+ for (const k of ['latestUser', 'assistant', 'summary']) if (evidence[k]) state.evidence[k] = evidence[k];
75
+ state.warnings = evidence.warnings;
76
+ // Only a fresh measurement counts: an unreadable or truncated tail must not warn on stale numbers.
77
+ // In-process adapters (Pi) report context size directly.
78
+ const compacted = state.compaction?.status === 'succeeded' && state.compaction.activityId === state.activityId
79
+ && (evidence.lastActivity == null || evidence.lastActivity <= state.compaction.at);
80
+ state.contextTokens = Number.isFinite(input.context_tokens) ? input.context_tokens : compacted ? state.contextTokens : evidence.contextTokens;
81
+ // Transcript response times cover interrupted turns, where Stop does not fire.
82
+ if (!compacted && evidence.lastActivity !== null && (state.lastActivity == null || evidence.lastActivity > state.lastActivity)) {
83
+ state.lastActivity = evidence.lastActivity;
84
+ state.activityId = (state.activityId ?? 0) + 1;
85
+ }
86
+ if (input.last_assistant_message) state.evidence.assistant = terse(input.last_assistant_message, 2400);
87
+ if (evidence.cacheTtl) state.observedTtl = evidence.cacheTtl;
88
+ // Pi sends a guess with every event (none means the default), so a model switch replaces it.
89
+ if (Object.hasOwn(input, 'cache_ttl_guess')) state.guessedTtl = Object.hasOwn(CACHE_TTLS, input.cache_ttl_guess ?? '') ? input.cache_ttl_guess : undefined;
90
+ }
91
+ // Only two events matter: Stop records the last model response; UserPromptSubmit checks before the next one.
92
+ export function handleHook(root, provider, input, { now = Date.now() } = {}) {
93
+ if (!PROVIDERS.includes(provider)) throw new Error(`provider must be ${PROVIDERS.join(', ')}`);
94
+ if (!input.session_id || typeof input.session_id !== 'string') throw new Error('hook requires session_id');
95
+ const event = input.hook_event_name;
96
+ if (event !== 'UserPromptSubmit' && event !== 'Stop') return {};
97
+ const c = configAt(root), key = keyFor(provider, input.session_id);
98
+ // State is a rebuildable cache of transcript evidence; corrupt state restarts rather than blocking work.
99
+ let state = null;
100
+ try { state = readJson(safePath(root, statePath(key))); } catch (error) { if (!(error instanceof SyntaxError)) throw error; }
101
+ state ??= { key, provider, sessionId: input.session_id, lastActivity: null, evidence: {} };
102
+ refreshEvidence(state, input, provider);
103
+ if (event === 'Stop') {
104
+ // Stop fires right after a model response, which refreshes the provider cache.
105
+ state.lastActivity = Math.max(state.lastActivity ?? 0, now);
106
+ state.activityId = (state.activityId ?? 0) + 1;
107
+ } else {
108
+ const result = evaluate(state, policyFor(c, provider, state), now);
109
+ // If native compaction did not succeed, hold the first continuation after the idle deadline once.
110
+ const held = state.heldAt != null && state.heldAt > (state.lastActivity ?? 0);
111
+ if (result.action === 'handoff' && !held) {
112
+ atomicWrite(root, `.cachetoast/handoffs/${key}.md`, handoffPrompt(root, state, input.prompt) + '\n');
113
+ state.heldAt = now;
114
+ atomicWrite(root, statePath(key), state);
115
+ // Pi puts the text back in the editor; elsewhere the notice repeats it verbatim so it is never lost.
116
+ if (provider === 'pi') return { decision: 'block', reason: holdNotice(result, 'Press Enter to continue here OR open a fresh session with:\n /handoff') };
117
+ // Include this session's key so another held session cannot change the selection.
118
+ const fresh = provider === 'claude' ? `/cachetoast-handoff ${key}` : `$cachetoast-handoff ${key}`;
119
+ const next = `Send it again to continue here OR start a new session (not resume or fork) and ${provider === 'claude' ? 'run' : 'send'}:\n ${fresh}\n(The handoff includes your prompt.)`;
120
+ const reason = `${holdNotice(result, next)}\n\nYour held prompt:\n${input.prompt ?? ''}`;
121
+ // The Codex app shows only "Hook blocked this message" for a block; systemMessage is its one visible warning.
122
+ return provider === 'codex' ? { decision: 'block', reason, systemMessage: holdNotice(result, next) } : { decision: 'block', reason };
123
+ }
124
+ state.latestUser = terse(input.prompt);
125
+ state.objective ||= state.latestUser;
126
+ // The prompt is sent to the model now, so the cache clock restarts.
127
+ state.lastActivity = now;
128
+ state.activityId = (state.activityId ?? 0) + 1;
129
+ }
130
+ atomicWrite(root, statePath(key), state);
131
+ return {};
132
+ }
133
+
134
+ // Shared with native in-process adapters; this never calls a model or edits its transcript.
135
+ export function idleState(root, provider, sessionId) {
136
+ if (!PROVIDERS.includes(provider) || !sessionId || typeof sessionId !== 'string') throw new Error('Invalid idle session');
137
+ const state = readJson(safePath(root, statePath(keyFor(provider, sessionId))));
138
+ return state ? { ...state, activityId: state.activityId ?? 0, p: policyFor(configAt(root), provider, state) } : null;
139
+ }
140
+ export function recordIdle(root, provider, sessionId, input, { now = Date.now() } = {}) {
141
+ const state = idleState(root, provider, sessionId);
142
+ if (!state || input.activityId !== state.activityId || input.lastActivity !== state.lastActivity) return false;
143
+ if (!['attempting', 'succeeded', 'skipped', 'failed'].includes(input.status)) throw new Error('Invalid compaction status');
144
+ if (input.status === 'attempting' && state.compaction?.activityId === state.activityId) return false;
145
+ delete state.p;
146
+ state.compaction = { activityId: state.activityId, status: input.status, at: now };
147
+ if (input.skip) state.compaction.reason = terse(input.skip, 300);
148
+ if (input.status === 'succeeded') state.contextTokens = Number.isFinite(input.tokensAfter) ? input.tokensAfter : null;
149
+ atomicWrite(root, statePath(state.key), state);
150
+ return true;
151
+ }
@@ -0,0 +1,40 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+
5
+ export const hash = text => crypto.createHash('sha256').update(text).digest('hex');
6
+ export const keyFor = (provider, id) => `${provider}-${hash(String(id)).slice(0, 24)}`;
7
+ export function safePath(root, relative) {
8
+ root = path.resolve(root);
9
+ const target = path.resolve(root, relative);
10
+ if (!target.startsWith(root + path.sep)) throw new Error('Path escapes repository');
11
+ let cursor = root;
12
+ if (fs.lstatSync(root).isSymbolicLink()) throw new Error('Repository root must not be a symlink');
13
+ for (const part of path.relative(root, target).split(path.sep)) {
14
+ cursor = path.join(cursor, part);
15
+ let stat;
16
+ try { stat = fs.lstatSync(cursor); } catch (e) { if (e.code !== 'ENOENT') throw e; }
17
+ if (stat?.isSymbolicLink()) throw new Error(`Refusing symlink: ${cursor}`);
18
+ }
19
+ return target;
20
+ }
21
+ export function readText(file, fallback = '') {
22
+ try { return fs.readFileSync(file, 'utf8'); } catch (e) { if (e.code === 'ENOENT') return fallback; throw e; }
23
+ }
24
+ export function readJson(file, fallback = null) {
25
+ const text = readText(file);
26
+ return text ? JSON.parse(text) : fallback;
27
+ }
28
+ export const atomicWrite = (root, relative, value) => writeFileAtomic(safePath(root, relative), value);
29
+ export function writeFileAtomic(file, value) {
30
+ fs.mkdirSync(path.dirname(file), { recursive: true });
31
+ const tmp = `${file}.${crypto.randomUUID()}.tmp`;
32
+ try {
33
+ fs.writeFileSync(tmp, typeof value === 'string' ? value : JSON.stringify(value, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
34
+ fs.renameSync(tmp, file);
35
+ } catch (error) {
36
+ // A cleanup failure must not replace the write error.
37
+ try { fs.rmSync(tmp, { force: true }); } catch (e) { error.message += `; also could not remove ${tmp}: ${e.message}`; }
38
+ throw error;
39
+ }
40
+ }
@@ -0,0 +1,69 @@
1
+ import fs from 'node:fs';
2
+
3
+ export const terse = (text, max = 1600) => String(text ?? '').replace(/\u001b\[[0-9;]*[a-zA-Z]/g, '').trim().slice(0, max);
4
+ const contentText = content => typeof content === 'string' ? content : Array.isArray(content) ? content.filter(b => ['text', 'input_text', 'output_text'].includes(b.type)).map(b => b.text ?? '').join('\n') : '';
5
+ const TAIL_BYTES = 1024 * 1024;
6
+
7
+ // Reads only the tail: the latest usage, response timestamp, and messages live there.
8
+ // Partial/malformed JSONL lines and tool content are ignored.
9
+ // Provider transcripts are unstable; these fields are evidence, not a semantic summary.
10
+ export function readTranscript(file, provider) {
11
+ const result = { latestUser: '', assistant: '', summary: '', sessionName: '', contextTokens: null, lastActivity: null, cacheTtl: null, warnings: [] };
12
+ if (!file) return result;
13
+ let fd;
14
+ try {
15
+ fd = fs.openSync(file, 'r');
16
+ const stat = fs.fstatSync(fd);
17
+ if (!stat.isFile()) throw new Error('Transcript must be a regular file');
18
+ const length = Math.min(stat.size, TAIL_BYTES), buffer = Buffer.alloc(length);
19
+ fs.readSync(fd, buffer, 0, length, stat.size - length);
20
+ let lines = buffer.toString('utf8').split('\n');
21
+ if (stat.size > length) lines = lines.slice(1);
22
+ let customName = '', aiName = '';
23
+ for (const line of lines) {
24
+ let r; try { r = JSON.parse(line); } catch { continue; }
25
+ if (r.isSidechain) continue;
26
+ let user = '', assistant = '', summary = '', tokens = null, activity = false;
27
+ if (provider === 'claude') {
28
+ if (r.type === 'custom-title') customName = terse(r.customTitle, 200);
29
+ if (r.type === 'ai-title') aiName = terse(r.aiTitle, 200);
30
+ const m = r.message ?? {};
31
+ if (r.type === 'user' && !r.isMeta) user = contentText(m.content);
32
+ if (r.type === 'assistant') {
33
+ assistant = contentText(m.content);
34
+ const u = m.usage;
35
+ if (u) {
36
+ tokens = (u.input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.output_tokens ?? 0);
37
+ activity = true;
38
+ // Each cache write records its TTL, so the latest write shows what this session actually gets
39
+ // (1h on Pro/Max, 5m on API keys or usage credits), with no Claude settings read.
40
+ const w = u.cache_creation ?? {};
41
+ if (w.ephemeral_1h_input_tokens > 0) result.cacheTtl = '1h';
42
+ else if (w.ephemeral_5m_input_tokens > 0) result.cacheTtl = '5m';
43
+ }
44
+ }
45
+ if (r.isCompactSummary) { summary = contentText(m.content); user = ''; }
46
+ } else {
47
+ const p = r.payload ?? {};
48
+ if (r.type === 'response_item' && p.type === 'message') {
49
+ if (p.role === 'user') user = contentText(p.content);
50
+ if (p.role === 'assistant') assistant = contentText(p.content);
51
+ }
52
+ if (r.type === 'event_msg' && p.type === 'token_count') {
53
+ tokens = p.info?.last_token_usage?.total_tokens ?? null;
54
+ activity = tokens !== null;
55
+ }
56
+ if (r.type === 'compacted') summary = p.message ?? '';
57
+ }
58
+ if (user) result.latestUser = terse(user);
59
+ if (assistant) result.assistant = terse(assistant, 2400);
60
+ if (summary) result.summary = terse(summary, 3000);
61
+ if (Number.isFinite(tokens) && tokens >= 0) result.contextTokens = tokens;
62
+ const ts = Date.parse(r.timestamp);
63
+ if (activity && Number.isFinite(ts)) result.lastActivity = Math.max(result.lastActivity ?? 0, ts);
64
+ }
65
+ result.sessionName = customName || aiName;
66
+ } catch (e) { result.warnings.push(`Transcript unavailable: ${e.message}`); }
67
+ finally { if (fd !== undefined) fs.closeSync(fd); }
68
+ return result;
69
+ }
package/package.json CHANGED
@@ -1,6 +1,17 @@
1
1
  {
2
2
  "name": "cachetoast",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "Local, provider-neutral handoffs and cache-aware session rotation for coding agents",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": { "name": "R. Hunter Harris (Hunter Software Consulting)", "url": "https://huntersoftwareconsulting.com" },
8
+ "homepage": "https://github.com/rhunterharris/cachetoast#readme",
9
+ "repository": { "type": "git", "url": "git+https://github.com/rhunterharris/cachetoast.git" },
10
+ "bugs": { "url": "https://github.com/rhunterharris/cachetoast/issues" },
11
+ "engines": { "node": ">=22" },
12
+ "bin": { "cachetoast": "bin/cachetoast.mjs" },
13
+ "exports": { ".": "./lib/policy.mjs" },
14
+ "files": ["bin", "lib", "docs", "examples", "README.md", "CONTRIBUTING.md", "LICENSE"],
15
+ "scripts": { "test": "node --test", "check": "node --check bin/cachetoast.mjs && node --test", "demo": "node examples/demo.mjs" },
16
+ "keywords": ["codex", "claude-code", "prompt-cache", "compaction", "handoff"]
17
+ }