SLOPSHOPPER

session-keeper

Shows the prompt cache state on desktop, reads the cache once when idle so a 1h cache does not expire, and suggests handing off to a new session when the…

newbandcommandtoastpromptmodel
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-keeper
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /session-keeper ⎿ session-keeper: Usage: /session-keeper handoff [goal], /session-keeper stats [days], /session-keeper status, /session-keeper s ⎿ session-keeper: Features that use the model: /session-keeper on <keep_warm|away_handoff|handoff_gate> [duration, default 2h], ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

cc-mod-trimtab

English | 繁體中文

Small mods that keep Claude Code from losing your work, your secrets and your tokens.

A trim tab is the small flap on a rudder that steers the whole ship. These mods do the same for Claude Code: each one is a few hooks that sit quietly in the session and step in only at the moment something would go wrong. Install the ones you want; none of them depends on another.

  • Fewer mistakes: risky git and rm commands show what they would destroy before they run, and Claude is asked to verify before it calls edited code done.
  • Fewer tokens: long command output is trimmed before the model reads it; turn on session-keeper and the prompt cache stays warm while you step away, and a large context is handed off to a fresh session instead of dragging on.
  • More fault tolerance: interrupted background agents come back in one key press, open questions survive /clear, and with project-memory on, what you learn in one session is waiting in the next.
  • No extra services, no surprise spending: no mod contacts a service of its own. The few features that call a model go through Claude Code's own model API with your account, and they are off until you turn them on.

The mods

ModWhat it does for you
secret-scrubMasks API keys, tokens and private keys in your prompt before it is sent, so they stay out of the transcript and the prompt cache.
tool-guardHolds git reset --hard, force pushes, rm -rf and migrations, lists exactly which files or commits they would lose (or which migrations would run), and lets you decide. Harmless runs pass straight through.
output-trimmerWhen a Bash command prints thousands of lines, the model reads only the head, the tail and the errors; the full output is saved to a file.
subagent-dispatchBrings interrupted background agents back with one key, and shows where your tokens went: main session, subagents, models, every spawn.
open-itemsKeeps undecided questions and deferred items outside the context, counts them above the prompt box, and quotes one back in a key press.
session-keeperShows the prompt cache state on desktop, and, once turned on, keeps a 1h cache warm while you are away and offers a handoff to a fresh session when the context gets expensive.
self-checkAsks Claude to find the cause when the same tool fails twice the same way, and to verify before it says "done" after editing code.
project-memoryOnce turned on, distills each conversation in the background into Claude Code's own memory and a progress file; rules confirmed across sessions are proposed for CLAUDE.md.

Quick start

Requires Claude Code 2.1.296 or later, in the CLI or desktop app (cloud sessions cannot load mods yet).

claude plugin install all --marketplace wen-hsiu-hsu/cc-mod-trimtab

This installs every mod, secret-scrub first so that it sees every prompt before other mods do. To install only some of them, or to drop one later, see Install and update.

The mods work right after install, with safe defaults. Features that call a model (in session-keeper and project-memory) stay off until you run /<mod> on or turn them on in /config. Run /<mod> status to see what a mod is set to.

Documentation

License

MIT. Some code is adapted from cablate/ctx-handoff-mod (MIT). See NOTICE.

Source 7 files
hooks/register.tsx 875 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, ModelForkResult, PluginOptions, Register, Timer } from 'claude-code'
3
4import type { HandoffKind, Offer } from '../types'
5import { configCommand, firstUse, LANGUAGE, loadConfig, nextExpiry, onLine, pick, resolve, type ConfigIO, type Lang, type Resolved, type Specs } from './config'
6import { TONE } from './ui'
7import {
8  GATE_LOG_KEEP,
9  GATE_SYSTEM,
10  PROGRESS_FILE,
11  QUIET_TOKENS,
12  gateEntry,
13  gateFloor,
14  gatePrompt,
15  gateStats,
16  handoffConfig,
17  handoffDraft,
18  handoffPrompt,
19  isColdAndLarge,
20  parseProgress,
21  parseVerdict,
22  progressNote,
23  progressTask,
24  renderHandoff,
25  thresholdOf,
26  withExclude,
27  withOffered,
28} from './handoff'
29import type { GateChoice, GateEntry, HandoffConfig, Offered } from './handoff'
30import { KEEP_PROMPT, keepConfig, keepDueAt, skipReason } from './keep'
31import type { KeepConfig } from './keep'
32import { EMPTY, fmtTokens, record, segments, toolsKey, ttlFromTranscript } from './meter'
33
34const meter = atom({ plugin: 'session-keeper', key: 'meter' } as const, EMPTY)
35const now = atom({ plugin: 'session-keeper', key: 'now' } as const, 0)
36const offer = atom({ plugin: 'session-keeper', key: 'offer' } as const, null)
37const handing = atom({ plugin: 'session-keeper', key: 'handing' } as const, null)
38const away = atom({ plugin: 'session-keeper', key: 'away' } as const, null)
39const quiet = atom({ plugin: 'session-keeper', key: 'quiet' } as const, { sessionId: '', tokens: 0 })
40const onUntil = atom({ plugin: 'session-keeper', key: 'onUntil' } as const, {})
41
42// A handoff summary over a large context takes a while; past this the handoff gives up.
43const HANDOFF_TIMEOUT_MS = 3 * 60_000
44
45// One command per mod (`/<mod> <sub>`): a bare `/handoff` collides with the person's own commands and skills.
46const COMMAND = 'session-keeper'
47
48function usage(lang: Lang): string {
49  return [
50    pick(lang, {
51      en: `Usage: /${COMMAND} handoff [goal], /${COMMAND} stats [days], /${COMMAND} status, /${COMMAND} set <key> <value>`,
52      'zh-TW': `用法:/${COMMAND} handoff [目標]、/${COMMAND} stats [天數]、/${COMMAND} status、/${COMMAND} set <key> <value>`,
53    }),
54    pick(lang, {
55      en: `Features that use the model: /${COMMAND} on <keep_warm|away_handoff|handoff_gate> [duration, default 2h], /${COMMAND} off <feature>`,
56      'zh-TW': `用模型的功能:/${COMMAND} on <keep_warm|away_handoff|handoff_gate> [時長,預設 2h]、/${COMMAND} off <功能>`,
57    }),
58  ].join('\n')
59}
60
61const GATE_MODEL = 'haiku'
62const GATE_TIMEOUT_MS = 8_000
63const GATE_LOG_KEY = 'gate-log'
64
65// Redraws the countdown while idle.
66const TICK_MS = 15_000
67// The TTL can change mid-session (usage overage drops it to 5m), so the transcript is read again after this long.
68const TTL_RECHECK_MS = 10 * 60_000
69const TONE_COLOR = { warn: TONE.attention, hot: TONE.danger } as const
70// Columns between the parts of the line.
71const GAP = 2
72
73async function tick($: EngineInterface): Promise<void> {
74  const at = await $.clock.now()
75  await update($, now, () => at)
76}
77
78// The keep timer lives in the module; its due time is derived from the meter in $.state,
79// so a reload re-arms it from there.
80type Keeper = {
81  config: KeepConfig
82  handoff: HandoffConfig
83  lang: Lang
84  timer: Timer | undefined
85  // Runs out with the first feature switched on for a while.
86  expiry: Timer | undefined
87  apply: (resolved: Resolved) => void
88  options: PluginOptions
89}
90
91// Takes new settings: the timers follow them, and the band counts down what is on for a while.
92async function settle($: EngineInterface, keeper: Keeper, resolved: Resolved): Promise<void> {
93  keeper.apply(resolved)
94  await update($, onUntil, () => resolved.until)
95  const at = nextExpiry(resolved)
96  const delay = at === undefined ? 0 : Math.max(0, at - (await $.clock.now()))
97  // No await between the cancel and the new timer: two settles at once must not leave one behind.
98  keeper.expiry?.cancel()
99  keeper.expiry = at === undefined ? undefined : $.clock.after(delay, () => void reload($, keeper).catch(() => undefined))
100  await arm($, keeper)
101}
102
103async function reload($: EngineInterface, keeper: Keeper): Promise<void> {
104  await settle($, keeper, await loadConfig(configIO($), COMMAND, SPECS, keeper.options))
105}
106
107// What each feature that uses a model does, for the toast the first time it does it in a session.
108const NOTICE: Readonly<Record<string, { en: string; 'zh-TW': string }>> = {
109  keep_warm: {
110    en: 'keep-warm starts sending forks (main conversation model, each reads the whole cache once)',
111    'zh-TW': '保持快取開始送 fork(主對話模型,每次讀一次整段快取)',
112  },
113  away_handoff: {
114    en: 'the handoff offer on an expired cache is now active (the summary is written by a fork of the main conversation model)',
115    'zh-TW': '快取過期提議交接開始作用(交接摘要由主對話模型 fork 寫)',
116  },
117  handoff_gate: {
118    en: 'the new-task check now sends the prompt and the recent conversation to Haiku',
119    'zh-TW': '新任務判斷開始把 prompt 與最近的對話送給 Haiku',
120  },
121}
122
123// A notice that fails must not stop the feature's own work.
124async function notice($: EngineInterface, lang: Lang, key: string): Promise<void> {
125  try {
126    if (await firstUse(configIO($), key)) {
127      const what = NOTICE[key] === undefined ? key : pick(lang, NOTICE[key])
128      $.ui.toast(pick(lang, { en: `session-keeper: ${what}; turn off: /${COMMAND} off ${key}`, 'zh-TW': `session-keeper:${what};關閉:/${COMMAND} off ${key}` }))
129    }
130  } catch (error) {
131    $.ui.log(`notice ${key}: ${String(error)}`, { to: 'debug' })
132  }
133}
134
135async function arm($: EngineInterface, keeper: Keeper): Promise<void> {
136  keeper.timer?.cancel()
137  keeper.timer = undefined
138  const due = keepDueAt(await read($, meter), keeper.config)
139  if (due === null) {
140    return
141  }
142  const delay = Math.max(0, due - (await $.clock.now()))
143  keeper.timer = $.clock.after(delay, () => void keepWarm($, keeper))
144}
145
146// One tool-less fork over the main thread's transcript reads its cached prefix, which renews the entry.
147// The last keep of an idle stretch writes the handoff summary instead: it reads the same warm
148// cache, so a person coming back to a cold cache can hand off without re-writing it.
149async function keepWarm($: EngineInterface, keeper: Keeper): Promise<void> {
150  keeper.timer = undefined
151  try {
152    const before = await read($, meter)
153    const sessionId = await $.session.id()
154    const reason = skipReason(before, sessionId, await $.clock.now(), keeper.config)
155    if (reason !== undefined) {
156      $.ui.log(`keep warm skipped: ${reason}`, { to: 'debug' })
157
158      return
159    }
160    const isLast = before.keeps + 1 >= keeper.config.maxKeeps
161    const threshold = thresholdOf(await $.env.get('CLAUDE_CTX_GUARD_TOKENS'))
162    const isSummary = isLast && keeper.handoff.isAwayOn && before.lastInput >= threshold
163    await notice($, keeper.lang, isSummary ? 'away_handoff' : 'keep_warm')
164    const result = await $.model.fork({ prompt: isSummary ? handoffPrompt('away', false, keeper.lang) : KEEP_PROMPT })
165    const at = await $.clock.now()
166    const keeps = before.keeps + 1
167    // A request of the main thread in the meantime renewed the cache itself and reset the count.
168    await update($, meter, m =>
169      m.lastAt !== before.lastAt
170        ? m
171        : { ...m, keeps, ...(result.isAnswered ? { lastAt: at } : {}) },
172    )
173    await update($, now, () => at)
174    const count = `${keeps}/${keeper.config.maxKeeps}`
175    if (!result.isAnswered) {
176      // Not re-armed: by the next due time the entry has lapsed anyway.
177      $.ui.log(`keep warm ${count} failed: ${result.reason}`, { to: 'debug' })
178
179      return
180    }
181    if (isSummary) {
182      const after = await read($, meter)
183      if (after.lastAt === at) {
184        await update($, away, () => ({ sessionId, lastAt: at, text: result.text }))
185      }
186    }
187    const { cache_read_input_tokens: hit, cache_creation_input_tokens: wrote } = result.usage
188    const what = isSummary ? ', wrote the handoff summary' : ''
189    $.ui.log(`keep warm ${count}: read ${fmtTokens(hit)}, wrote ${fmtTokens(wrote)}${what}`, { to: 'debug' })
190    await arm($, keeper)
191  } catch (error) {
192    $.ui.log(`keep warm: ${String(error)}`, { to: 'debug' })
193  }
194}
195
196// ---------- handoff ----------
197
198const slash = (p: string) => p.replace(/\\/g, '/').replace(/\/+$/, '')
199
200// Local "YYYY-MM-DD HH:mm".
201function localStamp(ms: number): string {
202  const d = new Date(ms - new Date(ms).getTimezoneOffset() * 60_000)
203
204  return d.toISOString().slice(0, 16).replace('T', ' ')
205}
206
207// The main working tree's root, so worktrees share the progress file (as project-memory does).
208async function workspace($: EngineInterface): Promise<string> {
209  return slash((await $.session.repo())?.root ?? (await $.session.root()))
210}
211
212async function readText($: EngineInterface, path: string): Promise<string> {
213  try {
214    return await $.fs.read(path)
215  } catch {
216    return ''
217  }
218}
219
220const offeredKey = (root: string) => `offered:${root}`
221
222async function threshold($: EngineInterface): Promise<number> {
223  return thresholdOf(await $.env.get('CLAUDE_CTX_GUARD_TOKENS'))
224}
225
226async function forkWithin($: EngineInterface, prompt: string, ms: number): Promise<ModelForkResult | undefined> {
227  return new Promise(resolve => {
228    const timer = $.clock.after(ms, () => resolve(undefined))
229    $.model.fork({ prompt }).then(
230      result => {
231        timer.cancel()
232        resolve(result)
233      },
234      () => {
235        timer.cancel()
236        resolve(undefined)
237      },
238    )
239  })
240}
241
242async function logGate($: EngineInterface, entry: GateEntry): Promise<void> {
243  const log = ((await $.store.get(GATE_LOG_KEY)) as GateEntry[] | undefined) ?? []
244  await $.store.set(GATE_LOG_KEY, [...log, entry].slice(-GATE_LOG_KEEP))
245}
246
247// Writes the person's choice on the latest offered verdict of this prompt.
248async function chooseGate($: EngineInterface, at: number, choice: GateChoice): Promise<void> {
249  const log = ((await $.store.get(GATE_LOG_KEY)) as GateEntry[] | undefined) ?? []
250  const i = log.findLastIndex(entry => entry.at === at && entry.verdict === 'new')
251  if (i < 0) {
252    return
253  }
254  await $.store.set(GATE_LOG_KEY, log.map((entry, j) => (j === i ? { ...entry, choice } : entry)))
255}
256
257// No new-task offer until the context grows past this one by QUIET_TOKENS.
258async function quietDown($: EngineInterface, context: number): Promise<void> {
259  const sessionId = await $.session.id()
260  await update($, quiet, () => ({ sessionId, tokens: context + QUIET_TOKENS }))
261}
262
263// Asks the small model whether the prompt starts unrelated work; undefined when it cannot tell.
264async function judge($: EngineInterface, lang: Lang, text: string): Promise<'new' | 'related' | undefined> {
265  await notice($, lang, 'handoff_gate')
266  const root = await workspace($)
267  const parsed = parseProgress(await readText($, `${root}/${PROGRESS_FILE}`))
268  const messages = (await $.session.messages()).slice(-40)
269  const result = await $.model.complete({
270    model: GATE_MODEL,
271    effort: 'low',
272    maxTokens: 8,
273    timeoutMs: GATE_TIMEOUT_MS,
274    system: GATE_SYSTEM,
275    prompt: gatePrompt(text, messages, parsed === undefined ? undefined : progressTask(parsed.body)),
276  })
277  if (!result.isAnswered) {
278    $.ui.log(`new-task gate: no verdict (${result.reason})`, { to: 'debug' })
279
280    return undefined
281  }
282
283  return parseVerdict(result.text)
284}
285
286// Suggest mode: the prompt went in already; a "new" verdict puts the offer above the prompt.
287async function suggest($: EngineInterface, lang: Lang, text: string, context: number, config: HandoffConfig): Promise<void> {
288  try {
289    const verdict = await judge($, lang, text)
290    if (verdict === undefined) {
291      return
292    }
293    const at = await $.clock.now()
294    await logGate($, gateEntry(at, context, config.gate, verdict, text))
295    $.ui.log(`new-task gate: ${verdict} at ${fmtTokens(context)}`, { to: 'debug' })
296    if (verdict === 'new' && (await read($, handing)) === null) {
297      await update($, offer, () => ({ kind: 'new-task', prompt: text, isHeld: false, context, at }))
298    }
299  } catch (error) {
300    $.ui.log(`new-task gate: ${String(error)}`, { to: 'debug' })
301  }
302}
303
304async function writeProgress($: EngineInterface, lang: Lang, summary: string, sessionId: string): Promise<number> {
305  const root = await workspace($)
306  const at = await $.clock.now()
307  await $.fs.write(`${root}/${PROGRESS_FILE}`, renderHandoff(summary, sessionId, at, localStamp(at), lang))
308  if ((await $.fs.stat(`${root}/.git`).catch(() => undefined))?.kind === 'dir') {
309    const exclude = `${root}/.git/info/exclude`
310    const added = withExclude(await readText($, exclude))
311    if (added !== undefined) {
312      await $.fs.write(exclude, added)
313    }
314  }
315
316  return at
317}
318
319// Hands the work to a new session: summary into the progress file, `/clear`, then the draft in
320// the prompt box for the person to review. Prompts typed meanwhile go into the draft.
321// `isHeld`: the prompt never entered this session (block mode, cold cache); else it did and its
322// answer, if still running, is stopped.
323async function handOff(
324  $: EngineInterface,
325  lang: Lang,
326  kind: HandoffKind,
327  prompt: string,
328  isHeld: boolean,
329  turn: { id: string | undefined },
330): Promise<void> {
331  if ((await read($, handing)) !== null) {
332    return
333  }
334  const startedAt = await $.clock.now()
335  await update($, handing, () => ({ kind, startedAt, held: [] }))
336  await update($, offer, () => null)
337  let held: string[] = []
338  // The prompt when it is not in this session (held, or its answer stopped): a failure gives it back.
339  let giveBack = isHeld ? prompt : ''
340  try {
341    // The offer came while the answer to the new prompt still ran: stop it, it belongs to the new session.
342    if (!isHeld && turn.id !== undefined) {
343      await $.turn.abort({ turnId: turn.id })
344      giveBack = prompt
345    }
346    const sessionId = await $.session.id()
347    const m = await read($, meter)
348    const saved = await read($, away)
349    let summary = kind === 'away' && saved?.sessionId === sessionId && saved.lastAt === m.lastAt ? saved.text : undefined
350    if (summary === undefined) {
351      const result = await forkWithin($, handoffPrompt(kind, !isHeld, lang), HANDOFF_TIMEOUT_MS)
352      if (result === undefined || !result.isAnswered) {
353        const reason = result === undefined ? 'timeout' : result.reason
354        $.ui.log(`handoff: no summary (${reason})`, { to: 'debug' })
355        $.ui.toast(
356          pick(lang, {
357            en: `session-keeper: handoff failed, no summary was produced (${reason}); staying in this session`,
358            'zh-TW': `session-keeper:交接失敗,沒有產生摘要(${reason}),仍在這個 session`,
359          }),
360        )
361        held = (await read($, handing))?.held ?? []
362        await restoreBox($, giveBack, held)
363
364        return
365      }
366      summary = result.text
367    }
368    const updated = await writeProgress($, lang, summary, sessionId)
369    held = (await read($, handing))?.held ?? []
370    await $.command.run({ command: 'clear', args: '' })
371    // Prompts typed while /clear ran.
372    held = [...held, ...((await read($, handing))?.held ?? []).filter(text => !held.includes(text))]
373    const root = await workspace($)
374    const fresh = await $.session.id()
375    // The new session has the work in its draft (or chose other work): no progress note for it.
376    await $.store.set(offeredKey(root), withOffered((await $.store.get(offeredKey(root))) as Offered | undefined, updated, fresh))
377    const draft = handoffDraft(kind, summary, prompt, held, lang)
378    const filled = await $.prompt.fill({ text: draft })
379    $.ui.log(`handoff (${kind}): ${filled.isFilled ? 'draft filled' : `draft not filled (${filled.refusal ?? 'unknown'})`}`, { to: 'debug' })
380    if (!filled.isFilled) {
381      $.ui.toast(
382        pick(lang, {
383          en: `session-keeper: the handoff summary is in ${PROGRESS_FILE}; it could not be put in the prompt box`,
384          'zh-TW': `session-keeper:交接摘要在 ${PROGRESS_FILE},沒能放進輸入框`,
385        }),
386      )
387    }
388  } catch (error) {
389    $.ui.log(`handoff: ${String(error)}`, { to: 'debug' })
390    $.ui.toast(pick(lang, { en: 'session-keeper: handoff failed; see the debug log', 'zh-TW': 'session-keeper:交接失敗,見 debug log' }))
391    await restoreBox($, giveBack, held.length > 0 ? held : ((await read($, handing))?.held ?? []))
392  } finally {
393    await update($, handing, () => null)
394  }
395}
396
397// Gives back what a failed handoff held: the box gets the prompts, not lost.
398async function restoreBox($: EngineInterface, prompt: string, held: readonly string[]): Promise<void> {
399  const text = [prompt, ...held].filter(s => s.trim() !== '').join('\n\n')
400  if (text !== '') {
401    await $.prompt.fill({ text, mode: 'append' })
402  }
403}
404
405// "Keep working here": a held prompt is sent now, as the person's own.
406async function stay($: EngineInterface, current: Offer): Promise<void> {
407  await update($, offer, () => null)
408  if (current.kind === 'new-task') {
409    await chooseGate($, current.at, 'stay')
410    await quietDown($, current.context)
411  }
412  if (current.isHeld) {
413    await $.prompt.submit({ text: current.prompt, asUser: true })
414  }
415}
416
417export const SPECS: Specs = {
418  keep_warm: {
419    category: 'cost',
420    type: 'boolean',
421    default: false,
422    repo: 'only-false',
423    toggle: { on: true, off: false },
424    description: {
425      en: 'With a 1h cache and context over 30k, send a fork that only replies OK after idling, so the cache does not expire',
426      'zh-TW': '1h 快取、context 超過 30k 時,閒置後送一次只回覆 OK 的 fork 讓快取不過期',
427    },
428  },
429  idle_minutes: {
430    category: 'cost',
431    type: 'number',
432    default: 55,
433    repo: 'only-higher',
434    min: 5,
435    max: 59,
436    description: { en: 'Minutes of idling after the cache was last used before a keep-warm is sent', 'zh-TW': '最後一次用到快取後閒置幾分鐘送出保持快取' },
437  },
438  max_keeps: {
439    category: 'cost',
440    type: 'number',
441    default: 3,
442    repo: 'only-lower',
443    min: 1,
444    max: 10,
445    description: { en: 'Most keep-warms in one idle stretch', 'zh-TW': '同一段閒置最多保持幾次' },
446  },
447  handoff_gate: {
448    category: 'cost',
449    type: 'string',
450    default: 'off',
451    repo: 'never',
452    choices: ['suggest', 'block', 'off'],
453    toggle: { on: 'suggest', off: 'off' },
454    description: {
455      en: 'When context passes the threshold, Haiku judges whether a prompt is a new task: suggest only offers, block holds the prompt first, off does not judge',
456      'zh-TW': 'context 超過門檻時用 Haiku 判斷新任務:suggest 只提議、block 先攔下、off 不判斷',
457    },
458  },
459  away_handoff: {
460    category: 'cost',
461    type: 'boolean',
462    default: false,
463    repo: 'only-false',
464    toggle: { on: true, off: false },
465    description: {
466      en: 'When the cache has expired and context is large, hold the prompt and offer a handoff; with a 1h TTL the last keep-warm writes the handoff summary instead',
467      'zh-TW': '快取過期且 context 大時先攔下 prompt 提議交接;1h TTL 時最後一次保持快取改寫交接摘要',
468    },
469  },
470  progress_resume: {
471    category: 'plain',
472    type: 'boolean',
473    default: true,
474    repo: 'any',
475    description: {
476      en: 'Attach a progress file under 24 hours old to the first prompt of a new session',
477      'zh-TW': '新 session 的第一則 prompt 附上 24 小時內的進度檔',
478    },
479  },
480  language: LANGUAGE,
481}
482
483// --- configIO: the same block in every mod (scripts/check-copies.sh) ---
484function configIO($: EngineInterface): ConfigIO {
485  return {
486    read: async path => {
487      try {
488        return await $.fs.read(path)
489      } catch {
490        return ''
491      }
492    },
493    write: (path, text) => $.fs.write(path, text),
494    isDir: async path => (await $.fs.stat(path).catch(() => undefined))?.kind === 'dir',
495    configDir: async () => (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${(await $.env.get('HOME')) ?? ''}/.claude`,
496    root: () => $.session.root(),
497    repoRoot: async () => (await $.session.repo())?.root,
498    sessionId: () => $.session.id(),
499    now: () => $.clock.now(),
500    storeGet: key => $.store.get(key),
501    storeSet: (key, value) => $.store.set(key, value),
502  }
503}
504// --- end configIO ---
505
506export const register: Register = (on, options) => {
507  // Re-read at session start, after `/session-keeper set|on|off` and when a feature switched on for
508  // a while runs out; `/config` changes reload the module.
509  const apply = (resolved: Resolved) => {
510    keeper.config = keepConfig(resolved.values)
511    keeper.handoff = handoffConfig(resolved.values)
512    keeper.lang = resolved.lang
513  }
514  const keeper: Keeper = { config: keepConfig({}), handoff: handoffConfig({}), lang: 'en', timer: undefined, expiry: undefined, apply: () => undefined, options }
515  apply(resolve(SPECS, { options, user: {}, repo: {}, local: {}, session: {} }))
516  keeper.apply = apply
517  keeper.options = options
518  // The main thread's running turn, which a handoff from a suggest-mode offer stops.
519  const turn: { id: string | undefined } = { id: undefined }
520  // Sessions whose first prompt was checked for a progress note.
521  const resumed = new Set<string>()
522
523  on('session.start', async ($, e, next) => {
524    $.clock.every(TICK_MS, () => void tick($))
525    // A reload drops the module's timers: pick the idle stretch and the features on for a while up where they were.
526    try {
527      await reload($, keeper)
528    } catch (error) {
529      $.ui.log(`session.start: settings not read: ${String(error)}`, { to: 'debug' })
530    }
531    const lang = keeper.lang
532    try {
533      await $.command.register({
534        name: COMMAND,
535        description: pick(lang, {
536          en: 'Hand off to a new session (the summary goes into the progress file, then /clear and the prompt box), or see the new-task check statistics',
537          'zh-TW': '交接到新 session(摘要寫進進度檔,/clear 後放進輸入框),或看新任務判斷的統計',
538        }),
539        argumentHint: pick(lang, {
540          en: 'handoff [goal] | stats [days] | on|off <feature> [duration] | status | set <key> <value> | unset <key>',
541          'zh-TW': 'handoff [目標] | stats [天數] | on|off <功能> [時長] | status | set <key> <value> | unset <key>',
542        }),
543      })
544    } catch (error) {
545      // Another command of the same name wins; say so once, or the command just goes missing.
546      $.ui.log(`session.start: /${COMMAND} not registered: ${String(error)}`, { to: 'debug' })
547      $.ui.toast(pick(lang, { en: `session-keeper: /${COMMAND} was not registered (${String(error)})`, 'zh-TW': `session-keeper:/${COMMAND} 指令沒有註冊成功(${String(error)})` }))
548    }
549    try {
550      await arm($, keeper)
551    } catch (error) {
552      $.ui.log(`session.start: ${String(error)}`, { to: 'debug' })
553    }
554
555    return next(e)
556  })
557
558  on('session.end', async ($, e, next) => {
559    keeper.timer?.cancel()
560    keeper.timer = undefined
561    keeper.expiry?.cancel()
562    keeper.expiry = undefined
563    await update($, onUntil, () => ({}))
564    // An offer and a summary belong to the conversation that ended; a handoff's own /clear keeps its state.
565    if ((await read($, handing)) === null) {
566      await update($, offer, () => null)
567      await update($, away, () => null)
568    }
569
570    return next(e)
571  })
572
573  on('turn.step', async function* ($, e, next) {
574    const result = yield* next(e)
575    const usage = result.usage
576    if (e.agentId !== undefined || usage === null) {
577      return result
578    }
579    try {
580      const sessionId = await $.session.id()
581      const tools = await $.tool.list()
582      const at = await $.clock.now()
583      await update($, meter, current =>
584        record(current.sessionId === sessionId ? current : { ...EMPTY, sessionId }, {
585          at,
586          model: usage.model,
587          toolsKey: toolsKey(tools.map(tool => tool.name)),
588          input: usage.input_tokens,
589          output: usage.output_tokens,
590          read: usage.cache_read_input_tokens,
591          write: usage.cache_creation_input_tokens,
592        }),
593      )
594      await update($, now, () => at)
595      await arm($, keeper)
596    } catch (error) {
597      $.ui.log(`turn.step: ${String(error)}`, { to: 'debug' })
598    }
599
600    return result
601  })
602
603
604  on('turn.start', async ($, e, next) => {
605    turn.id = e.turnId
606
607    return next(e)
608  })
609
610  on('turn.complete', async ($, e, next) => {
611    turn.id = undefined
612
613    return next(e)
614  })
615
616  on('command.run', { command: COMMAND }, async ($, e) => {
617    const args = e.args.trim()
618    const sub = args.split(/\s+/)[0] ?? ''
619    const rest = args.slice(sub.length).trim()
620    if (sub === 'stats') {
621      const days = Math.max(1, Math.round(Number(rest) || 30))
622      const log = ((await $.store.get(GATE_LOG_KEY)) as GateEntry[] | undefined) ?? []
623
624      return { text: gateStats(log, await $.clock.now(), days, keeper.lang) }
625    }
626    if (sub !== 'handoff') {
627      const text = await configCommand(configIO($), COMMAND, SPECS, options, args, resolved => void settle($, keeper, resolved).catch(error => $.ui.log(`settle: ${String(error)}`, { to: 'debug' })))
628
629      return { text: text ?? usage(keeper.lang) }
630    }
631    if ((await read($, handing)) !== null) {
632      return { text: pick(keeper.lang, { en: 'A handoff is already in progress', 'zh-TW': '交接已在進行中' }) }
633    }
634    // Not awaited: /clear waits for the session to be idle, which this command holds up.
635    void handOff($, keeper.lang, 'manual', rest, true, { id: undefined })
636
637    return {
638      text: pick(keeper.lang, {
639        en: "Handing off: once the summary is written it runs /clear and puts the summary in the new session's prompt box",
640        'zh-TW': '交接中:產生摘要後會 /clear,並把摘要放進新 session 的輸入框',
641      }),
642    }
643  })
644
645  // Order-independent: the state is only read before `next`, and changed once the prompt went in.
646  // A held prompt (block mode, cold cache) is answered here without `next`, which drops it.
647  on('prompt.submit', async ($, e, next) => {
648    const isPerson = e.origin.kind === 'composer' || e.origin.kind === 'bridge'
649    if (!isPerson) {
650      return next(e)
651    }
652    const isSlash = e.text.trimStart().startsWith('/')
653    const hasAttachments = (e.attachments?.length ?? 0) > 0
654    const current = await read($, handing)
655    if (current !== null && !isSlash) {
656      if (e.text.trim() === '') {
657        return {
658          drop: pick(keeper.lang, {
659            en: 'Handing off; attachments cannot go into the new session. Paste it again after the handoff.',
660            'zh-TW': '交接中,附件帶不進新 session;交接完成後再貼一次',
661          }),
662        }
663      }
664      if (!current.held.some(text => text.trim() === e.text.trim())) {
665        await update($, handing, h => (h === null ? h : { ...h, held: [...h.held, e.text] }))
666      }
667
668      return {
669        drop: pick(keeper.lang, {
670          en: "Handing off; this message is held and goes into the new session's prompt box with the rest.",
671          'zh-TW': '交接中,這則先攔下,會一起放進新 session 的輸入框',
672        }),
673      }
674    }
675    if (isSlash || e.text.trim() === '') {
676      return next(e)
677    }
678
679    let pending: Offer | null = null
680    let submitted = e
681    let context = 0
682    let isGated = false
683    let note: { text: string; root: string; updated: number } | undefined
684    try {
685      pending = await read($, offer)
686      const sessionId = await $.session.id()
687      const m = await read($, meter)
688      const at = await $.clock.now()
689      const limit = await threshold($)
690      context = m.sessionId === sessionId ? m.lastInput : 0
691      // A held offer answered by typing on: the person keeps working here, with both prompts.
692      if (pending?.isHeld) {
693        submitted = { ...e, text: `${pending.prompt}\n\n${e.text}` }
694      } else if (keeper.handoff.isAwayOn && !hasAttachments && isColdAndLarge(m, sessionId, at, limit)) {
695        await update($, offer, () => ({ kind: 'away', prompt: e.text, isHeld: true, context, at }))
696        await notice($, keeper.lang, 'away_handoff')
697
698        return {
699          drop: pick(keeper.lang, {
700            en: `The cache has expired; continuing rewrites ${fmtTokens(m.nextTokens)}. Choose to hand off or stay above the prompt box.`,
701            'zh-TW': `快取已過期,繼續要重寫 ${fmtTokens(m.nextTokens)};在輸入框上方選擇交接或留下`,
702          }),
703        }
704      } else if (keeper.handoff.gate === 'block' && !hasAttachments && context >= gateFloor(limit, await read($, quiet), sessionId)) {
705        const verdict = await judge($, keeper.lang, e.text)
706        if (verdict !== undefined) {
707          await logGate($, gateEntry(at, context, keeper.handoff.gate, verdict, e.text))
708        }
709        if (verdict === 'new') {
710          await update($, offer, () => ({ kind: 'new-task', prompt: e.text, isHeld: true, context, at }))
711
712          return {
713            drop: pick(keeper.lang, {
714              en: 'This looks like an unrelated new task. Choose to hand off or stay above the prompt box.',
715              'zh-TW': '這則看起來是無關的新任務;在輸入框上方選擇交接或留下',
716            }),
717          }
718        }
719      } else if (keeper.handoff.gate === 'suggest' && context >= gateFloor(limit, await read($, quiet), sessionId)) {
720        isGated = true
721      }
722      if (keeper.handoff.isResumeOn && !resumed.has(sessionId)) {
723        resumed.add(sessionId)
724        const root = await workspace($)
725        const text = await readText($, `${root}/${PROGRESS_FILE}`)
726        const offered = (await $.store.get(offeredKey(root))) as Offered | undefined
727        const body = text === '' ? undefined : progressNote(text, sessionId, at, offered)
728        const updated = parseProgress(text)?.head.updated
729        if (body !== undefined && updated !== undefined) {
730          note = { text: body, root, updated }
731        }
732      }
733    } catch (error) {
734      $.ui.log(`prompt.submit: ${String(error)}`, { to: 'debug' })
735    }
736
737    const result = await next(note === undefined ? submitted : { ...submitted, context: [...(submitted.context ?? []), note.text] })
738    if (result.drop !== undefined) {
739      return result
740    }
741    try {
742      if (pending !== null) {
743        await update($, offer, () => null)
744        if (pending.kind === 'new-task') {
745          await chooseGate($, pending.at, pending.isHeld ? 'stay' : 'ignored')
746          await quietDown($, pending.context)
747        }
748      }
749      if (note !== undefined) {
750        const key = offeredKey(note.root)
751        const sessionId = await $.session.id()
752        await $.store.set(key, withOffered((await $.store.get(key)) as Offered | undefined, note.updated, sessionId))
753      }
754      if (isGated && pending === null) {
755        void suggest($, keeper.lang, e.text, context, keeper.handoff)
756      }
757    } catch (error) {
758      $.ui.log(`prompt.submit: ${String(error)}`, { to: 'debug' })
759    }
760
761    return result
762  }).catch(($, e, next) => next(e))
763
764  // A compaction rewrites the prefix on purpose: its rebuild is not a miss.
765  on('session.compact', async ($, e, next) => {
766    const result = await next(e)
767    if (e.agentId === undefined) {
768      await update($, meter, current => ({ ...current, expectRebuild: true }))
769    }
770
771    return result
772  }).catch(($, e, next) => next(e))
773
774  // The usage the engine hands over has no 5m/1h split; the transcript's records do.
775  on('classic.Stop', async ($, e, next) => {
776    const current = await read($, meter)
777    const at = await $.clock.now()
778    const isDue = current.ttl === null || at - current.ttlCheckedAt > TTL_RECHECK_MS
779    if (e.agent_id === undefined && e.transcript_path !== '' && current.writeTokens > 0 && isDue) {
780      const ttl = ttlFromTranscript(await $.fs.read(e.transcript_path))
781      await update($, meter, m => ({ ...m, ttl: ttl ?? m.ttl, ttlCheckedAt: at }))
782    }
783
784    return next(e)
785  }).catch(($, e, next) => next(e))
786
787  // The offer and the handoff's progress on every surface; the cache line on the desktop only (the
788  // terminal has the status line for it). The bands of the plugins beneath are drawn under it.
789  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
790    if (e.props.hasSurvey) {
791      return next(e)
792    }
793    const pending = await read($, offer)
794    const busy = await read($, handing)
795    const isCacheLine = e.surface === 'desktop'
796    const at = Math.max(await read($, now), await $.clock.now())
797    const onText = onLine(COMMAND, await read($, onUntil), at)
798    if (!isCacheLine && pending === null && busy === null && onText === '') {
799      return next(e)
800    }
801    const below = await next(e)
802    const current = await read($, meter)
803    const { Box, Button, Text } = $.ui.resolve(e)
804
805    // The desktop's proportional font packs ' · ' tight, so the parts are spaced by the layout instead.
806    const parts = segments(current, at, keeper.config.maxKeeps).flatMap((part, i) => [
807      ...(i > 0 ? [<Text dimColor>·</Text>] : []),
808      part.tone === 'dim' ? <Text dimColor>{part.text}</Text> : <Text color={TONE_COLOR[part.tone]}>{part.text}</Text>,
809    ])
810    const lang = keeper.lang
811    const question =
812      pending === null
813        ? ''
814        : pending.kind === 'away'
815          ? pick(lang, {
816              en: `The cache has expired; doing this here rewrites ${fmtTokens(current.nextTokens)}`,
817              'zh-TW': `快取已過期,這則若在這裡做要重寫 ${fmtTokens(current.nextTokens)}`,
818            })
819          : pick(lang, {
820              en: `This looks like an unrelated new task (context ${fmtTokens(pending.context)})`,
821              'zh-TW': `這則看起來是無關的新任務(context ${fmtTokens(pending.context)})`,
822            })
823
824    return (
825      <Box flexDirection="column">
826        {onText !== '' && (
827          <Text dimColor wrap="truncate-end">
828            {onText}
829          </Text>
830        )}
831        {busy !== null && (
832          <Text color={TONE.attention} wrap="truncate-end">
833            {pick(lang, {
834              en: "Handing off: once the summary is written it runs /clear and puts it in the new session's prompt box; messages you type meanwhile go along",
835              'zh-TW': '交接中:產生摘要後會 /clear,摘要放進新 session 的輸入框;這段時間打的訊息會一起帶過去',
836            })}
837          </Text>
838        )}
839        {busy === null && pending !== null && (
840          <Box flexDirection="row">
841            <Text color={TONE.attention} wrap="truncate-end">
842              {question}{' '}
843            </Text>
844            <Button
845              key="handoff"
846              label={pick(lang, { en: 'Hand off to a new session', 'zh-TW': '交接並開新 session' })}
847              hotkey="h"
848              onPress={() => void handOff($, lang, pending.kind, pending.prompt, pending.isHeld, turn)}
849            />
850            <Button
851              key="stay"
852              label={
853                pending.isHeld
854                  ? pick(lang, { en: 'Keep working here', 'zh-TW': '仍在這裡做' })
855                  : pick(lang, { en: 'Stay here', 'zh-TW': '留在這裡' })
856              }
857              hotkey="k"
858              onPress={() => void stay($, pending)}
859            />
860          </Box>
861        )}
862        {isCacheLine && (
863          <Box flexDirection="row" columnGap={GAP}>
864            <Text dimColor bold>
865              cache
866            </Text>
867            {parts}
868          </Box>
869        )}
870        {below}
871      </Box>
872    )
873  })
874}
875
hooks/config.ts 792 lines
1// Layered settings, shared by every mod: the same file sits in each mod's hooks folder (a mod
2// cannot import outside its own folder), and scripts/check-copies.sh keeps the copies equal.
3//
4// Precedence, highest first: session (`/<mod> set --session`), `.claude/cc-mod-trimtab.local.json`,
5// `.claude/cc-mod-trimtab.json` (committed, held to each key's repo rule), the user layer
6// (`/config` for single values, `~/.claude/cc-mod-trimtab.json` for lists), the default.
7// Every file holds `{ "<mod>": { "<key>": value } }`, and may hold a `"*"` section beside them: of
8// that one only `language` is read, as the value every mod shares (a mod's own section wins).
9//
10// A feature that spends tokens is a key with a `toggle`: `/<mod> on [feature] [duration]` sets its
11// session value until a time (2h by default), `/<mod> off [feature]` takes it back.
12
13import type { PluginOptions } from 'claude-code'
14
15export type Value = boolean | number | string | readonly string[]
16
17// The language of everything shown on screen. Text for the model is English whatever this is.
18export type Lang = 'en' | 'zh-TW'
19
20export type Text = Readonly<Record<Lang, string>>
21
22export function pick(lang: Lang, text: Text): string {
23  return text[lang]
24}
25
26// A description is one string (tests) or a pair.
27export function say(lang: Lang, text: string | Text): string {
28  return typeof text === 'string' ? text : text[lang]
29}
30
31// Where the model writes for the person (memories, handoffs), the prompt names the language.
32export function writeIn(lang: Lang): string {
33  return lang === 'zh-TW' ? 'Write in Traditional Chinese (zh-TW).' : 'Write in English.'
34}
35
36export type Layer = 'default' | 'user' | 'repo' | 'local' | 'session'
37
38// What the committed repo file may do: a clone must not loosen a protection or switch on spending.
39// `append` adds to the list below it; `never` leaves the key to the person's own layers.
40export type RepoRule = 'any' | 'only-true' | 'only-false' | 'only-lower' | 'only-higher' | 'append' | 'never'
41
42export type Category = 'protect' | 'cost' | 'plain'
43
44export type KeySpec = {
45  category: Category
46  type: 'boolean' | 'number' | 'string' | 'list'
47  default: Value
48  repo: RepoRule
49  description: string | Text
50  min?: number
51  max?: number
52  // The values a string may take.
53  choices?: readonly string[]
54  // A feature `/<mod> on|off` switches: the value each one sets.
55  toggle?: { on: Value; off: Value }
56}
57
58export type Specs = Readonly<Record<string, KeySpec>>
59
60// The key every mod has: the language of what it shows. One value may stand for all mods in a
61// settings file's `"*"` section. It is the person's own taste, so the repo file cannot set it.
62export const LANGUAGE: KeySpec = {
63  category: 'plain',
64  type: 'string',
65  default: 'en',
66  repo: 'never',
67  choices: ['en', 'zh-TW'],
68  description: {
69    en: 'Language of what this mod shows: en or zh-TW. Put {"*": {"language": "..."}} in a settings file to set it for every mod.',
70    'zh-TW': '這個 mod 顯示的語言:en 或 zh-TW。在設定檔放 {"*": {"language": "..."}} 可一次設定所有 mod。',
71  },
72}
73
74export type Ignored = { key: string; layer: Layer; reason: string }
75
76export type Resolved = {
77  // The language the values come to (`language`, English without it).
78  lang: Lang
79  values: Readonly<Record<string, Value>>
80  sources: Readonly<Record<string, Layer>>
81  ignored: readonly Ignored[]
82  // When each key switched on by `/<mod> on` goes back, for the keys whose value it decides.
83  until: Readonly<Record<string, number>>
84}
85
86export type Target = 'session' | 'local' | 'repo'
87
88type Raw = Readonly<Record<string, unknown>>
89
90export type Layers = {
91  options: PluginOptions
92  user: Raw
93  repo: Raw
94  local: Raw
95  session: Raw
96  // When the session values set by `/<mod> on` run out; the expired ones are left out already.
97  until?: Readonly<Record<string, number>>
98}
99
100export const REPO_FILE = '.claude/cc-mod-trimtab.json'
101export const LOCAL_FILE = '.claude/cc-mod-trimtab.local.json'
102const USER_FILE = 'cc-mod-trimtab.json'
103// Both keyed by session id: `$.store` is one file every session of the mod shares.
104const SESSION_KEY = 'config-sessions'
105const NOTICED_KEY = 'config-noticed-sessions'
106// The settings files' section for what every mod shares.
107const SHARED = '*'
108
109const DEFAULT_ON_MS = 2 * 3_600_000
110const MAX_ON_MS = 24 * 3_600_000
111// A session's entries are dropped once nothing wrote them for this long.
112const KEEP_SESSION_MS = 7 * 86_400_000
113
114const LAYER_NAMES: Readonly<Record<Layer, Text>> = {
115  default: { en: 'default', 'zh-TW': '預設' },
116  user: { en: 'user', 'zh-TW': '使用者' },
117  repo: { en: 'repo', 'zh-TW': 'repo' },
118  local: { en: 'local', 'zh-TW': 'local' },
119  session: { en: 'session', 'zh-TW': 'session' },
120}
121
122const CATEGORY_NAMES: Readonly<Record<Category, Text>> = {
123  protect: { en: 'protection', 'zh-TW': '保護' },
124  cost: { en: 'cost', 'zh-TW': '花費' },
125  plain: { en: 'general', 'zh-TW': '一般' },
126}
127
128const RULE_NAMES: Readonly<Record<RepoRule, Text>> = {
129  any: { en: 'repo may set freely', 'zh-TW': 'repo 可自由設定' },
130  'only-true': { en: 'repo may only turn on', 'zh-TW': 'repo 只能開啟' },
131  'only-false': { en: 'repo may only turn off', 'zh-TW': 'repo 只能關閉' },
132  'only-lower': { en: 'repo may only lower', 'zh-TW': 'repo 只能調低' },
133  'only-higher': { en: 'repo may only raise', 'zh-TW': 'repo 只能調高' },
134  append: { en: 'repo may only add items', 'zh-TW': 'repo 只能加項目' },
135  never: { en: 'repo may not set', 'zh-TW': 'repo 不能設定' },
136}
137
138function langOf(value: unknown): Lang {
139  return value === 'zh-TW' ? 'zh-TW' : 'en'
140}
141
142function listOf(lang: Lang, items: readonly string[]): string {
143  return items.join(lang === 'zh-TW' ? '、' : ', ')
144}
145
146// ---------- pure ----------
147
148// A stored value of the key's type, or why it is not one.
149export function parseValue(spec: KeySpec, raw: unknown, lang: Lang = 'en'): { value: Value } | { error: string } {
150  switch (spec.type) {
151    case 'boolean':
152      return typeof raw === 'boolean' ? { value: raw } : { error: pick(lang, { en: 'must be true or false', 'zh-TW': '要是 true 或 false' }) }
153    case 'number': {
154      if (typeof raw !== 'number' || !Number.isFinite(raw)) {
155        return { error: pick(lang, { en: 'must be a number', 'zh-TW': '要是數字' }) }
156      }
157      if ((spec.min !== undefined && raw < spec.min) || (spec.max !== undefined && raw > spec.max)) {
158        const min = spec.min ?? '-∞'
159        const max = spec.max ?? '∞'
160
161        return { error: pick(lang, { en: `must be between ${min} and ${max}`, 'zh-TW': `要在 ${min} 到 ${max} 之間` }) }
162      }
163
164      return { value: raw }
165    }
166    case 'string':
167      if (typeof raw !== 'string') {
168        return { error: pick(lang, { en: 'must be a string', 'zh-TW': '要是字串' }) }
169      }
170      if (spec.choices !== undefined && !spec.choices.includes(raw)) {
171        return { error: pick(lang, { en: `must be one of ${listOf(lang, spec.choices)}`, 'zh-TW': `要是 ${listOf(lang, spec.choices)} 之一` }) }
172      }
173
174      return { value: raw }
175    case 'list':
176      return Array.isArray(raw) && raw.every(item => typeof item === 'string')
177        ? { value: raw as readonly string[] }
178        : { error: pick(lang, { en: 'must be an array of strings', 'zh-TW': '要是字串陣列' }) }
179  }
180}
181
182// A value typed after `/<mod> set <key>`: on/off for booleans; for lists a JSON array, or the
183// whole text as one item (commas are common in a pattern, so they do not split).
184export function parseInput(spec: KeySpec, text: string, lang: Lang = 'en'): { value: Value } | { error: string } {
185  const t = text.trim()
186  switch (spec.type) {
187    case 'boolean': {
188      const on = ['true', 'on', 'yes', '1'].includes(t.toLowerCase())
189      const off = ['false', 'off', 'no', '0'].includes(t.toLowerCase())
190
191      return on || off ? { value: on } : { error: pick(lang, { en: 'must be on or off', 'zh-TW': '要是 on 或 off' }) }
192    }
193    case 'number':
194      return parseValue(spec, t === '' ? Number.NaN : Number(t), lang)
195    case 'string':
196      return parseValue(spec, t, lang)
197    case 'list': {
198      if (t.startsWith('[')) {
199        try {
200          return parseValue(spec, JSON.parse(t), lang)
201        } catch {
202          return { error: pick(lang, { en: 'not a valid JSON array', 'zh-TW': 'JSON 陣列格式不對' }) }
203        }
204      }
205
206      return { value: t === '' ? [] : [t] }
207    }
208  }
209}
210
211function same(a: Value, b: Value): boolean {
212  return JSON.stringify(a) === JSON.stringify(b)
213}
214
215// What the repo file's value comes to over the one below it, or why it is ignored.
216export function applyRepoRule(spec: KeySpec, below: Value, value: Value, lang: Lang = 'en'): { value: Value } | { error: string } {
217  // `never` refuses even the value below it, so `set --repo` cannot write the key at all.
218  if (spec.repo !== 'never' && (same(below, value) || spec.repo === 'any')) {
219    return { value }
220  }
221  switch (spec.repo) {
222    case 'only-true':
223      return value === true ? { value } : { error: pick(lang, { en: 'protection key: the repo file may only turn it on', 'zh-TW': '保護類,repo 檔案只能開啟' }) }
224    case 'only-false':
225      return value === false ? { value } : { error: pick(lang, { en: 'cost key: the repo file may only turn it off', 'zh-TW': '花費類,repo 檔案只能關閉' }) }
226    case 'only-lower':
227      return (value as number) < (below as number) ? { value } : { error: pick(lang, { en: 'the repo file may only lower it', 'zh-TW': 'repo 檔案只能調低' }) }
228    case 'only-higher':
229      return (value as number) > (below as number) ? { value } : { error: pick(lang, { en: 'the repo file may only raise it', 'zh-TW': 'repo 檔案只能調高' }) }
230    case 'append':
231      return { value: [...new Set([...(below as readonly string[]), ...(value as readonly string[])])] }
232    case 'never':
233      return { error: pick(lang, { en: 'the repo file may not set it; use the local file', 'zh-TW': 'repo 檔案不能設定,改用 local 檔案' }) }
234  }
235}
236
237// The one key whose user layer is the settings file, single value or not: `/config` cannot hold
238// a value shared by every mod.
239function isFileValue(key: string, spec: KeySpec): boolean {
240  return spec.type === 'list' || key === 'language'
241}
242
243function resolveKey(key: string, spec: KeySpec, layers: Layers, lang: Lang, ignored: Ignored[]): { value: Value; source: Layer } {
244  let value = spec.default
245  let source: Layer = 'default'
246  const take = (layer: Layer, raw: unknown) => {
247    if (raw === undefined) {
248      return
249    }
250    const parsed = parseValue(spec, raw, lang)
251    if ('error' in parsed) {
252      ignored.push({ key, layer, reason: parsed.error })
253
254      return
255    }
256    const applied = layer === 'repo' ? applyRepoRule(spec, value, parsed.value, lang) : parsed
257    if ('error' in applied) {
258      ignored.push({ key, layer, reason: applied.error })
259
260      return
261    }
262    if (layer === 'user' && same(applied.value, spec.default)) {
263      // `/config` hands over defaults filled in: the same value is no choice of the person's.
264      return
265    }
266    value = applied.value
267    source = layer
268  }
269  // Lists have no `/config` row; their user layer is the file.
270  take('user', isFileValue(key, spec) ? layers.user[key] : layers.options[key])
271  take('repo', layers.repo[key])
272  take('local', layers.local[key])
273  take('session', layers.session[key])
274
275  return { value, source }
276}
277
278export function resolve(specs: Specs, layers: Layers): Resolved {
279  const values: Record<string, Value> = {}
280  const sources: Record<string, Layer> = {}
281  const ignored: Ignored[] = []
282  // The language first: the other keys' reasons are worded in it.
283  const languageSpec = Object.hasOwn(specs, 'language') ? (specs.language as KeySpec) : LANGUAGE
284  const lang = langOf(resolveKey('language', languageSpec, layers, 'en', []).value)
285  for (const [key, spec] of Object.entries(specs)) {
286    const one = resolveKey(key, spec, layers, lang, ignored)
287    values[key] = one.value
288    sources[key] = one.source
289  }
290  for (const layer of ['user', 'repo', 'local', 'session'] as const) {
291    for (const key of Object.keys(layers[layer])) {
292      const spec = Object.hasOwn(specs, key) ? specs[key] : undefined
293      if (spec === undefined) {
294        ignored.push({ key, layer, reason: pick(lang, { en: 'no such setting', 'zh-TW': '沒有這個設定' }) })
295      } else if (layer === 'user' && !isFileValue(key, spec)) {
296        ignored.push({
297          key,
298          layer,
299          reason: pick(lang, { en: 'the user settings file holds lists only; use /config for a single value', 'zh-TW': '使用者設定檔只放清單,單一值用 /config' }),
300        })
301      }
302    }
303  }
304
305  const until: Record<string, number> = {}
306  for (const [key, at] of Object.entries(layers.until ?? {})) {
307    if (sources[key] === 'session' && Object.hasOwn(layers.session, key)) {
308      until[key] = at
309    }
310  }
311
312  return { lang, values, sources, ignored, until }
313}
314
315// `2h`, `90m`, `1h30m`, or a bare number of minutes.
316export function parseDuration(text: string): number | undefined {
317  const t = text.trim().toLowerCase()
318  const match = /^(?:(\d+)h)?(?:(\d+)m?)?$/.exec(t)
319  if (t === '' || match === null || (match[1] === undefined && match[2] === undefined)) {
320    return undefined
321  }
322  const ms = Number(match[1] ?? 0) * 3_600_000 + Number(match[2] ?? 0) * 60_000
323
324  return ms > 0 && ms <= MAX_ON_MS ? ms : undefined
325}
326
327// `1h42m`, `37m`, `<1m`.
328export function fmtLeft(ms: number): string {
329  const minutes = Math.ceil(ms / 60_000)
330  if (ms < 60_000) {
331    return '<1m'
332  }
333  const h = Math.floor(minutes / 60)
334  const m = minutes % 60
335
336  return h === 0 ? `${m}m` : m === 0 ? `${h}h` : `${h}h${m}m`
337}
338
339// The band line while a feature is on for a while: `<mod> on · keep_warm 1h42m`, '' when none is.
340export function onLine(mod: string, until: Readonly<Record<string, number>>, now: number): string {
341  const parts = Object.entries(until)
342    .filter(([, at]) => at > now)
343    .map(([key, at]) => `${key} ${fmtLeft(at - now)}`)
344
345  return parts.length === 0 ? '' : [`${mod} on`, ...parts].join(' · ')
346}
347
348// When the first of the features switched on for a while goes back.
349export function nextExpiry(resolved: Resolved): number | undefined {
350  const times = Object.values(resolved.until)
351
352  return times.length === 0 ? undefined : Math.min(...times)
353}
354
355function show(value: Value): string {
356  return Array.isArray(value) ? (value.length === 0 ? '[]' : value.join(', ')) : String(value)
357}
358
359export function statusText(mod: string, specs: Specs, resolved: Resolved, now = 0): string {
360  const lang = resolved.lang
361  const keys = Object.keys(specs)
362  if (keys.length === 0) {
363    return pick(lang, { en: `${mod} has no settings.`, 'zh-TW': `${mod} 沒有可調的設定。` })
364  }
365  const lines = [
366    pick(lang, {
367      en: `${mod} settings (precedence: session > local > repo > user > default)`,
368      'zh-TW': `${mod} 設定(優先:session > local > repo > 使用者 > 預設)`,
369    }),
370  ]
371  for (const key of keys) {
372    const spec = specs[key] as KeySpec
373    const value = resolved.values[key] as Value
374    const source = resolved.sources[key] as Layer
375    const at = resolved.until[key]
376    const left = at === undefined ? '' : pick(lang, { en: `, on temporarily for ${fmtLeft(at - now)}`, 'zh-TW': `,臨時開啟剩 ${fmtLeft(at - now)}` })
377    const kind = pick(lang, CATEGORY_NAMES[spec.category])
378    const rule = pick(lang, RULE_NAMES[spec.repo])
379    lines.push(`  ${key} = ${show(value)}  [${pick(lang, LAYER_NAMES[source])}${left}]  ${kind}${lang === 'zh-TW' ? ',' : ', '}${rule}`)
380    lines.push(`    ${say(lang, spec.description)}`)
381  }
382  if (resolved.ignored.length > 0) {
383    lines.push(pick(lang, { en: 'Ignored:', 'zh-TW': '已忽略:' }))
384    for (const item of resolved.ignored) {
385      const layer = pick(lang, LAYER_NAMES[item.layer])
386      lines.push(pick(lang, { en: `  ${item.key} in ${layer}: ${item.reason}`, 'zh-TW': `  ${layer} 的 ${item.key}:${item.reason}` }))
387    }
388  }
389  lines.push(
390    pick(lang, {
391      en: `Change: /${mod} set <key> <value> [--session|--local|--repo] (default --session); revert: /${mod} unset <key> [same flags]`,
392      'zh-TW': `改值:/${mod} set <key> <value> [--session|--local|--repo](預設 --session),還原:/${mod} unset <key> [同上]`,
393    }),
394  )
395  const toggles = toggleKeys(specs)
396  if (toggles.length > 0) {
397    const names = listOf(lang, toggles)
398    const feature = toggles.length === 1 ? '' : pick(lang, { en: ' <feature>', 'zh-TW': ' <功能>' })
399    lines.push(
400      pick(lang, {
401        en: `Features that use the model (${names}): /${mod} on${feature} [duration, default 2h] turns one on for a while, /${mod} off${feature} turns it off`,
402        'zh-TW': `用模型的功能(${names}):/${mod} on${feature} [時長,預設 2h] 臨時開啟,/${mod} off${feature} 關閉`,
403      }),
404    )
405  }
406
407  return lines.join('\n')
408}
409
410function modSection(text: string, mod: string): Raw {
411  if (text.trim() === '') {
412    return {}
413  }
414  const parsed: unknown = JSON.parse(text)
415  const section = typeof parsed === 'object' && parsed !== null ? (parsed as Raw)[mod] : undefined
416
417  return typeof section === 'object' && section !== null && !Array.isArray(section) ? (section as Raw) : {}
418}
419
420// The file's text with the key set (or removed when `value` is undefined), other mods kept.
421export function withSetting(text: string, mod: string, key: string, value: Value | undefined): string {
422  const parsed: unknown = text.trim() === '' ? {} : JSON.parse(text)
423  const all = typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? { ...(parsed as Raw) } : {}
424  const section = { ...modSection(text, mod) }
425  if (value === undefined) {
426    delete section[key]
427  } else {
428    section[key] = value
429  }
430  if (Object.keys(section).length === 0) {
431    delete all[mod]
432  } else {
433    all[mod] = section
434  }
435
436  return `${JSON.stringify(all, null, 2)}\n`
437}
438
439export function withLocalExclude(text: string): string | undefined {
440  const line = `/${LOCAL_FILE}`
441  if (text.split('\n').some(l => l.trim() === line)) {
442    return undefined
443  }
444
445  return `${text}${text === '' || text.endsWith('\n') ? '' : '\n'}# cc-mod-trimtab\n${line}\n`
446}
447
448const TARGET_FLAG = /(^|\s)--(session|local|repo)(?=\s|$)/g
449
450// `<key> [value] [--session|--local|--repo]`, the flag anywhere. The value keeps its spacing.
451export function parseSetArgs(args: string, lang: Lang = 'en'): { key: string; rest: string; target: Target } | { error: string } {
452  const targets = [...args.matchAll(TARGET_FLAG)].map(match => match[2] as Target)
453  if (targets.length > 1) {
454    return { error: pick(lang, { en: 'give only one of --session, --local or --repo', 'zh-TW': '只能加一個 --session、--local 或 --repo' }) }
455  }
456  const text = args.replace(TARGET_FLAG, '$1').trim()
457  const key = text.split(/\s+/)[0] ?? ''
458  if (key === '') {
459    return { error: pick(lang, { en: 'name a setting', 'zh-TW': '要指定設定名稱' }) }
460  }
461
462  return { key, rest: text.slice(key.length).trim(), target: targets[0] ?? 'session' }
463}
464
465// ---------- through the mod's I/O ----------
466
467// What the settings need from `$`. `$` cannot cross an import, so each mod builds this in its own
468// register file (the `configIO` block, kept equal across mods by scripts/check-copies.sh).
469export type ConfigIO = {
470  // The file's text, '' when it is missing.
471  read: (path: string) => Promise<string>
472  write: (path: string, text: string) => Promise<void>
473  isDir: (path: string) => Promise<boolean>
474  // `CLAUDE_CONFIG_DIR`, else `$HOME/.claude`.
475  configDir: () => Promise<string>
476  root: () => Promise<string>
477  // The main working tree, whose `.git/info/exclude` covers every worktree.
478  repoRoot: () => Promise<string | undefined>
479  sessionId: () => Promise<string>
480  now: () => Promise<number>
481  storeGet: (key: string) => Promise<unknown>
482  storeSet: (key: string, value: unknown) => Promise<void>
483}
484
485// What a file adds to the layers: the mod's section, with `language` from `"*"` when the section
486// has none of its own, and the problems found on the way.
487type Problem = { key: string; layer: Layer; reason: Text }
488
489function readSection(text: string, path: string, mod: string, layer: Layer, broken: Problem[]): Raw {
490  try {
491    const own = modSection(text, mod)
492    const shared = modSection(text, SHARED)
493    for (const key of Object.keys(shared)) {
494      if (key !== 'language') {
495        broken.push({
496          key: `${SHARED}.${key}`,
497          layer,
498          reason: { en: `only language is read from "${SHARED}"`, 'zh-TW': `"${SHARED}" 只讀 language` },
499        })
500      }
501    }
502
503    return shared.language === undefined || own.language !== undefined ? own : { ...own, language: shared.language }
504  } catch {
505    broken.push({ key: path, layer, reason: { en: 'not valid JSON; the whole file is not used', 'zh-TW': 'JSON 格式不對,整個檔案沒有採用' } })
506
507    return {}
508  }
509}
510
511type SessionEntry = { values: Raw; until?: Readonly<Record<string, number>> }
512type Sessions<T> = Readonly<Record<string, T & { written: number }>>
513
514// Every session's entry under `key`, with `id`'s replaced by `entry` and those not written for a
515// week dropped.
516async function writeSessions<T>(io: ConfigIO, key: string, id: string, entry: T): Promise<void> {
517  const now = await io.now()
518  const kept: Record<string, T & { written: number }> = {}
519  for (const [other, stored] of Object.entries(((await io.storeGet(key)) ?? {}) as Sessions<T>)) {
520    if (other !== id && now - stored.written < KEEP_SESSION_MS) {
521      kept[other] = stored
522    }
523  }
524  kept[id] = { ...entry, written: now }
525  await io.storeSet(key, kept)
526}
527
528async function sessionEntry<T>(io: ConfigIO, key: string, id: string): Promise<T | undefined> {
529  const stored = (await io.storeGet(key)) as Sessions<T> | undefined
530
531  return stored !== undefined && Object.hasOwn(stored, id) ? stored[id] : undefined
532}
533
534// This session's values; those `/<mod> on` set left out once they ran out.
535async function sessionValues(io: ConfigIO): Promise<{ values: Raw; until: Record<string, number> }> {
536  const stored = await sessionEntry<SessionEntry>(io, SESSION_KEY, await io.sessionId())
537  if (stored === undefined) {
538    return { values: {}, until: {} }
539  }
540  const timed = Object.entries(stored.until ?? {})
541  // Read only when needed: a mod without features to switch never asks the clock.
542  const now = timed.length === 0 ? 0 : await io.now()
543  const values: Record<string, unknown> = { ...stored.values }
544  const until: Record<string, number> = {}
545  for (const [key, at] of timed) {
546    if (at > now) {
547      until[key] = at
548    } else {
549      delete values[key]
550    }
551  }
552
553  return { values, until }
554}
555
556async function writeSession(io: ConfigIO, key: string, value: Value | undefined, until: number | undefined): Promise<void> {
557  const current = await sessionValues(io)
558  const values: Record<string, unknown> = { ...current.values }
559  const times = { ...current.until }
560  delete values[key]
561  delete times[key]
562  if (value !== undefined) {
563    values[key] = value
564  }
565  if (value !== undefined && until !== undefined) {
566    times[key] = until
567  }
568  await writeSessions(io, SESSION_KEY, await io.sessionId(), { values, until: times })
569}
570
571// True the first time a feature does its work in this session, for the one notice it gets.
572export async function firstUse(io: ConfigIO, key: string): Promise<boolean> {
573  const sessionId = await io.sessionId()
574  const keys = (await sessionEntry<{ keys: string[] }>(io, NOTICED_KEY, sessionId))?.keys ?? []
575  if (keys.includes(key)) {
576    return false
577  }
578  await writeSessions(io, NOTICED_KEY, sessionId, { keys: [...keys, key] })
579
580  return true
581}
582
583async function filePaths(io: ConfigIO): Promise<{ user: string; repo: string; local: string }> {
584  const root = (await io.root()).replace(/\/$/, '')
585
586  return { user: `${(await io.configDir()).replace(/\/$/, '')}/${USER_FILE}`, repo: `${root}/${REPO_FILE}`, local: `${root}/${LOCAL_FILE}` }
587}
588
589async function loadLayers(io: ConfigIO, mod: string, options: PluginOptions): Promise<{ layers: Layers; broken: Problem[] }> {
590  const paths = await filePaths(io)
591  const broken: Problem[] = []
592  const session = await sessionValues(io)
593  const layers = {
594    options,
595    user: readSection(await io.read(paths.user), paths.user, mod, 'user', broken),
596    repo: readSection(await io.read(paths.repo), paths.repo, mod, 'repo', broken),
597    local: readSection(await io.read(paths.local), paths.local, mod, 'local', broken),
598    session: session.values,
599    until: session.until,
600  }
601
602  return { layers, broken }
603}
604
605export async function loadConfig(io: ConfigIO, mod: string, specs: Specs, options: PluginOptions): Promise<Resolved> {
606  const { layers, broken } = await loadLayers(io, mod, options)
607  const resolved = resolve(specs, layers)
608
609  const problems = broken.map(item => ({ ...item, reason: pick(resolved.lang, item.reason) }))
610
611  return { ...resolved, ignored: [...problems, ...resolved.ignored] }
612}
613
614async function writeSetting(io: ConfigIO, mod: string, key: string, value: Value | undefined, target: Target): Promise<void> {
615  if (target === 'session') {
616    await writeSession(io, key, value, undefined)
617
618    return
619  }
620  const paths = await filePaths(io)
621  const path = target === 'repo' ? paths.repo : paths.local
622  await io.write(path, withSetting(await io.read(path), mod, key, value))
623  if (target === 'local') {
624    const repo = await io.repoRoot()
625    if (repo !== undefined && (await io.isDir(`${repo}/.git`))) {
626      const exclude = `${repo}/.git/info/exclude`
627      const added = withLocalExclude(await io.read(exclude))
628      if (added !== undefined) {
629        await io.write(exclude, added)
630      }
631    }
632  }
633}
634
635// Answers `/<mod> status|set|unset …`; undefined for any other sub-command. `reload` hands the
636// mod the new values after a change.
637export async function configCommand(
638  io: ConfigIO,
639  mod: string,
640  specs: Specs,
641  options: PluginOptions,
642  args: string,
643  reload: (resolved: Resolved) => void,
644): Promise<string | undefined> {
645  const sub = args.trim().split(/\s+/)[0] ?? ''
646  const rest = args.trim().slice(sub.length)
647  if (sub === 'status') {
648    const resolved = await loadConfig(io, mod, specs, options)
649    reload(resolved)
650
651    return statusText(mod, specs, resolved, Object.keys(resolved.until).length === 0 ? 0 : await io.now())
652  }
653  if ((sub === 'on' || sub === 'off') && toggleKeys(specs).length > 0) {
654    return switchFeature(io, mod, specs, options, sub, rest, reload)
655  }
656  if (sub !== 'set' && sub !== 'unset') {
657    return undefined
658  }
659  const before = await loadLayers(io, mod, options)
660  const lang = resolve(specs, before.layers).lang
661  const parsed = parseSetArgs(rest, lang)
662  if ('error' in parsed) {
663    return parsed.error
664  }
665  const spec = Object.hasOwn(specs, parsed.key) ? specs[parsed.key] : undefined
666  if (spec === undefined) {
667    return pick(lang, {
668      en: `${mod} has no setting ${parsed.key}; /${mod} status lists them all`,
669      'zh-TW': `${mod} 沒有 ${parsed.key} 這個設定;/${mod} status 列出全部`,
670    })
671  }
672  let value: Value | undefined
673  if (sub === 'set') {
674    const input = parseInput(spec, parsed.rest, lang)
675    if ('error' in input) {
676      return `${parsed.key} ${input.error}`
677    }
678    value = input.value
679  }
680  if (parsed.target === 'repo' && value !== undefined) {
681    // Do not commit a value every reader would ignore.
682    const { layers } = before
683    const trial = resolve(specs, { ...layers, repo: { ...layers.repo, [parsed.key]: value } })
684    const refused = trial.ignored.find(item => item.layer === 'repo' && item.key === parsed.key)
685    if (refused !== undefined) {
686      return pick(lang, { en: `Not written: ${refused.reason}`, 'zh-TW': `沒有寫入:${refused.reason}` })
687    }
688  }
689  await writeSetting(io, mod, parsed.key, value, parsed.target)
690  const resolved = await loadConfig(io, mod, specs, options)
691  reload(resolved)
692  const shown = show(resolved.values[parsed.key] as Value)
693  const from = pick(resolved.lang, LAYER_NAMES[resolved.sources[parsed.key] as Layer])
694  const now = pick(resolved.lang, { en: `${parsed.key} is now ${shown} (from ${from})`, 'zh-TW': `${parsed.key} 現在是 ${shown}(來自 ${from})` })
695  const where =
696    parsed.target === 'session' ? pick(resolved.lang, { en: 'this session only', 'zh-TW': '只在這個 session' }) : parsed.target === 'repo' ? REPO_FILE : LOCAL_FILE
697  const done = sub === 'set' ? pick(resolved.lang, { en: 'Set', 'zh-TW': '已設定' }) : pick(resolved.lang, { en: 'Removed', 'zh-TW': '已移除' })
698
699  return pick(resolved.lang, { en: `${done} (${where}). ${now}`, 'zh-TW': `${done}(${where})。${now}` })
700}
701
702export function toggleKeys(specs: Specs): string[] {
703  return Object.keys(specs).filter(key => specs[key]?.toggle !== undefined)
704}
705
706// `[feature] [duration]` in either order; the feature may go when the mod has one.
707export function parseOnArgs(specs: Specs, args: string, withDuration: boolean, lang: Lang = 'en'): { key: string; ms: number } | { error: string } {
708  const toggles = toggleKeys(specs)
709  let key: string | undefined
710  let ms: number | undefined
711  for (const word of args.trim().split(/\s+/).filter(w => w !== '')) {
712    if (key === undefined && toggles.includes(word)) {
713      key = word
714      continue
715    }
716    const duration = withDuration && ms === undefined ? parseDuration(word) : undefined
717    if (duration === undefined) {
718      const names = listOf(lang, toggles)
719      const hint = withDuration ? pick(lang, { en: '; a duration looks like 2h, 90m, 1h30m (24h at most)', 'zh-TW': ',時長像 2h、90m、1h30m(最多 24h)' }) : ''
720
721      return { error: pick(lang, { en: `Cannot read "${word}": the features are ${names}${hint}`, 'zh-TW': `看不懂「${word}」:功能是 ${names}${hint}` }) }
722    }
723    ms = duration
724  }
725  if (key === undefined && toggles.length === 1) {
726    key = toggles[0]
727  }
728  if (key === undefined) {
729    return { error: pick(lang, { en: `Name a feature: ${listOf(lang, toggles)}`, 'zh-TW': `要指定功能:${listOf(lang, toggles)}` }) }
730  }
731
732  return { key, ms: ms ?? DEFAULT_ON_MS }
733}
734
735async function switchFeature(
736  io: ConfigIO,
737  mod: string,
738  specs: Specs,
739  options: PluginOptions,
740  sub: 'on' | 'off',
741  args: string,
742  reload: (resolved: Resolved) => void,
743): Promise<string> {
744  const { layers } = await loadLayers(io, mod, options)
745  const lang = resolve(specs, layers).lang
746  const parsed = parseOnArgs(specs, args, sub === 'on', lang)
747  if ('error' in parsed) {
748    return parsed.error
749  }
750  const { key, ms } = parsed
751  const toggle = (specs[key] as KeySpec).toggle as { on: Value; off: Value }
752  const rest: Record<string, unknown> = { ...layers.session }
753  delete rest[key]
754  const below = resolve(specs, { ...layers, session: rest })
755  const belowValue = below.values[key] as Value
756  const isOnBelow = !same(belowValue, toggle.off)
757  const now = await io.now()
758  const from = pick(lang, LAYER_NAMES[below.sources[key] as Layer])
759  if (sub === 'on') {
760    if (isOnBelow) {
761      const wasOff = Object.hasOwn(layers.session, key)
762      await writeSession(io, key, undefined, undefined)
763      reload(await loadConfig(io, mod, specs, options))
764      const source = pick(lang, { en: `${show(belowValue)}, from ${from}`, 'zh-TW': `${show(belowValue)},來自 ${from}` })
765
766      return wasOff
767        ? pick(lang, { en: `${key}: this session's off is cancelled, back to always on (${source})`, 'zh-TW': `${key} 取消這個 session 的關閉,回到常開(${source})` })
768        : pick(lang, { en: `${key} is already always on (${source}); no need to turn it on for a while`, 'zh-TW': `${key} 已經常開(${source}),不用臨時開啟` })
769    }
770    await writeSession(io, key, toggle.on, now + ms)
771    reload(await loadConfig(io, mod, specs, options))
772    const at = new Date(now + ms)
773    const clock = `${String(at.getHours()).padStart(2, '0')}:${String(at.getMinutes()).padStart(2, '0')}`
774    const off = `/${mod} off${toggleKeys(specs).length === 1 ? '' : ` ${key}`}`
775
776    return pick(lang, {
777      en: `${key} on for ${fmtLeft(ms)} (until ${clock}); it goes off by itself when that runs out or the session ends; turn off: ${off}`,
778      'zh-TW': `${key} 臨時開啟 ${fmtLeft(ms)}(到 ${clock}),到期或 session 結束自動關;關閉:${off}`,
779    })
780  }
781  // Off for the rest of the session; nothing to keep when the layers below are off already.
782  await writeSession(io, key, isOnBelow ? toggle.off : undefined, undefined)
783  reload(await loadConfig(io, mod, specs, options))
784
785  return isOnBelow
786    ? pick(lang, {
787        en: `${key} is off for this session (${from} has it on; it comes back next session)`,
788        'zh-TW': `${key} 在這個 session 關閉(${from} 設定是開啟,下個 session 恢復)`,
789      })
790    : pick(lang, { en: `${key} is already off`, 'zh-TW': `${key} 已關閉` })
791}
792
hooks/ui.tsx 114 lines
1// The parts every mod's screens share, as DESIGN.md sets them out. Mods cannot import outside their
2// own folder, so each mod that draws a pane or a band keeps a copy; scripts/check-copies.sh checks
3// they match.
4import type { Elements, RenderNode } from 'claude-code'
5
6import { pick, type Lang } from './config'
7
8type Kit = Pick<Elements['terminal'], 'Box' | 'Button' | 'Text'>
9type Words = { en: string; 'zh-TW': string }
10
11// What a color means: yellow waits on the user, red is an error, an interruption or a risk.
12export const TONE = { attention: 'yellow', danger: 'red' } as const
13
14// A card, and the yellow frame of a pane that holds Claude until the user answers.
15export const CARD = { borderStyle: 'round', borderDimColor: true, paddingX: 1 } as const
16export const CONFIRM = { borderStyle: 'round', borderColor: TONE.attention, paddingX: 1 } as const
17
18// Where a part of a bar comes from: `ink` on the terminal, `fill` in desktop's Svg. Main session
19// purple, subagent cyan, named model yellow, default model gray, fork green.
20export const COLORS = {
21  main: { ink: 'magenta', fill: '#8a5cc7' },
22  sub: { ink: 'cyan', fill: '#2f8fb0' },
23  named: { ink: 'yellow', fill: '#c98a12' },
24  default: { ink: 'gray', fill: '#8b8b93' },
25  fork: { ink: 'green', fill: '#3f9a4f' },
26} as const
27
28// The main session's parts (its models), one after another in the same bar.
29export const MAIN_SHADES = [COLORS.main, { ink: 'magentaBright', fill: '#b48ee6' }, { ink: 'magenta', fill: '#6b3fa8' }]
30
31export type Option = { value: string; label: string }
32
33// Switches in place, so nothing below moves (a Select pushes it down when it opens): in the
34// terminal one button that steps to the next value (its hotkey too) and wraps, elsewhere a segment
35// per value with the hotkey on the one after the current, so it steps forward the same way.
36export function switcher(
37  { Box, Button, Text }: Kit,
38  surface: string,
39  key: string,
40  label: string,
41  hotkey: string,
42  options: Option[],
43  value: string,
44  onPick: (value: string) => void,
45): RenderNode {
46  const at = Math.max(0, options.findIndex(option => option.value === value))
47  if (surface === 'terminal') {
48    const next = options[(at + 1) % options.length]!.value
49
50    return (
51      <Box key={`switch:${key}`} flexDirection="row" flexShrink={0}>
52        {label !== '' && <Text dimColor>{label}</Text>}
53        <Button key={key} plain hotkey={hotkey} onPress={() => onPick(next)}>
54          <Text bold>‹ {options[at]?.label ?? value} ›</Text>
55        </Button>
56      </Box>
57    )
58  }
59
60  return (
61    <Box key={`switch:${key}`} flexDirection="row" gap={1} flexShrink={0}>
62      {label !== '' && <Text dimColor>{label}</Text>}
63      {options.map((option, i) => (
64        <Button
65          key={`${key}:${option.value}`}
66          label={option.label}
67          {...(i === (at + 1) % options.length ? { hotkey } : {})}
68          {...(i === at ? { variant: 'primary' as const } : { dimColor: true })}
69          onPress={() => onPick(option.value)}
70        />
71      ))}
72    </Box>
73  )
74}
75
76// A pane's last line: each key and what it does, then how to close it unless `closes` is false
77// (a pane that waits for an answer). The key keeps the default color and the rest is dim, so the
78// eye finds the keys first: the first word ("d done"), or the key in brackets ("‹ Back (b) returns").
79export function hintLine({ Box, Text }: Kit, lang: Lang, items: Words[], closes = true): RenderNode {
80  const all = closes ? [...items, { en: 'Esc closes', 'zh-TW': 'Esc 關閉' }] : items
81  const parts = all.flatMap((item, i) => {
82    const text = pick(lang, item)
83    const m = /^(.*?[((])(\S)([))].*)$/.exec(text) ?? (text.startsWith('‹') ? null : /^()(\S+)(.*)$/.exec(text))
84    const sep = i === 0 ? [] : [<Text key={`sep${i}`} dimColor>{' · '}</Text>]
85    if (m === null) {
86      return [...sep, <Text key={`all${i}`} dimColor>{text}</Text>]
87    }
88
89    return [
90      ...sep,
91      ...(m[1] === '' ? [] : [<Text key={`pre${i}`} dimColor>{m[1]}</Text>]),
92      <Text key={`key${i}`}>{m[2]}</Text>,
93      <Text key={`does${i}`} dimColor>{m[3]}</Text>,
94    ]
95  })
96
97  return (
98    <Box key="hint" marginTop={1}>
99      <Text wrap="wrap">{parts}</Text>
100    </Box>
101  )
102}
103
104// What a pane shows when it has nothing to list; under a header, the caller sets it a line apart.
105export function emptyState({ Box, Text }: Kit, text: string): RenderNode {
106  return (
107    <Box key="empty">
108      <Text dimColor wrap="wrap">
109        {text}
110      </Text>
111    </Box>
112  )
113}
114
hooks/handoff.ts 292 lines
1// Handoff to a new session: the texts, the new-task gate's prompt and the progress file.
2// Pure functions, no `$`; sending, clearing and drawing are in register.tsx.
3import type { CacheMeter, HandoffKind } from '../types'
4import { pick, writeIn, type Lang } from './config'
5import { TTL_MS } from './meter'
6
7// Shared with project-memory, which writes the file after each distill: session-keeper overwrites
8// it with a handoff summary and is the only one that reads it into a new session.
9export const PROGRESS_FILE = '.claude/progress.md'
10// A progress file older than this is not offered: the work has most likely moved on.
11export const PROGRESS_MAX_AGE_MS = 24 * 60 * 60_000
12// Sessions a progress file was offered to, remembered per file.
13const OFFERED_KEEP = 5
14export const DEFAULT_THRESHOLD = 150_000
15// After the person kept working here, the context must grow this much before the next offer.
16export const QUIET_TOKENS = 50_000
17export const GATE_LOG_KEEP = 300
18const GATE_PROMPTS = 5
19const GATE_FILES = 8
20
21export type GateMode = 'off' | 'suggest' | 'block'
22export type HandoffConfig = { gate: GateMode; isAwayOn: boolean; isResumeOn: boolean }
23
24export function handoffConfig(options: Record<string, unknown>): HandoffConfig {
25  const gate = options.handoff_gate
26
27  return {
28    gate: gate === 'suggest' || gate === 'block' ? gate : 'off',
29    isAwayOn: options.away_handoff === true,
30    isResumeOn: options.progress_resume !== false,
31  }
32}
33
34// The same threshold as the status line's ctx colour.
35export function thresholdOf(env: string | undefined): number {
36  const n = Number(env)
37
38  return Number.isFinite(n) && n > 0 ? n : DEFAULT_THRESHOLD
39}
40
41// Sections ordered by what a successor most often gets wrong: taking unverified work for done,
42// retrying what failed, missing what still runs, forgetting the person's limits.
43// `isNewTaskIn`: the unrelated prompt went into the conversation (suggest mode), so the summary leaves it out.
44// The prompt is English; the language the summary is written in follows the `language` setting.
45export function handoffPrompt(kind: HandoffKind, isNewTaskIn = false, lang: Lang = 'en'): string {
46  return [
47    'Write a handoff for the new conversation that takes over this work. Start the first line with "HANDOFF:" and a one-sentence goal; keep the whole under 1800 words (1800 characters in Chinese).',
48    ...(kind === 'new-task' && isNewTaskIn
49      ? ['The last user message is an unrelated new task; leave it out of the handoff and cover only the work before it.']
50      : []),
51    'In order:',
52    '1. Goal',
53    '2. Verified: checks actually run with their results seen (tests, builds, command output); give the results',
54    '3. Done but unverified: changes not yet checked, or conclusions from reasoning only',
55    '4. Approaches tried that failed, and why (the successor should not try them again)',
56    '5. What still runs or is unfinished: background processes, dev servers, uncommitted changes and their branch, temp files',
57    '6. Decisions made and why, and the limits the user gave during this work (for example no pushing, ask first)',
58    '7. Relevant file paths, and the commands to re-check the current state on pickup',
59    '8. Next step: one concrete action',
60    '9. Questions waiting for the user',
61    '10. Other: anything the list above does not cover but the successor needs to know, in any format',
62    'Write only the facts the successor needs; write "none" for an empty item; no pleasantries.',
63    'For content written elsewhere (spec, plan, issue, commit, diff) give the path or link; do not copy it.',
64    'Never include secrets, tokens, passwords or personal data: the handoff is saved as a file in the repo.',
65    `${writeIn(lang)} Keep code, commands, paths and error messages in their original form.`,
66  ].join('\n')
67}
68
69// The new session's draft: what the person reviews before sending.
70export function handoffDraft(kind: HandoffKind, summary: string, prompt: string, held: readonly string[], lang: Lang = 'en'): string {
71  const asked = [prompt, ...held].map(s => s.trim()).filter(s => s !== '')
72  if (kind === 'new-task') {
73    return asked.join('\n\n')
74  }
75  const intro =
76    kind === 'away'
77      ? pick(lang, {
78          en: '(session-keeper) The cache of the previous conversation expired; here is its handoff summary. Continue from it:',
79          'zh-TW': '(session-keeper)上一段對話的快取已過期,以下是它的交接摘要,從這裡接續:',
80        })
81      : pick(lang, {
82          en: '(session-keeper) Here is the handoff summary of the previous conversation. Continue from it:',
83          'zh-TW': '(session-keeper)以下是上一段對話的交接摘要,從這裡接續:',
84        })
85  const tail = asked.length === 0 ? [] : ['', '---', '', ...asked.flatMap((s, i) => (i > 0 ? ['', s] : [s]))]
86
87  return [intro, '', summary.trim(), ...tail].join('\n')
88}
89
90// Whether the cache under a large context has lapsed, so the next prompt re-writes all of it.
91export function isColdAndLarge(meter: CacheMeter, sessionId: string, now: number, threshold: number): boolean {
92  return (
93    meter.sessionId === sessionId &&
94    meter.lastInput >= threshold &&
95    meter.ttl !== null &&
96    now - meter.lastAt >= TTL_MS[meter.ttl]
97  )
98}
99
100// The context the new-task gate starts at: the threshold, or past the quiet mark after a stay.
101export function gateFloor(threshold: number, quiet: { sessionId: string; tokens: number }, sessionId: string): number {
102  return quiet.sessionId === sessionId ? Math.max(threshold, quiet.tokens) : threshold
103}
104
105// Sent to the model only, so always English.
106export const GATE_SYSTEM = [
107  'You judge whether a new message the user sent in a coding conversation continues the current work or starts an unrelated new task.',
108  "Follow-ups, corrections, additions, answers to Claude's questions, and the next step on the same feature or the same files all count as continuing (related).",
109  'Only starting something unrelated to the current work counts as a new task (new).',
110  'When unsure, answer related. Reply with one word: related or new.',
111].join('\n')
112
113type GateMessage = { role: 'user' | 'assistant'; text: string; toolUses: readonly { input: Record<string, unknown> }[] }
114
115// What the gate's model reads: the task in the progress file, the latest prompts, the files touched.
116export function gatePrompt(text: string, messages: readonly GateMessage[], task: string | undefined): string {
117  const prompts = messages
118    .filter(m => m.role === 'user' && m.text.trim() !== '')
119    .slice(-GATE_PROMPTS)
120    .map(m => `- ${clip(m.text, 300)}`)
121  const files: string[] = []
122  for (const m of [...messages].reverse()) {
123    for (const use of m.toolUses) {
124      const path = use.input.file_path ?? use.input.path
125      if (typeof path === 'string' && !files.includes(path)) {
126        files.push(path)
127      }
128    }
129    if (files.length >= GATE_FILES) {
130      break
131    }
132  }
133
134  return [
135    `Current task (from the progress file; may lag behind): ${task ?? '(none)'}`,
136    '',
137    'Recent user messages:',
138    ...(prompts.length > 0 ? prompts : ['(none)']),
139    '',
140    `Files touched recently: ${files.length > 0 ? files.slice(0, GATE_FILES).join(', ') : '(none)'}`,
141    '',
142    'New message:',
143    clip(text, 1000),
144  ].join('\n')
145}
146
147export function parseVerdict(reply: string): 'new' | 'related' | undefined {
148  const word = reply.trim().toLowerCase()
149  if (word.startsWith('new')) {
150    return 'new'
151  }
152
153  return word.startsWith('related') ? 'related' : undefined
154}
155
156function clip(text: string, max: number): string {
157  const flat = text.replace(/\s+/g, ' ').trim()
158
159  return flat.length > max ? `${flat.slice(0, max)}…` : flat
160}
161
162// ---------- the gate's log: verdicts against what the person chose ----------
163
164export type GateChoice = 'handoff' | 'stay' | 'ignored'
165export type GateEntry = {
166  at: number
167  context: number
168  mode: GateMode
169  verdict: 'new' | 'related'
170  choice?: GateChoice
171  prompt: string
172}
173
174export const gateEntry = (at: number, context: number, mode: GateMode, verdict: 'new' | 'related', text: string): GateEntry => ({
175  at,
176  context,
177  mode,
178  verdict,
179  prompt: clip(text, 60),
180})
181
182export function gateStats(log: readonly GateEntry[], now: number, days: number, lang: Lang = 'en'): string {
183  const since = now - days * 24 * 60 * 60_000
184  const recent = log.filter(e => e.at >= since)
185  const offered = recent.filter(e => e.verdict === 'new')
186  const count = (choice: GateChoice) => offered.filter(e => e.choice === choice).length
187  const [handoff, stay, ignored] = [count('handoff'), count('stay'), count('ignored')]
188  const answered = handoff + stay + ignored
189  const wrong = answered === 0 ? '—' : `${Math.round(((stay + ignored) / answered) * 100)}%`
190
191  return [
192    pick(lang, {
193      en: `new-task gate, last ${days} days: ${recent.length} judged, ${offered.length} judged new`,
194      'zh-TW': `new-task gate,最近 ${days} 天:判斷 ${recent.length} 次,判為新任務 ${offered.length} 次`,
195    }),
196    pick(lang, {
197      en: `  handed off ${handoff}, stayed ${stay}, ignored ${ignored}; share judged new but not handed off ${wrong}`,
198      'zh-TW': `  交接 ${handoff}、留下 ${stay}、忽略 ${ignored};判為新任務卻沒交接的比例 ${wrong}`,
199    }),
200    ...offered.slice(-10).map(e => `  ${e.choice ?? pick(lang, { en: 'no response', 'zh-TW': '未回應' })}  ${Math.round(e.context / 1000)}k  ${e.prompt}`),
201  ].join('\n')
202}
203
204// ---------- the progress file ----------
205
206export type ProgressHead = { session: string; updated: number; source: string }
207
208export function parseProgress(text: string): { head: ProgressHead; body: string } | undefined {
209  const match = /^---\n([\s\S]*?)\n---\n?/.exec(text)
210  if (match === null) {
211    return undefined
212  }
213  const field = (name: string) => new RegExp(`^${name}:\\s*(.*)$`, 'm').exec(match[1] ?? '')?.[1]?.trim() ?? ''
214  const updated = Date.parse(field('updated'))
215
216  return Number.isNaN(updated)
217    ? undefined
218    : { head: { session: field('session'), updated, source: field('source') }, body: text.slice(match[0].length).trim() }
219}
220
221// The task line of the file, as project-memory writes it (in either language), or the summary's HANDOFF line.
222export function progressTask(body: string): string | undefined {
223  const line = /^- (?:任務:|Task:\s*)(.+)$/m.exec(body)?.[1] ?? /^HANDOFF:\s*(.+)$/m.exec(body)?.[1]
224
225  return line === undefined ? undefined : clip(line, 120)
226}
227
228export function renderHandoff(summary: string, session: string, at: number, stamp: string, lang: Lang = 'en'): string {
229  return [
230    '---',
231    'source: session-keeper',
232    `session: ${session}`,
233    `updated: ${new Date(at).toISOString()}`,
234    '---',
235    '',
236    pick(lang, { en: `# Handoff (${stamp})`, 'zh-TW': `# 交接(${stamp})` }),
237    '',
238    summary.trim(),
239    '',
240  ].join('\n')
241}
242
243export type Offered = { updated: number; sessions: string[] }
244
245// The note handed to the model beside the first prompt of a session, or undefined when the file
246// is its own, too old, or already offered to it.
247export function progressNote(text: string, sessionId: string, now: number, offered: Offered | undefined): string | undefined {
248  const parsed = parseProgress(text)
249  if (parsed === undefined || parsed.body === '') {
250    return undefined
251  }
252  const { head, body } = parsed
253  const isOffered = offered !== undefined && offered.updated === head.updated && offered.sessions.includes(sessionId)
254  if (head.session === sessionId || isOffered || now - head.updated > PROGRESS_MAX_AGE_MS) {
255    return undefined
256  }
257
258  // Handed to the model beside the prompt, so always English.
259  return [
260    `(session-keeper) Progress left by the previous conversation (${ago(now - head.updated)}); it may be unrelated to this work:`,
261    '',
262    body,
263    '',
264    'If the user wants to pick it up, start from this and check the actual files and git state before acting; if the user is doing something else, ignore it and do not bring it up.',
265  ].join('\n')
266}
267
268export function withOffered(offered: Offered | undefined, updated: number, sessionId: string): Offered {
269  const sessions = offered?.updated === updated ? offered.sessions : []
270
271  return { updated, sessions: [...sessions.filter(s => s !== sessionId), sessionId].slice(-OFFERED_KEEP) }
272}
273
274function ago(ms: number): string {
275  const min = Math.round(Math.max(0, ms) / 60_000)
276  if (min < 1) {
277    return 'just now'
278  }
279
280  return min < 60 ? `${min} min ago` : `${Math.round(min / 60)} h ago`
281}
282
283// `.git/info/exclude` with the progress file listed once (project-memory adds the same line).
284export function withExclude(text: string): string | undefined {
285  const line = `/${PROGRESS_FILE}`
286  if (text.split('\n').some(l => l.trim() === line)) {
287    return undefined
288  }
289
290  return `${text}${text === '' || text.endsWith('\n') ? '' : '\n'}# session-keeper\n${line}\n`
291}
292
hooks/keep.ts 55 lines
1import type { CacheMeter } from '../types'
2import { TTL_MS } from './meter'
3
4// Below this much context a cold cache is cheap to re-write, so it is not kept.
5export const MIN_KEEP_TOKENS = 30_000
6// Sent to the model only, so always English.
7export const KEEP_PROMPT = 'Reply with OK only'
8
9export type KeepConfig = { isOn: boolean; idleMs: number; maxKeeps: number }
10
11// Reads the manifest's options, clamped: past 59 minutes a 1h cache has already lapsed.
12export function keepConfig(options: Record<string, unknown>): KeepConfig {
13  const num = (value: unknown, fallback: number, min: number, max: number) => {
14    const n = typeof value === 'number' && Number.isFinite(value) ? Math.round(value) : fallback
15
16    return Math.min(max, Math.max(min, n))
17  }
18
19  return {
20    isOn: options.keep_warm === true,
21    idleMs: num(options.idle_minutes, 55, 5, 59) * 60_000,
22    maxKeeps: num(options.max_keeps, 3, 1, 10),
23  }
24}
25
26// When the next keep is due, or null when none will be sent this idle stretch.
27export function keepDueAt(meter: CacheMeter, config: KeepConfig): number | null {
28  if (!config.isOn || meter.requests === 0 || meter.keeps >= config.maxKeeps) {
29    return null
30  }
31
32  return meter.lastAt + config.idleMs
33}
34
35// Why a due keep is not sent; undefined when it should be.
36export function skipReason(meter: CacheMeter, sessionId: string, now: number, config: KeepConfig): string | undefined {
37  if (meter.sessionId !== sessionId) {
38    return 'another conversation'
39  }
40  if (meter.ttl !== '1h') {
41    return `ttl ${meter.ttl ?? 'unknown'}`
42  }
43  if (meter.lastInput < MIN_KEEP_TOKENS) {
44    return `context ${meter.lastInput} < ${MIN_KEEP_TOKENS}`
45  }
46  if (meter.keeps >= config.maxKeeps) {
47    return `${meter.keeps}/${config.maxKeeps} used`
48  }
49  if (now - meter.lastAt >= TTL_MS['1h']) {
50    return 'already cold'
51  }
52
53  return undefined
54}
55
hooks/meter.ts 165 lines
1import type { CacheMeter, CacheTtl } from '../types'
2
3// A request that reads this many fewer tokens than the previous one sent counts as a miss.
4const MISS_MIN_TOKENS = 2048
5const MISS_MIN_RATIO = 0.2
6export const TTL_MS: Record<CacheTtl, number> = { '5m': 5 * 60_000, '1h': 60 * 60_000 }
7// Yellow once this little is left: 5 minutes of an hour, 1 minute of five.
8const WARN_MS: Record<CacheTtl, number> = { '5m': 60_000, '1h': 5 * 60_000 }
9
10export const EMPTY: CacheMeter = {
11  sessionId: '',
12  requests: 0,
13  inputTokens: 0,
14  readTokens: 0,
15  writeTokens: 0,
16  lastAt: 0,
17  lastModel: '',
18  lastInput: 0,
19  nextTokens: 0,
20  toolsKey: '',
21  ttl: null,
22  ttlCheckedAt: 0,
23  misses: 0,
24  missTokens: 0,
25  lastCauses: [],
26  expectRebuild: false,
27  keeps: 0,
28}
29
30export type Step = {
31  at: number
32  model: string
33  toolsKey: string
34  input: number
35  output: number
36  read: number
37  write: number
38}
39
40// Short, order-independent fingerprint of the tool list.
41export function toolsKey(names: readonly string[]): string {
42  let hash = 5381
43  for (const char of [...names].sort().join(',')) {
44    hash = ((hash * 33) ^ char.charCodeAt(0)) >>> 0
45  }
46
47  return `${names.length}:${hash.toString(36)}`
48}
49
50// Which local change most likely invalidated the cache; the API does not say.
51export function guessCauses(meter: CacheMeter, step: Step): string[] {
52  const causes: string[] = []
53  if (meter.lastModel !== '' && step.model !== meter.lastModel) {
54    causes.push('model?')
55  }
56  if (meter.toolsKey !== '' && step.toolsKey !== meter.toolsKey) {
57    causes.push('tools?')
58  }
59  if (step.at - meter.lastAt > TTL_MS[meter.ttl ?? '5m']) {
60    causes.push('ttl?')
61  }
62
63  return causes.length > 0 ? causes : ['unknown']
64}
65
66export function record(meter: CacheMeter, step: Step): CacheMeter {
67  const sent = step.input + step.read + step.write
68  const lost = meter.lastInput - step.read
69  const isMiss =
70    meter.requests > 0 && !meter.expectRebuild && lost > Math.max(MISS_MIN_TOKENS, meter.lastInput * MISS_MIN_RATIO)
71
72  return {
73    ...meter,
74    requests: meter.requests + 1,
75    inputTokens: meter.inputTokens + sent,
76    readTokens: meter.readTokens + step.read,
77    writeTokens: meter.writeTokens + step.write,
78    lastAt: step.at,
79    lastModel: step.model,
80    lastInput: sent,
81    nextTokens: sent + step.output,
82    toolsKey: step.toolsKey,
83    misses: meter.misses + (isMiss ? 1 : 0),
84    missTokens: meter.missTokens + (isMiss ? step.write : 0),
85    lastCauses: isMiss ? guessCauses(meter, step) : meter.lastCauses,
86    expectRebuild: false,
87    keeps: 0,
88  }
89}
90
91// The TTL of the latest cache write the transcript records.
92export function ttlFromTranscript(text: string): CacheTtl | null {
93  let ttl: CacheTtl | null = null
94  for (const match of text.matchAll(/"ephemeral_(5m|1h)_input_tokens":(\d+)/g)) {
95    if (Number(match[2]) > 0) {
96      ttl = match[1] as CacheTtl
97    }
98  }
99
100  return ttl
101}
102
103export function fmtTokens(n: number): string {
104  if (n < 1000) {
105    return String(n)
106  }
107  const [value, unit] = n < 1_000_000 ? [n / 1000, 'k'] : [n / 1_000_000, 'M']
108
109  return `${value.toFixed(1).replace(/\.0$/, '')}${unit}`
110}
111
112export function fmtDuration(ms: number): string {
113  const s = Math.max(0, Math.floor(ms / 1000))
114  if (s < 60) {
115    return `${s}s`
116  }
117  const m = Math.floor(s / 60)
118  if (m < 60) {
119    return `${m}m`
120  }
121
122  return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m`
123}
124
125export function fmtClock(ms: number): string {
126  const date = new Date(ms)
127
128  return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
129}
130
131// 'dim' is normal, 'warn' needs attention (yellow), 'hot' is the cold cache (red).
132export type Segment = { text: string; tone: 'dim' | 'warn' | 'hot' }
133
134export function segments(meter: CacheMeter, now: number, maxKeeps = 0): Segment[] {
135  if (meter.readTokens + meter.writeTokens === 0) {
136    return [{ text: '—', tone: 'dim' }]
137  }
138  const parts: Segment[] = []
139  if (meter.ttl === null) {
140    parts.push({ text: 'warm', tone: 'dim' })
141  } else {
142    const expires = meter.lastAt + TTL_MS[meter.ttl]
143    const left = expires - now
144    parts.push(
145      left > 0
146        ? {
147            text: `warm ${meter.ttl} [${fmtClock(expires)}] ${fmtDuration(left)} left`,
148            tone: left < WARN_MS[meter.ttl] ? 'warn' : 'dim',
149          }
150        : { text: `cold, next +${fmtTokens(meter.nextTokens)}`, tone: 'hot' },
151    )
152  }
153  if (meter.keeps > 0) {
154    parts.push({ text: `kept ${meter.keeps}/${maxKeeps}`, tone: 'dim' })
155  }
156  parts.push({ text: `hit ${Math.round((meter.readTokens / meter.inputTokens) * 100)}%`, tone: 'dim' })
157  if (meter.misses > 0) {
158    const [first, ...rest] = meter.lastCauses
159    const cause = rest.length > 0 ? `${first} +${rest.length}` : first
160    parts.push({ text: `miss ${meter.misses} +${fmtTokens(meter.missTokens)} (${cause})`, tone: 'warn' })
161  }
162
163  return parts
164}
165
types/index.d.ts 69 lines
1export type CacheTtl = '5m' | '1h'
2
3// Prompt cache figures of the main thread, summed over its requests.
4export type CacheMeter = {
5  sessionId: string
6  requests: number
7  // Every input token sent: uncached, cache-written and cache-read together.
8  inputTokens: number
9  readTokens: number
10  writeTokens: number
11  // When the latest response arrived; the cache entry lives `ttl` from then.
12  lastAt: number
13  lastModel: string
14  // Input tokens of the latest request: what the next one should read from cache.
15  lastInput: number
16  // Input + output of the latest request: what a cold cache re-writes next.
17  nextTokens: number
18  toolsKey: string
19  // Read off the transcript; null until a cache write shows which one is used.
20  ttl: CacheTtl | null
21  ttlCheckedAt: number
22  misses: number
23  missTokens: number
24  // Guessed causes of the latest miss, most likely first.
25  lastCauses: string[]
26  // Set by a compaction: its rebuild is expected, not a miss.
27  expectRebuild: boolean
28  // Forks sent to keep the cache warm since the latest request of the main thread.
29  keeps: number
30}
31
32// Why a handoff runs: `/session-keeper handoff`, the new-task gate, or a cold cache under a large context.
33export type HandoffKind = 'manual' | 'new-task' | 'away'
34
35// An offer to hand off, drawn above the prompt with its two buttons.
36export type Offer = {
37  kind: 'new-task' | 'away'
38  // The prompt the offer is about: held back from the session when `isHeld`, else already sent.
39  prompt: string
40  isHeld: boolean
41  context: number
42  at: number
43}
44
45// A handoff under way; prompts typed meanwhile are held and go into the new session's draft.
46export type Handing = { kind: HandoffKind; startedAt: number; held: string[] }
47
48// A handoff summary written by the last keep, while the cache was still warm. Valid while the
49// main thread has made no request since (`lastAt` still the meter's).
50export type AwaySummary = { sessionId: string; lastAt: number; text: string }
51
52// After the person kept working here, no new-task offer until the context passes `tokens`.
53export type GateQuiet = { sessionId: string; tokens: number }
54
55declare module 'claude-code' {
56  interface PluginState {
57    'session-keeper': {
58      meter: CacheMeter
59      now: number
60      offer: Offer | null
61      handing: Handing | null
62      away: AwaySummary | null
63      quiet: GateQuiet
64      // When each feature switched on by `/session-keeper on` goes back.
65      onUntil: Record<string, number>
66    }
67  }
68}
69