cachetoast 0.1.0 → 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.
package/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # Cachetoast
2
2
 
3
- When you come back to a **large, idle** coding-agent session whose prompt cache has likely expired, Cachetoast holds your first prompt before the agent re-reads the whole context at full price, and gives you a handoff to continue in a fresh session instead. Works with Claude Code, Codex, and Pi.
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
4
 
5
- Zero dependencies. No LLM calls, API keys, telemetry, or network access. Node.js 22+, macOS/Linux.
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
6
 
7
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
8
 
@@ -20,12 +20,23 @@ It installs for the agents you have (`--provider codex` to choose, `--dry-run` t
20
20
 
21
21
  Then restart your agent.
22
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.
23
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)).
24
25
  - **Pi:** trust the project.
25
26
 
26
27
  ## How it works
27
28
 
28
- If a session is at least 100k tokens (`--min-context-tokens`) and has been idle past its cache deadline, your next prompt is held once:
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:
29
40
 
30
41
  ```text
31
42
  Cachetoast held this prompt!
@@ -65,7 +76,7 @@ node .cachetoast/runtime/bin/cachetoast.mjs uninstall # restore origin
65
76
 
66
77
  ## What to commit
67
78
 
68
- Commit `.cachetoast/runtime`, `.cachetoast/config.json`, and the agent settings, hooks, and handoff command or skill. `local.json`, `state/`, `handoffs/`, and `manifest.json` are gitignored; handoffs contain conversation excerpts.
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.
69
80
 
70
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.
71
82
 
@@ -74,8 +85,10 @@ Commit `.cachetoast/runtime`, `.cachetoast/config.json`, and the agent settings,
74
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.
75
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.
76
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.
77
90
  - No hold if hooks are disabled, untrusted, or time out.
78
- - Verified with Claude Code 2.1.285, Codex CLI 0.159.2, and Pi 0.85.1; provider formats can change. Research: [docs/research.md](docs/research.md).
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).
79
92
 
80
93
  ## Develop
81
94
 
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
- import { handleHook, configAt, ttlChoice, statePath, userConfigPath, KEY_RE, PROVIDERS } from '../lib/runtime.mjs';
4
+ import { handleHook, idleState, recordIdle, configAt, ttlChoice, statePath, userConfigPath, KEY_RE, PROVIDERS } from '../lib/runtime.mjs';
5
5
  import { readJson, readText, safePath } from '../lib/storage.mjs';
6
6
 
7
- const HELP = `Cachetoast 0.1.0 — local handoffs, inspired in part by Antiburn
7
+ const HELP = `Cachetoast 0.1.1 — local handoffs, inspired in part by Antiburn
8
8
  Usage: cachetoast <command> [--repo PATH] [options]
9
9
  init Install in the current repo (or --repo) for the agents you have installed.
10
10
  [--provider claude,codex,pi] [--dry-run]
@@ -19,8 +19,10 @@ Usage: cachetoast <command> [--repo PATH] [options]
19
19
  Cache TTLs are read from Claude Code sessions, 30m for Codex (GPT-5.6+), and guessed from Pi's model.
20
20
  To override (5m, 30m, 1h, 24h), pass --<agent>-cache-ttl: saved per user in ~/.config/cachetoast/config.json,
21
21
  or per repo in the gitignored .cachetoast/local.json. A TTL a Claude session reports always wins.
22
- Only sessions of 100k+ tokens are held: the first prompt after the cache goes cold, once per idle period.
23
- Hooks and all commands run without LLMs, credentials, or network access.
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.
24
26
  `;
25
27
  function options(args) {
26
28
  const result = {};
@@ -65,6 +67,13 @@ async function main() {
65
67
  if (argv[0] === '--help' || argv[0] === '-h') { process.stdout.write(HELP); return; }
66
68
  const [command = 'help', ...args] = argv, o = options(args);
67
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
+ }
68
77
  if (command === 'hook') {
69
78
  // A cost advisory must never stop work because of its own failure: fail open with a visible warning.
70
79
  let result;
@@ -104,8 +113,9 @@ async function main() {
104
113
  if (result.userConfig) console.log(`${result.dryRun ? 'Would save' : 'Saved'} cache TTLs to ${result.userConfig}.`);
105
114
  if (result.dryRun || !result.changed.length) return;
106
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).');
107
117
  if (result.providers.includes('codex') || result.providers.includes('pi')) next.push('Trust the repo.');
108
- if (result.providers.includes('codex')) next.push('Codex: review /hooks.');
118
+ if (result.providers.includes('codex')) next.push('Codex: review /hooks. Proactive idle compaction is unavailable, so the handoff is the fallback.');
109
119
  console.log(next.join(' '));
110
120
  return;
111
121
  }
@@ -127,7 +137,7 @@ async function main() {
127
137
  const sessions = keys.map(key => {
128
138
  try {
129
139
  const s = readJson(safePath(root, statePath(key)));
130
- return { key, provider: s.provider, ...ttlChoice(config, s.provider, s), heldAt: s.heldAt ?? null, contextTokens: s.contextTokens ?? null, lastActivity: s.lastActivity, warnings: s.warnings ?? [] };
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 ?? [] };
131
141
  } catch (error) { return { key, error: `Unreadable state (${error.message}); delete it to reset` }; }
132
142
  });
133
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));
package/docs/research.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
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
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
+
5
7
  ## What survives cache expiry
6
8
 
7
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.
@@ -34,11 +36,17 @@ A universal “shorter sessions always perform better” or “250K is optimal
34
36
 
35
37
  ## Available hooks
36
38
 
37
- Both current clients expose command hooks, including `UserPromptSubmit`, `Stop`, and compaction events. `UserPromptSubmit` supports blocking before the model processes the prompt. A `systemMessage` only appears after the prompt is sent, when the cost is already paid, so Cachetoast holds the first cold prompt once per idle period (`decision: "block"`) and lets it through when sent again. Codex's CLI adds a submitted prompt to message history before hooks run, so Up-arrow recalls a held prompt. Neither client offers a repo-installable surface visible before typing, so the hold is the only point where the cost can still be avoided. Neither documented lifecycle provides a portable command that requests in-process `/compact` while an arbitrary CLI session is idle. Cachetoast therefore performs deterministic local extraction at the next prompt, leaving live transcripts intact. Because extraction needs no model call, preparing it earlier with a timer would cost the same and only add moving parts.
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.
38
46
 
39
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).
40
48
 
41
- 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 therefore fails open on its own errors too, and reads response timestamps from the transcript to cover interrupted turns. It uses only `UserPromptSubmit` and `Stop`; native compaction summaries are read from the transcript. See [Claude hooks](https://code.claude.com/docs/en/hooks).
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).
42
50
 
43
51
  ## Pi
44
52
 
@@ -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
+ }
package/lib/install.mjs CHANGED
@@ -101,6 +101,13 @@ export function install(root, options = {}) {
101
101
  settings.autoCompactEnabled = true;
102
102
  for (const flag of ['DISABLE_AUTO_COMPACT', 'DISABLE_COMPACT']) if (settings.env[flag] === '1') settings.env[flag] = '0';
103
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}`);
104
111
  // Arguments are prompt data, never interpolated into an injected shell command.
105
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')}`);
106
113
  }
@@ -115,7 +122,7 @@ export function install(root, options = {}) {
115
122
  set('.pi/extensions/cachetoast.ts', '// Managed by Cachetoast; edits are overwritten by init.\nexport { default } from "../../.cachetoast/runtime/lib/pi-extension.mjs";\n');
116
123
  }
117
124
  set('.cachetoast/config.json', JSON.stringify(config, null, 2) + '\n');
118
- 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\nWhen a large session (default 100k+ tokens) has been idle past your cache deadline, the first prompt 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');
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');
119
126
  const ignore = readText(safePath(root, '.gitignore'));
120
127
  const ignores = ['state/', 'handoffs/', 'manifest.json', 'local.json'].map(p => `.cachetoast/${p}`);
121
128
  const missing = ignores.filter(p => !ignore.split('\n').includes(p));
@@ -2,7 +2,8 @@
2
2
  // Runs in-process: same policy, state, and handoffs as the Claude/Codex hooks.
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
5
- import { handleHook, handoffFor, handoffName, configAt, policyFor, statePath } from './runtime.mjs';
5
+ import { handleHook, handoffFor, handoffName, configAt, policyFor, statePath, idleState, recordIdle } from './runtime.mjs';
6
+ import { createIdleCompactor } from './idle.mjs';
6
7
  import { statusText } from './policy.mjs';
7
8
  import { readJson, safePath, keyFor } from './storage.mjs';
8
9
 
@@ -24,7 +25,7 @@ const ttlGuess = ({ model } = {}) => model?.provider !== 'anthropic' && !/claude
24
25
  : model.provider === 'anthropic' && process.env.PI_CACHE_RETENTION === 'long' ? '1h' : '5m';
25
26
 
26
27
  export default function cachetoast(pi) {
27
- let lastAssistant = '', compacting = false, timer = null;
28
+ let lastAssistant = '', compacting = false, compacted = false, timer = null, idle = null;
28
29
  // Fail open: any error becomes a warning and Pi continues.
29
30
  const guarded = (ctx, action) => {
30
31
  try {
@@ -37,6 +38,40 @@ export default function cachetoast(pi) {
37
38
  session_name: ctx.sessionManager.getSessionName?.(),
38
39
  context_tokens: ctx.getContextUsage()?.tokens ?? undefined, cache_ttl_guess: ttlGuess(ctx), ...extra
39
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
+ };
40
75
 
41
76
  // Footer status, visible before the next prompt; a timer flips it to cold while idle. Errors just clear it.
42
77
  const status = ctx => {
@@ -48,13 +83,17 @@ export default function cachetoast(pi) {
48
83
  } catch { try { ctx.ui.setStatus('cachetoast', undefined); } catch {} }
49
84
  };
50
85
  pi.on('session_start', (_event, ctx) => {
86
+ compacted = false;
51
87
  clearInterval(timer);
52
88
  timer = setInterval(() => status(ctx), 30_000);
53
89
  timer.unref?.();
54
90
  status(ctx);
91
+ arm(ctx);
55
92
  });
56
- pi.on('session_shutdown', () => { clearInterval(timer); timer = null; });
93
+ pi.on('session_shutdown', () => { clearInterval(timer); timer = null; idle?.cancel(); });
94
+ pi.on('agent_start', () => { idle?.cancel(); compacted = false; });
57
95
  pi.on('input', (event, ctx) => {
96
+ idle?.cancel();
58
97
  // Only prompts that start a new run after idle; steering and queued follow-ups do not.
59
98
  if (event.source === 'extension' || event.streamingBehavior) return { action: 'continue' };
60
99
  const out = guarded(ctx, root => hook(root, ctx, 'UserPromptSubmit', { prompt: event.text }));
@@ -65,6 +104,7 @@ export default function cachetoast(pi) {
65
104
  return { action: 'handled' };
66
105
  }
67
106
  ctx.ui.setStatus('cachetoast', undefined);
107
+ compacted = false;
68
108
  return { action: 'continue' };
69
109
  });
70
110
  pi.on('agent_end', event => {
@@ -72,14 +112,16 @@ export default function cachetoast(pi) {
72
112
  if (last) lastAssistant = messageText(last);
73
113
  });
74
114
  pi.on('agent_settled', (_event, ctx) => guarded(ctx, root => {
115
+ if (compacting || compacted || idle?.isRunning()) return;
75
116
  hook(root, ctx, 'Stop', { last_assistant_message: lastAssistant });
76
117
  // Same context ceiling as Claude/Codex. Pi's compact() aborts a running turn, so only compact once idle.
77
118
  const tokens = ctx.getContextUsage()?.tokens;
78
119
  if (!compacting && ctx.isIdle() && Number.isFinite(tokens) && tokens >= configAt(root).targetTokens) {
79
- compacting = true;
80
- const done = () => { compacting = false; };
81
- ctx.compact({ onComplete: done, onError: done });
82
- }
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);
83
125
  status(ctx);
84
126
  }));
85
127
  pi.registerCommand('handoff', {
package/lib/policy.mjs CHANGED
@@ -29,7 +29,8 @@ export function policy(options = {}) {
29
29
  export function evaluate(session, p = policy(), now = Date.now()) {
30
30
  const idleForMs = session.lastActivity == null ? null : Math.max(0, now - session.lastActivity);
31
31
  const large = Number.isFinite(session.contextTokens) && session.contextTokens >= p.minContextTokens;
32
- const due = large && idleForMs !== null && idleForMs >= p.idleMs;
32
+ const compacted = session.compaction?.status === 'succeeded' && session.compaction.activityId === session.activityId;
33
+ const due = !compacted && large && idleForMs !== null && idleForMs >= p.idleMs;
33
34
  return {
34
35
  action: due ? 'handoff' : 'wait',
35
36
  idleForMs,
@@ -40,6 +41,7 @@ export function evaluate(session, p = policy(), now = Date.now()) {
40
41
 
41
42
  // Short status for the Pi footer, shown before the next prompt. Null when the session is small or unmeasured.
42
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';
43
45
  if (!Number.isFinite(session.contextTokens) || session.contextTokens < p.minContextTokens || session.lastActivity == null) return null;
44
46
  const size = `${Math.round(session.contextTokens / 1000)}k`, left = p.idleMs - (now - session.lastActivity);
45
47
  return left > 0 ? `cache warm ${Math.ceil(left / 60_000)}m · ${size}` : `cache cold · ${size}`;
package/lib/runtime.mjs CHANGED
@@ -75,9 +75,14 @@ function refreshEvidence(state, input, provider) {
75
75
  state.warnings = evidence.warnings;
76
76
  // Only a fresh measurement counts: an unreadable or truncated tail must not warn on stale numbers.
77
77
  // In-process adapters (Pi) report context size directly.
78
- state.contextTokens = Number.isFinite(input.context_tokens) ? input.context_tokens : evidence.contextTokens;
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;
79
81
  // Transcript response times cover interrupted turns, where Stop does not fire.
80
- if (evidence.lastActivity !== null) state.lastActivity = Math.max(state.lastActivity ?? 0, evidence.lastActivity);
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
+ }
81
86
  if (input.last_assistant_message) state.evidence.assistant = terse(input.last_assistant_message, 2400);
82
87
  if (evidence.cacheTtl) state.observedTtl = evidence.cacheTtl;
83
88
  // Pi sends a guess with every event (none means the default), so a model switch replaces it.
@@ -98,10 +103,10 @@ export function handleHook(root, provider, input, { now = Date.now() } = {}) {
98
103
  if (event === 'Stop') {
99
104
  // Stop fires right after a model response, which refreshes the provider cache.
100
105
  state.lastActivity = Math.max(state.lastActivity ?? 0, now);
106
+ state.activityId = (state.activityId ?? 0) + 1;
101
107
  } else {
102
108
  const result = evaluate(state, policyFor(c, provider, state), now);
103
- // No agent shows anything before you type, so the first prompt after the cache goes cold is held once:
104
- // that is the last moment the cost can be avoided. Sending it again goes through.
109
+ // If native compaction did not succeed, hold the first continuation after the idle deadline once.
105
110
  const held = state.heldAt != null && state.heldAt > (state.lastActivity ?? 0);
106
111
  if (result.action === 'handoff' && !held) {
107
112
  atomicWrite(root, `.cachetoast/handoffs/${key}.md`, handoffPrompt(root, state, input.prompt) + '\n');
@@ -120,7 +125,27 @@ export function handleHook(root, provider, input, { now = Date.now() } = {}) {
120
125
  state.objective ||= state.latestUser;
121
126
  // The prompt is sent to the model now, so the cache clock restarts.
122
127
  state.lastActivity = now;
128
+ state.activityId = (state.activityId ?? 0) + 1;
123
129
  }
124
130
  atomicWrite(root, statePath(key), state);
125
131
  return {};
126
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cachetoast",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Local, provider-neutral handoffs and cache-aware session rotation for coding agents",
5
5
  "type": "module",
6
6
  "license": "MIT",