Disclosure: I'm the developer of the tool described here, and this post was drafted and published by an AI agent from the project's source code. Relaymote is unofficial; not affiliated with or endorsed by Anthropic.
If you run several Claude Code sessions and let them message each other, or let a script nudge them ("you stopped early, carry on"), every one of those messages wakes a session. Whether that wake is cheap or expensive depends on one thing you rarely see: whether the session's prompt cache is still warm.
Claude's prompt cache lasts about an hour. A session that gets a message inside that hour re-reads its context from cache, and cached input is billed at about a tenth of the normal input rate. A session woken after the hour writes its whole context to the cache again. For a long session, that one cold turn costs many times what the same turn would have cost warm.
I wanted numbers, not a hunch, so I added a small module to Relaymote (MIT, Node.js), my phone remote for Claude Desktop's Code-tab sessions. It answers two questions: is this session warm right now, and how many of today's wakes were cold, and who sent them? It only reads files. It never opens a session.
The data is already on disk
Claude Code writes each conversation as a .jsonl transcript under the config directory's projects/ folder. Every assistant record carries the model name, a timestamp and a usage block, including cache_read_input_tokens and cache_creation_input_tokens. That is everything needed:
- Warmth now = minutes since the last real model reply, compared with the cache window.
- Cost of a wake = the cache tokens on the first reply after it. That is a measured number, not an estimate.
Transcripts get long, so the reader takes only the tail (the last few MB), drops the first partial line, and parses line by line, skipping anything that does not parse:
function tailText(file, bytes) {
const st = fs.statSync(file);
const start = Math.max(0, st.size - bytes);
const fd = fs.openSync(file, 'r');
try {
const buf = Buffer.allocUnsafe(st.size - start);
fs.readSync(fd, buf, 0, buf.length, start);
const t = buf.toString('utf8');
return start > 0 ? t.slice(t.indexOf('\n') + 1) : t;
} finally { fs.closeSync(fd); }
}
Step 1: decide what counts as a "real" reply
Not every assistant record is a model call. Sidechain records (sub-agent work) and records whose model is <synthetic> are skipped, because they don't touch the main session's cache. Only a record with a real model name moves the "last reply" clock.
Step 2: decide what counts as a wake
A wake is a user turn that came from somewhere other than the human. In the transcripts I found three shapes:
- a
<cross-session-message from="…">wrapper in the text (one session messaging another); - an
origin: { kind: 'peer', … }field on the record; - Relaymote's own nudges, which start with a fixed prefix such as
[Relaymote goals], so the sender can be named.
Tool results arrive as user records too, so anything containing a tool_result block is excluded. Otherwise every tool call would look like a wake.
Step 3: walk the transcript once
One pass keeps two pieces of state: the time of the last real reply, and the wake still waiting for its first reply.
for (const line of text.split('\n')) {
if (!line || line[0] !== '{') continue;
let r; try { r = JSON.parse(line); } catch { continue; }
if (r.isSidechain) continue;
const ts = Date.parse(r.timestamp || '') || 0;
if (r.type === 'assistant') {
const m = r.message || {};
if (m.model && m.model !== '<synthetic>') {
if (open) { // first real reply after a wake: record its true cost
const u = m.usage || {};
open.cacheRead = Number(u.cache_read_input_tokens) || 0;
open.cacheWrite = Number(u.cache_creation_input_tokens) || 0;
open = null;
}
if (ts > (out.lastApiAt || 0)) out.lastApiAt = ts;
}
continue;
}
if (r.type !== 'user') continue;
const f = wakeSender(r, userText(r));
if (!f) continue;
const idleMs = out.lastApiAt ? ts - out.lastApiAt : Infinity;
const w = { at: new Date(ts).toISOString(), from: f.from, warm: idleMs < windowMs,
idleMin: isFinite(idleMs) ? Math.round(idleMs / 60000) : null, cacheRead: null, cacheWrite: null };
out.wakes.push(w);
open = w;
}
Two details matter here:
- A message queued while the session is mid-turn reads as warm, which is correct: the session was already awake.
- A wake whose previous reply isn't in the tail is counted as cold, and flagged as an unknown gap. It is never dropped. A metric that hides the cases it can't measure will always look better than reality.
Step 4: one line a day
A daily roll-up walks every transcript modified in the last 24 hours and appends one JSON line: wakes, warm, cold, cold wakes by sender, and the summed cache-read and cache-write tokens for each group. It writes a separate log of the wakes Relaymote itself sent, with the warmth each one was sent at, so "who is sending the cold ones" has an answer that includes the tool itself.
The send path is wrapped rather than edited. The probe runs before the send, and the log is written only after a successful one. Logging failures are swallowed, because a broken log must never fail the message it describes:
function instrument(send, kind) {
return async function (sessionId, text, o) {
let w = null;
try { w = await warmthById(sessionId); } catch {}
const out = await send(sessionId, text, o);
try { if (out && out.ok) logWake({ to: sessionId, kind, warmth: w || {} }); } catch {}
return out;
};
}
What the numbers change
Once cold wakes have a sender attached, the fix is mostly scheduling. Relaymote's optional cache keeper (off by default, like every module that acts on sessions) times its own nudges to land 25–55 minutes after a session's last reply. That is inside the window, with margin on both sides. It batches everything for one session into a single wake, and it only wakes a cold session when a goal is late or has been stuck for days. Non-urgent messages can check warmth first and wait for the next warm slot.
The general lesson goes beyond this tool. If you automate anything that talks to a cached LLM session, log the cache tokens of the turn you caused, not just the fact that you sent something. The transcript already records the bill.
Relaymote is MIT-licensed: github.com/ashwarsadh/relaymote. Unofficial; not affiliated with or endorsed by Anthropic.
Top comments (0)