cachetoast 0.1.0 → 0.1.2

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,25 @@ 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
+ Claude Code 2.1.299+ has its own [idle compaction](https://code.claude.com/docs/en/settings-reference#idlecompaction): terminal sessions on the 1h TTL, about 54 minutes idle, 200k tokens by default. Cachetoast lowers that minimum to match its own (`CLAUDE_CODE_IDLE_COMPACT_MIN_TOKENS`, 100k at least), so Claude usually compacts first and Cachetoast's timer covers what it skips: the 5m TTL, the desktop app and other clients, older versions, and `idleCompaction: false`. Either compaction prevents the hold.
40
+
41
+ If compaction failed, was skipped, or is unavailable, your next prompt after the deadline is held once:
29
42
 
30
43
  ```text
31
44
  Cachetoast held this prompt!
@@ -49,11 +62,11 @@ Small sessions are left alone, since a fresh session wouldn't be cheaper. Cachet
49
62
  The hold starts at the TTL minus up to 5 minutes (1h → 55 min, 30m → 25 min, 5m → 4 min).
50
63
 
51
64
  - **Claude Code:** read from the session (1h on Pro/Max; 5m on API keys, cloud providers, or usage credits).
52
- - **Codex:** 30m (GPT-5.6 and later).
65
+ - **Codex:** 30m (GPT-5.6 and later). OpenAI's API now also offers `12h`, but Codex CLI 0.163 doesn't request it. A Codex lead has said signed-in Codex users keep their cache indefinitely; OpenAI hasn't documented this, so Cachetoast still assumes 30m. If you trust it, raise the TTL with `--codex-cache-ttl 12h` or `24h`.
53
66
  - **Pi:** 5m for Claude models (1h with `PI_CACHE_RETENTION=long`), otherwise 30m.
54
67
  - **Anything else:** 30m.
55
68
 
56
- 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.
69
+ To override, run `init --pi-cache-ttl 1h` (or `--codex-cache-ttl`, `--claude-cache-ttl`; `5m`, `30m`, `1h`, `12h`, `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.
57
70
 
58
71
  ## Commands
59
72
 
@@ -65,7 +78,7 @@ node .cachetoast/runtime/bin/cachetoast.mjs uninstall # restore origin
65
78
 
66
79
  ## What to commit
67
80
 
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.
81
+ 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
82
 
70
83
  `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
84
 
@@ -74,8 +87,10 @@ Commit `.cachetoast/runtime`, `.cachetoast/config.json`, and the agent settings,
74
87
  - 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
88
  - 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
89
  - Expiry is estimated from a local clock; Cachetoast can't see the provider's cache.
90
+ - 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.
91
+ - 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
92
  - 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).
93
+ - Claude plugin statically validated with Claude Code 2.1.292; adapter behavior tested with synthetic sessions. Claude's native idle compaction is read from its transcript summary; no live run confirmed it. No paid live-compaction experiment was run. Other integration baselines: Codex CLI 0.163.0 and Pi 0.85.1. Research: [docs/research.md](docs/research.md).
79
94
 
80
95
  ## Develop
81
96
 
@@ -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.2 — 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]
@@ -17,10 +17,12 @@ Usage: cachetoast <command> [--repo PATH] [options]
17
17
  uninstall Restore installation files if they have no external edits
18
18
  hook --provider claude|codex: JSON lifecycle event on stdin (internal; Pi runs in-process)
19
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,
20
+ To override (5m, 30m, 1h, 12h, 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,10 @@
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
+
7
+ Updated October 21, 2026: Claude Code added native idle compaction (2.1.299), and OpenAI added a 12-hour cache lifetime for GPT-5.6+ (October 20). Checked against Codex CLI 0.163.0; Pi was not rechecked.
8
+
5
9
  ## What survives cache expiry
6
10
 
7
11
  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.
@@ -12,13 +16,15 @@ Claude’s current docs say a cold `/compact` summarization request reprocesses
12
16
 
13
17
  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).
14
18
 
19
+ Claude Code 2.1.299 added [idle compaction](https://code.claude.com/docs/en/settings-reference#idlecompaction) (settings exist from 2.1.290): in an interactive terminal session on the one-hour TTL, with feature flags reachable, a conversation of at least 200K tokens is compacted after about 54 idle minutes, and the conversation shows `Compacted while idle, before the prompt cache expired`. `CLAUDE_CODE_IDLE_COMPACT_MIN_TOKENS` changes the minimum (values below 100K are raised to 100K); `idleCompaction: false` or `DISABLE_AUTO_COMPACT` turns it off. It does not cover the five-minute TTL, the desktop app, or 100K–200K sessions at its default. Cachetoast sets the minimum to its own (at least 100K) and keeps its mod for the rest. The docs do not say whether this compaction reaches a mod's `session.compact` hook, so Cachetoast instead treats a compaction summary after the latest usage record as an unmeasured context: no hold until the next response reports a size.
20
+
15
21
  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).
16
22
 
17
23
  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).
18
24
 
19
25
  ## OpenAI API versus Codex quota/credits
20
26
 
21
- 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).
27
+ Current API docs distinguish model generations. GPT-5.6 and later set the lifetime with `prompt_cache_options.ttl`: `30m` (default and minimum, writes at 1.25× uncached input) or, since October 20, 2026, `12h` (Responses API only, writes at 2.5×); `prompt_cache_breakpoint.ttl` overrides one breakpoint. Reads are 0.1× on most models and 0.05× on GPT-6.1 Sol. Codex CLI 0.163.0 sends neither `prompt_cache_options` nor `prompt_cache_retention` (the strings appear only in its bundled migration notes), so API-key Codex gets the 30-minute default. 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 (0.163 adds `cache_write_input_tokens` but no lifetime), so Cachetoast supports GPT-5.6 and later only and assumes their 30-minute minimum. On October 20, 2026, Codex lead Tibo Sottiaux posted on X that signed-in Codex and Work users keep their cache indefinitely. Neither the Codex nor the API docs say so, and the API docs state cache entries are not stored indefinitely, so Cachetoast still assumes 30m for Codex. Users who rely on the claim can set `--codex-cache-ttl 12h` or `24h`. See [OpenAI prompt caching](https://developers.openai.com/api/docs/guides/prompt-caching).
22
28
 
23
29
  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).
24
30
 
@@ -34,11 +40,17 @@ A universal “shorter sessions always perform better” or “250K is optimal
34
40
 
35
41
  ## Available hooks
36
42
 
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.
43
+ 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.
44
+
45
+ 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.
46
+
47
+ Claude (alongside Claude Code's own idle compaction, which fires about a minute earlier on one-hour terminal sessions) and Pi 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.
48
+
49
+ 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
50
 
39
51
  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
52
 
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).
53
+ 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
54
 
43
55
  ## Pi
44
56
 
@@ -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
@@ -97,10 +97,22 @@ export function install(root, options = {}) {
97
97
  const prior = settings.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW;
98
98
  if (prior != null && !/^\d+$/.test(String(prior))) throw new Error('Existing Claude compact window must be a plain integer');
99
99
  settings.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW = String(Math.min(prior == null ? target : Number(prior), target));
100
+ // Claude Code's own idle compaction (1h TTL, terminal) starts at 200K; lower it to this policy's minimum
101
+ // (Claude's floor is 100K) so it covers the same sessions. The mod still covers 5m TTLs and other clients.
102
+ const priorIdle = settings.env.CLAUDE_CODE_IDLE_COMPACT_MIN_TOKENS, idleMin = Math.max(100_000, config.minContextTokens);
103
+ if (priorIdle != null && !/^\d+$/.test(String(priorIdle))) throw new Error('Existing Claude idle compaction minimum must be a plain integer');
104
+ settings.env.CLAUDE_CODE_IDLE_COMPACT_MIN_TOKENS = String(Math.min(priorIdle == null ? idleMin : Number(priorIdle), idleMin));
100
105
  // The bootstrap explicitly enables this compaction policy in the repo.
101
106
  settings.autoCompactEnabled = true;
102
107
  for (const flag of ['DISABLE_AUTO_COMPACT', 'DISABLE_COMPACT']) if (settings.env[flag] === '1') settings.env[flag] = '0';
103
108
  set('.claude/settings.json', JSON.stringify(mergeHooks(settings, 'claude'), null, 2) + '\n');
109
+ // Claude discovers trusted project plugins here. Relative paths survive moves and clones.
110
+ const plugin = '.claude/skills/cachetoast-idle';
111
+ set(`${plugin}/.claude-plugin/plugin.json`, JSON.stringify({ name: 'cachetoast-idle', version: '0.1.2', author: { name: 'R. Hunter Harris' }, description: 'Compact large idle sessions before estimated cache expiry' }, null, 2) + '\n');
112
+ set(`${plugin}/hooks/hooks.json`, JSON.stringify({ modules: ['./register.mjs'] }, null, 2) + '\n');
113
+ const idle = readText(path.join(PACKAGE_ROOT, 'lib/idle.mjs')).replace(/^export /gm, '');
114
+ const adapter = readText(path.join(PACKAGE_ROOT, 'lib/claude-mod.mjs')).replace(/^import .*\n/, '');
115
+ set(`${plugin}/hooks/register.mjs`, `// Managed by Cachetoast; bundled for Claude's mod sandbox.\n${idle}\n${adapter}`);
104
116
  // Arguments are prompt data, never interpolated into an injected shell command.
105
117
  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
118
  }
@@ -115,7 +127,7 @@ export function install(root, options = {}) {
115
127
  set('.pi/extensions/cachetoast.ts', '// Managed by Cachetoast; edits are overwritten by init.\nexport { default } from "../../.cachetoast/runtime/lib/pi-extension.mjs";\n');
116
128
  }
117
129
  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');
130
+ 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. Claude Code 2.1.299+ also compacts idle 1h terminal sessions itself (about 54 minutes); its minimum is set to match. 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
131
  const ignore = readText(safePath(root, '.gitignore'));
120
132
  const ignores = ['state/', 'handoffs/', 'manifest.json', 'local.json'].map(p => `.cachetoast/${p}`);
121
133
  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
@@ -1,6 +1,6 @@
1
1
  export const DEFAULT_TARGET = 250_000;
2
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 });
3
+ export const CACHE_TTLS = Object.freeze({ '5m': 300_000, '30m': 1_800_000, '1h': 3_600_000, '12h': 43_200_000, '24h': 86_400_000 });
4
4
 
5
5
  export function positive(value, name) {
6
6
  if (!Number.isSafeInteger(value) || value <= 0) throw new Error(`${name} must be a positive integer`);
@@ -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
+ }
@@ -59,6 +59,9 @@ export function readTranscript(file, provider) {
59
59
  if (assistant) result.assistant = terse(assistant, 2400);
60
60
  if (summary) result.summary = terse(summary, 3000);
61
61
  if (Number.isFinite(tokens) && tokens >= 0) result.contextTokens = tokens;
62
+ // A summary after the latest usage means the context was replaced, e.g. by Claude Code's own idle
63
+ // compaction, which no hook reports. The old size no longer applies until the next response measures it.
64
+ else if (summary) result.contextTokens = null;
62
65
  const ts = Date.parse(r.timestamp);
63
66
  if (activity && Number.isFinite(ts)) result.lastActivity = Math.max(result.lastActivity ?? 0, ts);
64
67
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cachetoast",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Local, provider-neutral handoffs and cache-aware session rotation for coding agents",
5
5
  "type": "module",
6
6
  "license": "MIT",