Compacts the session just before the prompt cache expires, and says so in the transcript

Two mods for Claude Code.
/plugin install agent-deck --marketplace Prabal-Singh/claude-mods
Type y to add the marketplace, then pick a scope (user = every session).
Top half: the agent tree. Every subagent shows as a row. Agents that subagents start sit under them, as deep as they go.
Edit …/app/x.py, Bash pytest -q).Bottom half: Blocked on you. Everything that stops progress until you act:
blocked_on_user tool: a decision, a command only you can run (with Copy), access or credentials, a review.! command a reply hands you to run.Items clear when they are resolved, when you run the command, or when you press Done. Each message you send carries the open items (unseen), so when your message settles one ("go with B", pasted output, "done") Claude clears it. A newer reply's commands replace the previous reply's, commands read from replies expire after 2 hours, and flagged items after 24 hours.
Names. Every subagent gets a name, so you can refer to it and message it: Vox Machina, in order. kiki, vex, vax, grog, pike, scanlan, percy…, the first one no running agent holds (a finished agent frees its name; -2 only when all are busy). Two running agents never share a name, even when they start in the same message.
Subagents may spawn subagents. Every subagent prompt says so, and agents are told to hand off parts that split cleanly.
/retire [name]. When an agent's context gets big, it is retired and replaced. Its committed work is pushed and its uncommitted changes are noted. A fresh agent, with the next name, takes over in the same worktree with a tight brief plus the leftover work. Claude also does this by itself once an agent's context passes about 200k tokens.
/deck shows or hides the panel. It opens by itself on the first subagent; it docks on the right in fullscreen on a wide terminal, and sits above the prompt otherwise.
Costs are estimates from list prices set in agent-deck/hooks/register.tsx (PRICES). Edit them if yours differ.
/plugin install cache-clock --marketplace Prabal-Singh/claude-mods
Claude Code's prompt cache lasts an hour after the last reply. After that, your next message re-reads the whole context at full price. cache-clock compacts the session 3 minutes before the cache expires, while compacting is still cheap, and says so in the transcript. It compacts once per reply, so an idle session isn't summarised over and over. It assumes the one-hour cache, not the five-minute one.
MIT
hooks/register.ts 63 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4// The prompt cache lives 1 hour from the last API response (5 minutes in usage overage).
5const TTL_MS = 60 * 60 * 1000
6// Compaction is itself a model call over the whole context: start it early enough to finish
7// while that context is still cached.
8const COMPACT_LEAD_MS = 3 * 60 * 1000
9const TICK_MS = 30 * 1000
10
11const lastReplyAt = atom({ plugin: 'cache-clock', key: 'lastReplyAt' } as const, null)
12const compactedFor = atom({ plugin: 'cache-clock', key: 'compactedFor' } as const, null)
13
14let compacting = false
15
16// Once per reply: from COMPACT_LEAD_MS before the cache expires, and also after it (a sleeping
17// laptop missed the window), never twice for the same reply.
18export function compactDue(now: number, last: number | null, done: number | null): boolean {
19 if (last === null || done === last) return false
20 return last + TTL_MS - now <= COMPACT_LEAD_MS
21}
22
23async function tick($: EngineInterface): Promise<void> {
24 if (compacting) return
25 const last = await read($, lastReplyAt)
26 if (last === null || !compactDue(await $.clock.now(), last, await read($, compactedFor))) return
27 compacting = true
28 try {
29 const before = (await $.session.usage()).context.tokens
30 const result = await $.session.compact()
31 await update($, compactedFor, () => last)
32 if (result.skip !== undefined) {
33 $.ui.log(`cache-clock: auto-compact before the prompt cache expired was skipped: ${result.skip}`)
34 } else {
35 const from = result.tokensBefore ?? before
36 const sizes = from !== undefined && result.tokensAfter !== undefined ? ` (${Math.round(from / 1000)}k → ${Math.round(result.tokensAfter / 1000)}k tokens)` : ''
37 $.ui.log(`cache-clock: compacted before the prompt cache expired${sizes}`)
38 }
39 } catch {
40 // A turn is running: its reply resets the clock, or the next tick tries again.
41 } finally {
42 compacting = false
43 }
44}
45
46export const register: Register = on => {
47 on('session.start', async ($, e, next) => {
48 const result = await next(e)
49 $.ui.status(undefined)
50 $.clock.every(TICK_MS, () => {
51 void tick($)
52 })
53 return result
54 })
55
56 on('turn.complete', async ($, e, next) => {
57 const result = await next(e)
58 const now = await $.clock.now()
59 await update($, lastReplyAt, () => now)
60 return result
61 })
62}
63types/index.d.ts 8 lines1export type Stamp = number | null
2
3declare module 'claude-code' {
4 interface PluginState {
5 'cache-clock': { lastReplyAt: Stamp; compactedFor: Stamp }
6 }
7}
8