SLOPSHOPPER

auto-handover

When context usage reaches your threshold, writes a handover note over the cached conversation prefix, compacts automatically, and hands the note to the…

newbandcommandtoastpromptmodel
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · auto-handover
› 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 › /handover ⎿ auto-handover: auto-handover: threshold 75%, context now 49% ⎿ auto-handover: Notes folder: ~/.claude/handovers ⎿ auto-handover: Status: usage 49% is below the 75% threshold ⎿ auto-handover: Last handover: none in this session ⎿ auto-handover: 0 handover(s) this session. /handover now to hand over now, /handover show for the latest note, /handover off ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

auto-handover

When context usage reaches a threshold you set, write a handover note first, then compact automatically, and hand the note to whoever continues: this conversation after compaction, and the next session in the same project.

Claude Code's own auto-compact acts close to the limit, and what the summary keeps is up to it. This mod lets you lower the threshold and, before compacting, asks the model to write a handover note with fixed headings over the whole conversation. The note is saved to disk, handed to the summarizer as instructions, and appended back into the conversation once compaction is done.

⟲ context 72% / threshold 75% · last handover 12m ago · ~/.claude/handovers/Users-me-code-shop/latest.md

The line sits in its own rounded frame, stacked with the other mods' frames above the prompt (band_style). While a handover runs, the line and its frame turn yellow:

⟲ Handing over: asking the model for a note…
⟲ Handing over: note written, compacting…

When it is done, a toast: ⟲ Handed over and compacted: 76% → 21%, note ~/.claude/handovers/Users-me-code-shop/latest.md

Usage

It works as soon as it is installed. To change the threshold, folder or language, use the settings page under /plugin.

CommandWhat it does
/handoverStatus: threshold, current usage, whether the next turn will hand over, when and where the last handover was
/handover nowHands over and compacts about 1.5 s later, ignoring the threshold and cooldown (waits for a running turn to end)
/handover showPrints the latest handover note
/handover off / onPauses or resumes automatic handovers; /handover now still works while paused

When it acts

  1. Every usage reading (session.measure) is checked against the threshold.
  2. Once over it, the mod arms, but acts only between turns: a running turn is never interrupted; 1.5 s after the turn ends it starts.
  3. Write the note → save it → compact (the note goes to the summarizer as instructions) → append the note to the conversation as a message the model reads and you do not see → toast.
  4. A cooldown follows (default 10 minutes), so a still-high reading right after compaction does not trigger again.

Manual /compact and the engine's own auto-compact also pass through this mod: a note is written first, merged into the summary instructions, and appended afterwards. Whoever starts the compaction, a readable handover remains.

Settings

FieldDefaultMeaning
threshold75Trigger threshold (%). Clamped to 40..95
handover_dir~/.claude/handoversNotes folder; must be ~/… or an absolute path under your home directory, otherwise the mod disables itself and says why in the band
cooldown_minutes10Minimum gap between two automatic handovers
resume_hours24A new session in the same project gets latest.md as opening context when it is younger than this; 0 disables it
inject_after_compacttrueAppend the note to the conversation after compaction
languageautoLanguage of the UI and of the note: auto reads LC_ALL, then LC_MESSAGES, then LANG (zh* → Traditional Chinese, ja* → Japanese, anything else → English); or en, zh-TW, ja
band_styleboxHow the band line is framed: box (its own rounded frame, dim normally and yellow while a handover runs or something is wrong), rule (a thin line beneath it), plain (text only)

The language applies to the band, toasts, /handover output, the note's headings, the prompt that asks for the note, the compaction instructions and the framing of the appended and resumed notes. The note's body is written in the language the conversation itself uses.

The handover note

Where it goes

<handover_dir>/<project root path with / replaced by ->/
  2026-10-04T13-05-22Z.md   ← one per handover
  latest.md                 ← always the newest

For a project at /Users/me/code/shop the notes are in ~/.claude/handovers/Users-me-code-shop/.

Format

---
project: /Users/me/code/shop
session: 2f1c…
time: 2026-10-04T13:05:22.000Z
context_percent: 76
model: claude-…
trigger: threshold        ← threshold | command | manual | auto
language: en
written_by: auto-handover
---

# Handover note
## Goal
## Current state
## Done
## In progress
## To do (by priority)
## Key decisions and why
## Important files and locations
## Caveats / pitfalls
## Next step (first thing)

The headings are fixed (in the chosen language); the model writes "none" under an empty one. About 600 words at most, body in the conversation's language. The front block keys stay English so other tools can read them.

Who gets it

  • This conversation after compaction: the full note is merged into the summarizer's instructions (keep goal, to-do, decisions, files and next step verbatim), and after compaction it is appended as a meta message the model continues from.
  • The next session: a new session in the same project gets latest.md as an opening context block (named handover) when it is younger than resume_hours, framed as left by the previous session. A session that handed over itself does not get it again.

Safety boundary

  • Writes only under handover_dir; the folder must be under your home directory and contain no ... An invalid value disables the mod and shows why; there is no fallback folder. An unknown home directory (HOME unset) disables it too.
  • Reads only latest.md in that folder (for a new session); files over 256 KB are not read.
  • No network, no shell, no other sessions touched.
  • Two kinds of model calls only: $.model.fork to write the note, and compaction's own summary request.

What it does before you install it

claude plugin validate ./plugins/auto-handover

Result (v0.1.0):

hooks: session.start, session.measure, turn.start, turn.complete, session.compact,
       prompt.context, session.end, command.run{command=handover},
       ui.render{component=AbovePrompt}
calls: $.clock.after, $.clock.now, $.command.register, $.env.get, $.fs.read, $.fs.stat,
       $.fs.write, $.model.fork, $.session.append, $.session.compact, $.session.id,
       $.session.model, $.session.root, $.session.usage, $.state.get, $.state.set,
       $.ui.invalidate, $.ui.log, $.ui.resolve, $.ui.toast
env reads: HOME, LANG, LC_ALL, LC_MESSAGES
env writes: nothing
  • $.model.fork: writes the handover note, once per handover
  • $.session.compact: compacts when the threshold is reached
  • $.session.append: appends the note to the compacted conversation
  • $.fs.write: only the two files under handover_dir; $.fs.read / $.fs.stat: only latest.md in the same folder
  • $.env.get: HOME for the folder, LC_ALL / LC_MESSAGES / LANG for the language

Cost

The note is written with $.model.fork: the request is appended to the current conversation, so the whole conversation prefix is served from the prompt cache. One handover costs roughly:

  • a cache read of the whole conversation (about a tenth of the regular input price on a hit),
  • plus the note itself (about 600 words of output),
  • plus the summary request compaction makes anyway.

When the cache has lapsed (an idle hour, a model change) the prefix is billed once more. The debug log (claude --debug) records cache_read_input_tokens for every note.

Limits

  • Acts only between turns; a very long turn is not compacted midway (the engine's own auto-compact still takes over at its threshold, and that one gets a note too).
  • claude -p and SDK sessions never hand over automatically (nobody watches the band, and the mod should not decide when they compact); the session.compact hook path still writes a note.
  • A failed note (API error, empty reply) shows a toast and starts one cooldown; it never blocks compaction, which the engine does as usual.
  • The usage percentage is the engine's own reading (the status line's number), updated after each model response.

Development

claude --plugin-dir ./plugins/auto-handover   # load once
claude plugin test ./plugins/auto-handover     # run the tests

tsconfig.json relies on .claude-plugin/types/, which Claude Code writes when it loads the mod; it is not committed.

The test kit has no implementation of session.append, so the plugin's $.session.append gets no implementation under test; the tests count injection attempts from the debug log, and only a real session appends the note.

Source 4 files
hooks/register.tsx 556 lines
1// auto-handover: when context usage reaches the threshold, write a handover note first, then compact,
2// and hand the note to whoever continues.
3//
4// - Usage comes from session.measure; the mod acts only between turns ($.session.compact refuses mid-turn).
5// - The note is written by $.model.fork: the conversation prefix is served from the prompt cache, so it is cheap;
6//   it is saved under handover_dir, which must be under the home directory.
7// - The note goes to the compaction summarizer as instructions, and after compaction it is appended to the
8//   conversation as a meta message (can be turned off).
9// - A new session in the same project gets the latest note as opening context (age limit configurable).
10// - Manual /compact and the engine's own auto-compact also get a note first (session.compact hook).
11// - No network, no shell; files are written only under handover_dir.
12// - Language: the `language` option, or the LC_ALL / LC_MESSAGES / LANG environment when `auto`.
13
14import { atom, read, update } from 'claude-code'
15import type { EngineInterface, Register, Timer } from 'claude-code'
16
17import type { HandoverRecord, HandoverTrigger } from '../types'
18import {
19  MAX_NOTE_BYTES,
20  RUN_DELAY_MS,
21  TOAST_MS,
22  bandLine,
23  compactInstructions,
24  expandDir,
25  gateReason,
26  handoverPrompt,
27  injectedMessage,
28  isOverThreshold,
29  notePaths,
30  readSettings,
31  renderNoteFile,
32  resolveLang,
33  resumeBlock,
34  statusText,
35  stripFrontMatter,
36  t,
37  tildify,
38  truncate,
39} from './logic'
40import type { BandStyle, Lang, Settings } from './logic'
41
42const lastPercent = atom({ plugin: 'auto-handover', key: 'lastPercent' } as const, null)
43const phase = atom({ plugin: 'auto-handover', key: 'phase' } as const, 'idle')
44const last = atom({ plugin: 'auto-handover', key: 'last' } as const, null)
45const count = atom({ plugin: 'auto-handover', key: 'count' } as const, 0)
46const isPaused = atom({ plugin: 'auto-handover', key: 'isPaused' } as const, false)
47const failure = atom({ plugin: 'auto-handover', key: 'failure' } as const, null)
48const langState = atom({ plugin: 'auto-handover', key: 'lang' } as const, 'en')
49
50// Module-level state: reset on hot reload.
51let settings: Settings = readSettings(undefined)
52let lang: Lang = 'en'
53let isInteractive = true
54let isTurnRunning = false
55let armed = false
56let inFlight = false
57/** A forced handover queued by /handover now: skips the threshold and cooldown next time. */
58let forceNext = false
59let pendingTimer: Timer | null = null
60/** After the model failed to write a note: no automatic retry before this time. */
61let retryAfter: number | null = null
62/** prompt.context cache: the same file at the same mtime is read once. */
63let resumeCache: { key: string; text: string } | null = null
64
65type $ = EngineInterface
66
67function toast($: $, text: string): void {
68  try {
69    $.ui.toast(text, { timeoutMs: TOAST_MS })
70  } catch {}
71}
72
73function debug($: $, text: string): void {
74  try {
75    $.ui.log(`auto-handover: ${text}`, { to: 'debug' })
76  } catch {}
77}
78
79async function home($: $): Promise<string | null> {
80  const h = await $.env.get('HOME')
81  return h && h.startsWith('/') ? h : null
82}
83
84type Located = { ok: true; home: string; dir: string } | { ok: false; error: string }
85
86/** Expands handover_dir and checks it lies under the home directory; an invalid value disables the mod with a reason, never a fallback folder. */
87async function locate($: $): Promise<Located> {
88  const h = await home($)
89  if (!h) return { ok: false, error: t(lang, 'home.unset') }
90  const expanded = expandDir(settings.dir, h, lang)
91  if (!expanded.ok) return { ok: false, error: expanded.error }
92  return { ok: true, home: h, dir: expanded.path }
93}
94
95async function percentNow($: $): Promise<number | null> {
96  try {
97    const { context } = await $.session.usage()
98    return typeof context.percent === 'number' ? context.percent : null
99  } catch {
100    return null
101  }
102}
103
104type Written = { ok: true; note: string; file: string; folder: string } | { ok: false; reason: string }
105
106/** Asks the model for the note and saves it. Failures come back as a reason, never as an exception. */
107async function writeNote($: $, trigger: HandoverTrigger, percent: number | null): Promise<Written> {
108  const loc = await locate($)
109  if (!loc.ok) {
110    await update($, failure, () => loc.error)
111    return { ok: false, reason: loc.error }
112  }
113  await update($, failure, () => null)
114  const r = await $.model.fork({ prompt: handoverPrompt(lang) })
115  if (!r.isAnswered) {
116    if (r.reason === 'nothing-to-fork') return { ok: false, reason: t(lang, 'fail.nothingToFork') }
117    const why =
118      r.reason === 'api-error'
119        ? t(lang, 'fail.apiError', { error: r.error, status: r.status !== null ? ` ${r.status}` : '' })
120        : r.reason === 'empty-reply'
121          ? t(lang, 'fail.emptyReply')
122          : t(lang, 'fail.aborted')
123    return { ok: false, reason: why }
124  }
125  debug($, `fork wrote the note: cache_read ${r.usage.cache_read_input_tokens}, output ${r.usage.output_tokens}`)
126  const now = await $.clock.now()
127  const root = await $.session.root()
128  const paths = notePaths(loc.dir, root, now)
129  const text = renderNoteFile(
130    { project: root, sessionId: await $.session.id(), nowMs: now, percent, model: await $.session.model(), trigger },
131    r.text,
132    lang,
133  )
134  try {
135    await $.fs.write(paths.stamped, text)
136    await $.fs.write(paths.latest, text)
137  } catch (err) {
138    return { ok: false, reason: t(lang, 'fail.write', { reason: truncate(String(err), 80) }) }
139  }
140  return { ok: true, note: r.text, file: paths.latest, folder: paths.folder }
141}
142
143async function inject($: $, note: string, file: string): Promise<void> {
144  if (!settings.injectAfterCompact) return
145  debug($, `appending the note to the conversation (${note.length} chars)`)
146  try {
147    const out = await $.session.append({ message: { type: 'user', content: [{ type: 'text', text: injectedMessage(note, file, lang) }] } })
148    if (out.deny !== undefined) debug($, `could not append the note: ${out.deny}`)
149  } catch (err) {
150    debug($, `could not append the note: ${truncate(String(err), 80)}`)
151  }
152}
153
154async function record($: $, rec: HandoverRecord): Promise<void> {
155  await update($, last, () => rec)
156  await update($, count, n => n + 1)
157  resumeCache = null
158  try {
159    $.ui.invalidate('prompt.context')
160  } catch {}
161}
162
163/**
164 * The handover itself: write the note → compact → append the note to the conversation.
165 * `force` is /handover now: it skips the threshold and cooldown, but still never acts mid-turn.
166 */
167async function runHandover($: $, trigger: HandoverTrigger, force = false): Promise<string> {
168  if (inFlight) return t(lang, 'gate.inFlight')
169  if (isTurnRunning) {
170    armed = true
171    if (force) forceNext = true
172    return t(lang, 'gate.turnRunning')
173  }
174  const now = await $.clock.now()
175  const percent = await percentNow($)
176  if (percent !== null) await update($, lastPercent, () => percent)
177  if (!force) {
178    if (retryAfter !== null && now < retryAfter) return 'backing off after a failed note'
179    const prev = await read($, last)
180    const why = gateReason(
181      {
182        percent,
183        threshold: settings.threshold,
184        isTurnRunning,
185        isPaused: await read($, isPaused),
186        inFlight,
187        lastHandoverAt: prev?.at ?? null,
188        cooldownMs: settings.cooldownMs,
189        now,
190      },
191      lang,
192    )
193    if (why !== null) {
194      armed = false
195      return why
196    }
197  }
198  inFlight = true
199  armed = false
200  await update($, phase, () => 'writing')
201  try {
202    const written = await writeNote($, trigger, percent)
203    if (!written.ok) {
204      retryAfter = now + settings.cooldownMs
205      toast($, t(lang, 'toast.failed', { reason: written.reason }))
206      return written.reason
207    }
208    await update($, phase, () => 'compacting')
209    let c
210    try {
211      c = await $.session.compact({ instructions: `${compactInstructions(lang)}\n\n${written.note}` })
212    } catch (err) {
213      // Most likely a turn started in between: the note is saved, so compact after the next turn ends.
214      armed = true
215      debug($, `compaction refused: ${truncate(String(err), 80)}`)
216      return t(lang, 'compactRejected', { file: written.file, reason: truncate(String(err), 60) })
217    }
218    if (c.skip !== undefined) {
219      toast($, t(lang, 'toast.compactSkipped', { reason: c.skip }))
220      await record($, { at: now, file: written.file, trigger, percent, percentAfter: null })
221      return `compaction skipped: ${c.skip}`
222    }
223    await inject($, written.note, written.file)
224    let percentAfter: number | null = null
225    if (typeof c.tokensAfter === 'number') {
226      try {
227        const { context } = await $.session.usage()
228        if (context.window > 0) percentAfter = Math.round((c.tokensAfter / context.window) * 100)
229      } catch {}
230    }
231    await record($, { at: now, file: written.file, trigger, percent, percentAfter })
232    const h = await home($)
233    toast(
234      $,
235      t(lang, 'toast.done', {
236        percent: percent ?? '?',
237        after: percentAfter !== null ? ` → ${percentAfter}%` : '',
238        file: h ? tildify(written.file, h) : written.file,
239      }),
240    )
241    return 'handed over and compacted'
242  } finally {
243    inFlight = false
244    await update($, phase, () => 'idle')
245  }
246}
247
248function schedule($: $): void {
249  if (!isInteractive && !forceNext) return
250  if (pendingTimer) pendingTimer.cancel()
251  pendingTimer = $.clock.after(RUN_DELAY_MS, () => {
252    pendingTimer = null
253    const force = forceNext
254    forceNext = false
255    void runHandover($, force ? 'command' : 'threshold', force).catch(err => debug($, `handover failed: ${truncate(String(err), 80)}`))
256  })
257}
258
259/** Over the threshold, not cooling down, not paused → arm; schedule right away while idle. */
260async function consider($: $, percent: number | null): Promise<void> {
261  if (!isOverThreshold(percent, settings.threshold)) {
262    armed = false
263    return
264  }
265  if (await read($, isPaused)) return
266  const now = await $.clock.now()
267  if (retryAfter !== null && now < retryAfter) return
268  const prev = await read($, last)
269  if (prev && now - prev.at < settings.cooldownMs) return
270  armed = true
271  if (!isTurnRunning && !inFlight) schedule($)
272}
273
274/** The latest note, when fresh enough, as a context block for a new session. */
275async function resumeText($: $): Promise<{ text: string; ageMs: number; file: string } | null> {
276  if (settings.resumeMs <= 0) return null
277  if ((await read($, count)) > 0) return null // this session handed over itself: the note is already in the conversation
278  const loc = await locate($)
279  if (!loc.ok) return null
280  const root = await $.session.root()
281  const now = await $.clock.now()
282  const { latest } = notePaths(loc.dir, root, now)
283  const st = await $.fs.stat(latest).catch(() => undefined)
284  if (!st || st.kind !== 'file' || st.size > MAX_NOTE_BYTES) return null
285  const ageMs = now - st.mtimeMs
286  if (ageMs > settings.resumeMs) return null
287  const key = `${latest}@${st.mtimeMs}`
288  if (!resumeCache || resumeCache.key !== key) {
289    const text = await $.fs.read(latest)
290    resumeCache = { key, text: typeof text === 'string' ? text : '' }
291  }
292  return { text: resumeCache.text, ageMs, file: latest }
293}
294
295async function onCommand($: $, args: string): Promise<{ text: string }> {
296  const arg = args.trim()
297  const now = await $.clock.now()
298  const h = await home($)
299  if (arg === 'now') {
300    // $.session.compact cannot run inside a command.run hook (it would compact under the turn this hook holds): schedule it.
301    await update($, isPaused, () => false)
302    if (inFlight) return { text: t(lang, 'cmd.inFlight') }
303    forceNext = true
304    armed = true
305    if (isTurnRunning) return { text: t(lang, 'cmd.nowTurnRunning') }
306    schedule($)
307    return { text: t(lang, 'cmd.nowScheduled', { seconds: RUN_DELAY_MS / 1000 }) }
308  }
309  if (arg === 'off') {
310    await update($, isPaused, () => true)
311    armed = false
312    forceNext = false
313    if (pendingTimer) {
314      pendingTimer.cancel()
315      pendingTimer = null
316    }
317    return { text: t(lang, 'cmd.off') }
318  }
319  if (arg === 'on') {
320    await update($, isPaused, () => false)
321    await consider($, await read($, lastPercent))
322    return { text: t(lang, 'cmd.on') }
323  }
324  if (arg === 'show') {
325    const prev = await read($, last)
326    const loc = await locate($)
327    let file = prev?.file ?? null
328    if (!file && loc.ok) file = notePaths(loc.dir, await $.session.root(), now).latest
329    if (!file) return { text: t(lang, 'cmd.noNote') }
330    try {
331      const text = await $.fs.read(file)
332      return { text: `${h ? tildify(file, h) : file}\n\n${stripFrontMatter(typeof text === 'string' ? text : '')}` }
333    } catch {
334      return { text: t(lang, 'cmd.unreadable', { file }) }
335    }
336  }
337  if (arg !== '') return { text: t(lang, 'cmd.usage') }
338  const loc = await locate($)
339  const prev = await read($, last)
340  const percent = await read($, lastPercent)
341  return {
342    text: statusText(
343      {
344        percent,
345        threshold: settings.threshold,
346        phase: await read($, phase),
347        last: prev,
348        failure: await read($, failure),
349        isPaused: await read($, isPaused),
350        now,
351        home: h,
352        count: await read($, count),
353        dirDisplay: loc.ok ? (h ? tildify(loc.dir, h) : loc.dir) : settings.dir,
354        gate: gateReason(
355          {
356            percent,
357            threshold: settings.threshold,
358            isTurnRunning,
359            isPaused: await read($, isPaused),
360            inFlight,
361            lastHandoverAt: prev?.at ?? null,
362            cooldownMs: settings.cooldownMs,
363            now,
364          },
365          lang,
366        ),
367      },
368      lang,
369    ),
370  }
371}
372
373export const register: Register = (on, options) => {
374  settings = readSettings(options)
375  lang = resolveLang(settings.language, {})
376
377  on('session.start', async ($, e, next) => {
378    const out = await next(e)
379    isInteractive = e.isInteractive
380    lang = resolveLang(settings.language, {
381      LC_ALL: await $.env.get('LC_ALL'),
382      LC_MESSAGES: await $.env.get('LC_MESSAGES'),
383      LANG: await $.env.get('LANG'),
384    })
385    await update($, langState, () => lang)
386    await $.command.register({
387      name: 'handover',
388      description: 'Handover status; now hands over and compacts, show prints the latest note, off/on pauses or resumes automatic handovers',
389      argumentHint: '[now|show|off|on]',
390    })
391    const loc = await locate($)
392    await update($, failure, () => (loc.ok ? null : loc.error))
393    return out
394  })
395
396  on('session.measure', async ($, e, next) => {
397    if (e.changed.includes('context') && typeof e.context.percent === 'number') {
398      const p = e.context.percent
399      await update($, lastPercent, () => p)
400      await consider($, p)
401    }
402    return next(e)
403  })
404
405  on('turn.start', ($, e, next) => {
406    isTurnRunning = true
407    if (pendingTimer) {
408      pendingTimer.cancel()
409      pendingTimer = null
410    }
411    return next(e)
412  })
413
414  on('turn.complete', async ($, e, next) => {
415    const out = await next(e)
416    if (e.agentId !== undefined) return out
417    isTurnRunning = false
418    if (armed) schedule($)
419    else await consider($, await read($, lastPercent))
420    return out
421  })
422
423  // Manual /compact and the engine's own auto-compact: write a note first, hand it to the summarizer, append it afterwards.
424  on('session.compact', async ($, e, next) => {
425    if (e.agentId !== undefined || e.trigger === 'plugin' || e.trigger === 'precompute' || inFlight) return next(e)
426    if (await read($, isPaused)) return next(e)
427    inFlight = true
428    await update($, phase, () => 'writing')
429    let written: Written
430    try {
431      written = await writeNote($, e.trigger === 'manual' ? 'manual' : 'auto', await read($, lastPercent))
432    } catch (err) {
433      inFlight = false
434      await update($, phase, () => 'idle')
435      debug($, `could not write the note: ${truncate(String(err), 80)}`)
436      return next(e)
437    }
438    if (!written.ok) {
439      inFlight = false
440      await update($, phase, () => 'idle')
441      debug($, written.reason)
442      return next(e)
443    }
444    await update($, phase, () => 'compacting')
445    try {
446      const instructions = `${e.instructions ? `${e.instructions}\n\n` : ''}${compactInstructions(lang)}\n\n${written.note}`
447      const out = await next({ ...e, instructions })
448      if (out.skip === undefined) {
449        await inject($, written.note, written.file)
450        const now = await $.clock.now()
451        await record($, { at: now, file: written.file, trigger: e.trigger === 'manual' ? 'manual' : 'auto', percent: await read($, lastPercent), percentAfter: null })
452        const h = await home($)
453        toast($, t(lang, 'toast.noteWritten', { file: h ? tildify(written.file, h) : written.file }))
454      }
455      return out
456    } finally {
457      inFlight = false
458      await update($, phase, () => 'idle')
459    }
460  })
461
462  on('prompt.context', async ($, e, next) => {
463    const out = await next(e)
464    try {
465      const found = await resumeText($)
466      if (!found) return out
467      const h = await home($)
468      const text = resumeBlock(found.text, found.ageMs, h ? tildify(found.file, h) : found.file, lang)
469      return { ...out, blocks: [...out.blocks.filter(b => b.name !== 'handover'), { name: 'handover', text }] }
470    } catch (err) {
471      debug($, `could not read the handover note: ${truncate(String(err), 80)}`)
472      return out
473    }
474  })
475
476  on('session.end', ($, e, next) => {
477    if (e.reason === 'clear') {
478      isTurnRunning = false
479      armed = false
480      forceNext = false
481      retryAfter = null
482      resumeCache = null
483      if (pendingTimer) {
484        pendingTimer.cancel()
485        pendingTimer = null
486      }
487    }
488    return next(e)
489  })
490
491  on('command.run', { command: 'handover' }, ($, e) => onCommand($, e.args))
492
493  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
494    if (e.props.hasSurvey) return next(e)
495    const line = bandLine(
496      {
497        percent: await read($, lastPercent),
498        threshold: settings.threshold,
499        phase: await read($, phase),
500        last: await read($, last),
501        failure: await read($, failure),
502        isPaused: await read($, isPaused),
503        now: await $.clock.now(),
504        home: await home($),
505      },
506      await read($, langState),
507    )
508    if (line === null) return next(e)
509    const ui = $.ui.resolve(e)
510    const { Text } = ui
511    // AbovePrompt is a hook chain: draw our own line in its frame, then stack what the plugins below drew.
512    const below = await next(e)
513    return frameBand(
514      ui,
515      settings.bandStyle,
516      line.tone === 'warn',
517      e.props.bodyColumns,
518      <Text wrap="truncate-end" dimColor={line.tone === 'dim'} color={line.tone === 'warn' ? 'yellow' : undefined}>
519        {line.text}
520      </Text>,
521      below,
522    )
523  })
524}
525
526/**
527 * Frames this mod's band content per `band_style` and stacks the plugins beneath under it.
528 * `box`: a rounded frame (yellow when `isWarning`); `rule`: a dim line beneath, only when another
529 * plugin drew something below; `plain`: the bare text.
530 */
531function frameBand(ui: { Box: any; Text: any }, style: BandStyle, isWarning: boolean, bodyColumns: number | undefined, content: any, below: any) {
532  const { Box, Text } = ui
533  const hasBelow = below !== null && below !== undefined && (below as { type?: string }).type !== 'engine'
534  const own =
535    style === 'box' ? (
536      <Box key="frame" flexDirection="column" borderStyle="round" borderDimColor={isWarning ? undefined : true} borderColor={isWarning ? 'yellow' : undefined} paddingX={1}>
537        {content}
538      </Box>
539    ) : style === 'rule' ? (
540      <Box key="frame" flexDirection="column">
541        {content}
542        {hasBelow ? <Text key="rule" dimColor>{'─'.repeat(Math.max(8, Math.min(bodyColumns ?? 60, 200)))}</Text> : null}
543      </Box>
544    ) : (
545      <Box key="frame" flexDirection="column">
546        {content}
547      </Box>
548    )
549  return (
550    <Box flexDirection="column">
551      {own}
552      {below}
553    </Box>
554  )
555}
556
hooks/logic.ts 292 lines
1// auto-handover pure functions: settings clamping, folder and path rules, the trigger gate,
2// note text, band and status text. No `$` here; shared by register.tsx and the tests.
3// Every function that produces text takes the language as a parameter.
4
5import type { HandoverRecord, HandoverTrigger } from '../types'
6import { t } from './i18n'
7import type { Lang } from './i18n'
8
9export type { HandoverRecord, HandoverTrigger }
10export { resolveLang, t, LANGS, DEFAULT_LANG } from './i18n'
11export type { Lang } from './i18n'
12
13export const PLUGIN = 'auto-handover'
14export const DEFAULT_DIR = '~/.claude/handovers'
15export const DEFAULT_THRESHOLD = 75
16export const MIN_THRESHOLD = 40
17export const MAX_THRESHOLD = 95
18export const DEFAULT_COOLDOWN_MIN = 10
19export const DEFAULT_RESUME_HOURS = 24
20/** Wait this long after a turn ends before acting, so the engine can finish wrapping the turn up. */
21export const RUN_DELAY_MS = 1500
22export const TOAST_MS = 8000
23/** The band starts showing this many points below the threshold. */
24export const BAND_NEAR_POINTS = 10
25export const MAX_NOTE_BYTES = 256 * 1024
26
27// ── Settings ─────────────────────────────────────────────────────────────────
28
29export type Settings = {
30  threshold: number
31  dir: string
32  cooldownMs: number
33  resumeMs: number
34  injectAfterCompact: boolean
35  /** The `language` option as given (`auto` or a Lang); resolved at session.start. */
36  language: string
37  /** How the band line is framed (`band_style`). */
38  bandStyle: BandStyle
39}
40
41function num(v: unknown, fallback: number): number {
42  const n = typeof v === 'number' ? v : typeof v === 'string' && v.trim() !== '' ? Number(v) : NaN
43  return Number.isFinite(n) ? n : fallback
44}
45
46/** userConfig → settings; the threshold is clamped to 40..95, cooldown and resume window never negative. */
47export function readSettings(options: Readonly<Record<string, unknown>> | undefined): Settings {
48  const o = options ?? {}
49  const threshold = Math.min(MAX_THRESHOLD, Math.max(MIN_THRESHOLD, Math.round(num(o.threshold, DEFAULT_THRESHOLD))))
50  const dirRaw = typeof o.handover_dir === 'string' && o.handover_dir.trim() ? o.handover_dir : DEFAULT_DIR
51  const cooldownMin = Math.max(0, num(o.cooldown_minutes, DEFAULT_COOLDOWN_MIN))
52  const resumeHours = Math.max(0, num(o.resume_hours, DEFAULT_RESUME_HOURS))
53  const inject = o.inject_after_compact === undefined ? true : o.inject_after_compact === true || o.inject_after_compact === 'true'
54  const language = typeof o.language === 'string' && o.language.trim() ? o.language.trim() : 'auto'
55  return { threshold, dir: dirRaw, cooldownMs: cooldownMin * 60_000, resumeMs: resumeHours * 3_600_000, injectAfterCompact: inject, language, bandStyle: parseBandStyle(o.band_style) }
56}
57
58// ── Folder and paths ─────────────────────────────────────────────────────────
59// Notes are written to one folder under the home directory only: `~`, `~/…` or an absolute
60// path, which after expansion must lie under the home directory.
61
62export function expandDir(raw: string, home: string, lang: Lang = 'en'): { ok: true; path: string } | { ok: false; error: string } {
63  const text = raw.trim()
64  if (!text) return { ok: false, error: t(lang, 'dir.empty') }
65  if (text.includes('\0')) return { ok: false, error: t(lang, 'dir.badChars') }
66  let path: string
67  if (text === '~') path = home
68  else if (text.startsWith('~/')) path = `${home}/${text.slice(2)}`
69  else if (text.startsWith('/')) path = text
70  else return { ok: false, error: t(lang, 'dir.notAbsolute', { value: truncate(text, 60) }) }
71  path = path.replace(/\/+$/, '')
72  if (path.split('/').some(seg => seg === '..')) return { ok: false, error: t(lang, 'dir.dotdot') }
73  if (!isUnder(path, home)) return { ok: false, error: t(lang, 'dir.outsideHome', { path }) }
74  return { ok: true, path }
75}
76
77export function isUnder(child: string, parent: string): boolean {
78  const p = parent.replace(/\/+$/, '')
79  return child.startsWith(`${p}/`)
80}
81
82/** Abbreviates the home directory to ~, for display. */
83export function tildify(path: string, home: string): string {
84  if (path === home) return '~'
85  return isUnder(path, home) ? `~${path.slice(home.replace(/\/+$/, '').length)}` : path
86}
87
88/** Project root → folder name: `/Users/me/code/foo` → `Users-me-code-foo`. */
89export function projectSlug(root: string): string {
90  const slug = root
91    .replace(/\\/g, '/')
92    .split('/')
93    .filter(Boolean)
94    .join('-')
95    .replace(/[^A-Za-z0-9._-]/g, '-')
96    .replace(/^[-.]+/, '')
97  return slug || 'root'
98}
99
100/** Timestamped file name: ISO without colons or milliseconds, `2026-10-04T13-05-22Z.md`. */
101export function stampName(nowMs: number): string {
102  return `${new Date(nowMs).toISOString().replace(/\.\d{3}Z$/, 'Z').replace(/:/g, '-')}.md`
103}
104
105export function notePaths(dir: string, root: string, nowMs: number): { folder: string; stamped: string; latest: string } {
106  const folder = `${dir}/${projectSlug(root)}`
107  return { folder, stamped: `${folder}/${stampName(nowMs)}`, latest: `${folder}/latest.md` }
108}
109
110// ── Trigger gate ─────────────────────────────────────────────────────────────
111
112export type GateFacts = {
113  percent: number | null
114  threshold: number
115  isTurnRunning: boolean
116  isPaused: boolean
117  inFlight: boolean
118  lastHandoverAt: number | null
119  cooldownMs: number
120  now: number
121}
122
123/** May we hand over automatically now? null = yes, otherwise the reason (shown by /handover and tested). */
124export function gateReason(f: GateFacts, lang: Lang = 'en'): string | null {
125  if (f.isPaused) return t(lang, 'gate.paused')
126  if (f.inFlight) return t(lang, 'gate.inFlight')
127  if (f.isTurnRunning) return t(lang, 'gate.turnRunning')
128  if (f.percent === null) return t(lang, 'gate.noReading')
129  if (f.percent < f.threshold) return t(lang, 'gate.below', { percent: f.percent, threshold: f.threshold })
130  if (f.lastHandoverAt !== null && f.now - f.lastHandoverAt < f.cooldownMs) {
131    return t(lang, 'gate.cooldown', { ago: duration(f.now - f.lastHandoverAt), minutes: Math.round(f.cooldownMs / 60_000) })
132  }
133  return null
134}
135
136/** Is the threshold reached, whatever else is true? session.measure uses it to arm. */
137export function isOverThreshold(percent: number | null | undefined, threshold: number): boolean {
138  return typeof percent === 'number' && percent >= threshold
139}
140
141// ── Note text ────────────────────────────────────────────────────────────────
142
143const HEADING_KEYS = ['note.title', 'note.h1', 'note.h2', 'note.h3', 'note.h4', 'note.h5', 'note.h6', 'note.h7', 'note.h8', 'note.h9'] as const
144
145/** The note's headings, title first, in the chosen language. */
146export function noteHeadings(lang: Lang): string[] {
147  return HEADING_KEYS.map(k => t(lang, k))
148}
149
150/** The prompt asking the model for a handover note (forked onto the conversation's tail, so it sees everything). */
151export function handoverPrompt(lang: Lang): string {
152  return t(lang, 'prompt.handover', { headings: noteHeadings(lang).join('\n') })
153}
154
155/** The instruction handed to the compaction summarizer: keep the note's facts. */
156export function compactInstructions(lang: Lang): string {
157  return t(lang, 'prompt.compact')
158}
159
160export type NoteMeta = {
161  project: string
162  sessionId: string
163  nowMs: number
164  percent: number | null
165  model: string
166  trigger: HandoverTrigger
167}
168
169/** File content: a YAML front block (machine-readable keys) followed by the note. */
170export function renderNoteFile(meta: NoteMeta, note: string, lang: Lang = 'en'): string {
171  const lines = [
172    '---',
173    `project: ${meta.project}`,
174    `session: ${meta.sessionId}`,
175    `time: ${new Date(meta.nowMs).toISOString()}`,
176    `context_percent: ${meta.percent === null ? 'unknown' : meta.percent}`,
177    `model: ${meta.model}`,
178    `trigger: ${meta.trigger}`,
179    `language: ${lang}`,
180    `written_by: ${PLUGIN}`,
181    '---',
182    '',
183  ]
184  return `${lines.join('\n')}${ensureHeading(note, lang).trim()}\n`
185}
186
187/** The model sometimes drops the title line; put it back. */
188export function ensureHeading(note: string, lang: Lang = 'en'): string {
189  const s = note.trim()
190  return s.startsWith('# ') ? s : `${t(lang, 'note.title')}\n\n${s}`
191}
192
193/** Removes the front block, leaving the note body (for /handover show and injection). */
194export function stripFrontMatter(text: string): string {
195  const m = /^---\n[\s\S]*?\n---\n\n?/.exec(text)
196  return m ? text.slice(m[0].length) : text
197}
198
199/** The message appended to the conversation after compaction. */
200export function injectedMessage(note: string, file: string, lang: Lang = 'en'): string {
201  return [t(lang, 'inject.framing'), '', ensureHeading(note, lang).trim(), '', t(lang, 'inject.file', { file })].join('\n')
202}
203
204/** The context block a new session starts with. */
205export function resumeBlock(noteText: string, ageMs: number, file: string, lang: Lang = 'en'): string {
206  return [t(lang, 'resume.framing', { ago: duration(ageMs), file }), '', stripFrontMatter(noteText).trim()].join('\n')
207}
208
209// ── Time and text ────────────────────────────────────────────────────────────
210
211export function duration(ms: number): string {
212  const s = Math.max(0, Math.floor(ms / 1000))
213  if (s < 60) return `${s}s`
214  if (s < 3600) return `${Math.floor(s / 60)}m`
215  const h = Math.floor(s / 3600)
216  if (h < 48) return `${h}h${Math.floor((s % 3600) / 60)}m`
217  return `${Math.floor(h / 24)}d`
218}
219
220export function truncate(s: string, max: number): string {
221  const one = s.replace(/\s+/g, ' ').trim()
222  return one.length <= max ? one : `${one.slice(0, max - 1)}…`
223}
224
225// ── Band and status text ─────────────────────────────────────────────────────
226
227export type BandFacts = {
228  percent: number | null
229  threshold: number
230  phase: 'idle' | 'writing' | 'compacting'
231  last: HandoverRecord | null
232  failure: string | null
233  isPaused: boolean
234  now: number
235  home: string | null
236}
237
238export type BandLine = { text: string; tone: 'dim' | 'warn' }
239
240/** One band line; null = take no row (far from the threshold, never handed over, no failure). */
241export function bandLine(f: BandFacts, lang: Lang = 'en'): BandLine | null {
242  if (f.failure !== null) return { text: t(lang, 'band.failure', { reason: f.failure }), tone: 'warn' }
243  if (f.phase === 'writing') return { text: t(lang, 'band.writing'), tone: 'warn' }
244  if (f.phase === 'compacting') return { text: t(lang, 'band.compacting'), tone: 'warn' }
245  const near = f.percent !== null && f.percent >= f.threshold - BAND_NEAR_POINTS
246  if (!near && f.last === null) return null
247  const parts = [t(lang, 'band.context', { percent: f.percent === null ? '?' : `${f.percent}%`, threshold: f.threshold })]
248  if (f.isPaused) parts.push(t(lang, 'band.paused'))
249  if (f.last) {
250    parts.push(t(lang, 'band.last', { ago: duration(f.now - f.last.at) }))
251    parts.push(f.home ? tildify(f.last.file, f.home) : f.last.file)
252  }
253  return { text: parts.join(' · '), tone: 'dim' }
254}
255
256export function statusText(f: BandFacts & { gate: string | null; dirDisplay: string; count: number }, lang: Lang = 'en'): string {
257  const lines = [
258    t(lang, 'status.head', {
259      threshold: f.threshold,
260      percent: f.percent === null ? t(lang, 'status.noReading') : `${f.percent}%`,
261      paused: f.isPaused ? t(lang, 'status.pausedSuffix') : '',
262    }),
263    t(lang, 'status.dir', { dir: f.dirDisplay }),
264  ]
265  if (f.failure) lines.push(t(lang, 'status.failure', { reason: f.failure }))
266  lines.push(f.gate === null ? t(lang, 'status.ready') : t(lang, 'status.gate', { reason: f.gate }))
267  lines.push(
268    f.last
269      ? t(lang, 'status.last', {
270          ago: duration(f.now - f.last.at),
271          trigger: f.last.trigger,
272          percent: f.last.percent ?? '?',
273          after: f.last.percentAfter !== null ? ` → ${f.last.percentAfter}%` : '',
274          file: f.home ? tildify(f.last.file, f.home) : f.last.file,
275        })
276      : t(lang, 'status.lastNone'),
277  )
278  lines.push(t(lang, 'status.count', { count: f.count }))
279  return lines.join('\n')
280}
281
282
283// ── Band framing ───────────────────────────────────────────────────────────
284
285/** How the mod's line above the prompt is framed: a rounded box, a thin rule beneath, or bare text. */
286export type BandStyle = 'box' | 'rule' | 'plain'
287
288/** The `band_style` option; anything but `rule` or `plain` is the default box. */
289export function parseBandStyle(v: unknown): BandStyle {
290  return v === 'rule' || v === 'plain' ? v : 'box'
291}
292
hooks/i18n.ts 287 lines
1// auto-handover i18n: the language choice and every user- or model-facing string.
2// Pure: no `$`. Shared by logic.ts, register.tsx and the tests.
3
4export type Lang = 'en' | 'zh-TW' | 'ja'
5export const LANGS: readonly Lang[] = ['en', 'zh-TW', 'ja']
6export const DEFAULT_LANG: Lang = 'en'
7
8export type LangEnv = { LC_ALL?: string; LC_MESSAGES?: string; LANG?: string }
9
10/** Picks the language: an explicit option wins; `auto` (or anything else) reads LC_ALL, then LC_MESSAGES, then LANG. */
11export function resolveLang(option: unknown, env: LangEnv): Lang {
12  if (option === 'en' || option === 'zh-TW' || option === 'ja') return option
13  for (const raw of [env.LC_ALL, env.LC_MESSAGES, env.LANG]) {
14    const v = (raw ?? '').trim()
15    if (!v) continue
16    if (v === 'C' || v === 'POSIX') return 'en'
17    const lower = v.toLowerCase()
18    if (lower.startsWith('zh')) return 'zh-TW'
19    if (lower.startsWith('ja')) return 'ja'
20    return 'en'
21  }
22  return DEFAULT_LANG
23}
24
25type Params = Record<string, string | number>
26type Msg = string | ((p: Params) => string)
27
28const EN = {
29  // band
30  'band.failure': (p: Params) => `⟲ auto-handover: ${p.reason}`,
31  'band.writing': '⟲ Handing over: asking the model for a note…',
32  'band.compacting': '⟲ Handing over: note written, compacting…',
33  'band.context': (p: Params) => `⟲ context ${p.percent} / threshold ${p.threshold}%`,
34  'band.paused': 'paused',
35  'band.last': (p: Params) => `last handover ${p.ago} ago`,
36  // /handover status
37  'status.head': (p: Params) => `auto-handover: threshold ${p.threshold}%, context now ${p.percent}${p.paused}`,
38  'status.noReading': 'no reading yet',
39  'status.pausedSuffix': ' (paused)',
40  'status.dir': (p: Params) => `Notes folder: ${p.dir}`,
41  'status.failure': (p: Params) => `Disabled: ${p.reason}`,
42  'status.ready': 'Status: conditions met, handing over after the next turn ends',
43  'status.gate': (p: Params) => `Status: ${p.reason}`,
44  'status.last': (p: Params) => `Last handover: ${p.ago} ago (${p.trigger}, ${p.percent}%${p.after}), ${p.file}`,
45  'status.lastNone': 'Last handover: none in this session',
46  'status.count': (p: Params) => `${p.count} handover(s) this session. /handover now to hand over now, /handover show for the latest note, /handover off to pause.`,
47  // gate reasons
48  'gate.paused': 'paused with /handover off',
49  'gate.inFlight': 'a handover is in progress',
50  'gate.turnRunning': 'a turn is running; waiting for it to end',
51  'gate.noReading': 'no usage reading yet',
52  'gate.below': (p: Params) => `usage ${p.percent}% is below the ${p.threshold}% threshold`,
53  'gate.cooldown': (p: Params) => `cooling down (last handover ${p.ago} ago, cooldown ${p.minutes} min)`,
54  // handover_dir checks
55  'dir.empty': 'handover_dir is empty',
56  'dir.badChars': 'handover_dir contains an invalid character',
57  'dir.notAbsolute': (p: Params) => `handover_dir must be ~/… or an absolute path (got ${p.value})`,
58  'dir.dotdot': 'handover_dir must not contain ..',
59  'dir.outsideHome': (p: Params) => `handover_dir ${p.path} is outside your home directory; writing nothing`,
60  'home.unset': 'cannot find your home directory (HOME is unset); writing nothing',
61  // commands
62  'cmd.inFlight': 'auto-handover: a handover is in progress.',
63  'cmd.nowTurnRunning': 'auto-handover: a turn is running; handing over and compacting once it ends.',
64  'cmd.nowScheduled': (p: Params) => `auto-handover: handing over in about ${p.seconds} s (write note → compact → append note to the conversation).`,
65  'cmd.off': 'auto-handover paused: no automatic handovers (/handover now still works). /handover on to resume.',
66  'cmd.on': 'auto-handover resumed.',
67  'cmd.noNote': 'auto-handover: no handover note yet.',
68  'cmd.unreadable': (p: Params) => `auto-handover: cannot read ${p.file}`,
69  'cmd.usage': 'Usage: /handover (status), /handover now (hand over now), /handover show (latest note), /handover off|on',
70  // toasts
71  'toast.failed': (p: Params) => `⟲ auto-handover: ${p.reason}`,
72  'toast.compactSkipped': (p: Params) => `⟲ Note written, but compaction was skipped: ${p.reason}`,
73  'toast.done': (p: Params) => `⟲ Handed over and compacted: ${p.percent}%${p.after}, note ${p.file}`,
74  'toast.noteWritten': (p: Params) => `⟲ Handover note written: ${p.file}`,
75  // failure reasons
76  'fail.nothingToFork': 'nothing to hand over yet',
77  'fail.apiError': (p: Params) => `could not write the note: API error (${p.error}${p.status})`,
78  'fail.emptyReply': 'could not write the note: the model returned no text',
79  'fail.aborted': 'could not write the note: the request was interrupted',
80  'fail.write': (p: Params) => `could not save the note: ${p.reason}`,
81  'compactRejected': (p: Params) => `Note saved to ${p.file}, but compaction was refused (${p.reason}); retrying after the next turn ends`,
82  // note headings (model-facing)
83  'note.title': '# Handover note',
84  'note.h1': '## Goal',
85  'note.h2': '## Current state',
86  'note.h3': '## Done',
87  'note.h4': '## In progress',
88  'note.h5': '## To do (by priority)',
89  'note.h6': '## Key decisions and why',
90  'note.h7': '## Important files and locations',
91  'note.h8': '## Caveats / pitfalls',
92  'note.h9': '## Next step (first thing)',
93  // prompts (model-facing)
94  'prompt.handover': (p: Params) =>
95    [
96      'Stop what you are doing and write a handover note for the next agent. They cannot see this conversation, only what you write, so make it enough to continue the work directly.',
97      '',
98      'Format: Markdown with exactly these headings, in this order, every one present (write "none" under a heading with nothing to say):',
99      String(p.headings),
100      '',
101      'Rules:',
102      '- Facts and decisions only; no pleasantries, do not restate this request.',
103      '- Give full paths or names for files and functions; put commands in backticks.',
104      '- "Next step" is one action, concrete enough to run as is.',
105      '- At most 600 words in total.',
106      '- Write the body in the language this conversation mostly uses; keep the headings as given.',
107      '- Output the note only, with no preface or closing.',
108    ].join('\n'),
109  'prompt.compact':
110    'Below is the handover note this session just wrote. When summarizing, keep its goal, current state, to-do list, key decisions, important files and next step verbatim (quoting is fine); do not drop or rewrite any path, name or number in it.',
111  'inject.framing':
112    '(auto-handover) This conversation wrote a handover note when context reached the threshold, then compacted. The full note follows; continue from it, and where it disagrees with the summary, the note wins.',
113  'inject.file': (p: Params) => `(file: ${p.file})`,
114  'resume.framing': (p: Params) =>
115    `The previous session left a handover note for this project ${p.ago} ago (written by auto-handover, file: ${p.file}). Read it before starting; if the user's request is unrelated to it, ignore it.`,
116} satisfies Record<string, Msg>
117
118export type Messages = { [K in keyof typeof EN]: Msg }
119export type MessageKey = keyof Messages
120
121const ZH_TW: Messages = {
122  'band.failure': p => `⟲ auto-handover:${p.reason}`,
123  'band.writing': '⟲ 交接中:請模型寫筆記…',
124  'band.compacting': '⟲ 交接中:筆記已寫好,compact…',
125  'band.context': p => `⟲ context ${p.percent} / 門檻 ${p.threshold}%`,
126  'band.paused': '已暫停',
127  'band.last': p => `上次交接 ${p.ago} 前`,
128  'status.head': p => `auto-handover:門檻 ${p.threshold}%,目前 context ${p.percent}${p.paused}`,
129  'status.noReading': '尚無讀數',
130  'status.pausedSuffix': '(已暫停)',
131  'status.dir': p => `筆記目錄:${p.dir}`,
132  'status.failure': p => `停用原因:${p.reason}`,
133  'status.ready': '狀態:條件已滿足,下一個 turn 結束就會交接',
134  'status.gate': p => `狀態:${p.reason}`,
135  'status.last': p => `上次交接:${p.ago} 前(${p.trigger},${p.percent}%${p.after}),${p.file}`,
136  'status.lastNone': '上次交接:本 session 還沒有',
137  'status.count': p => `本 session 交接 ${p.count} 次。/handover now 立即交接、/handover show 看最新筆記、/handover off 暫停。`,
138  'gate.paused': '已用 /handover off 暫停',
139  'gate.inFlight': '交接進行中',
140  'gate.turnRunning': '有 turn 在跑,等它結束',
141  'gate.noReading': '還沒有用量讀數',
142  'gate.below': p => `用量 ${p.percent}% 未達門檻 ${p.threshold}%`,
143  'gate.cooldown': p => `冷卻中(上次交接 ${p.ago} 前,冷卻 ${p.minutes} 分鐘)`,
144  'dir.empty': 'handover_dir 是空的',
145  'dir.badChars': 'handover_dir 含不合法字元',
146  'dir.notAbsolute': p => `handover_dir 須為 ~/… 或絕對路徑(收到 ${p.value})`,
147  'dir.dotdot': 'handover_dir 不能含 ..',
148  'dir.outsideHome': p => `handover_dir ${p.path} 不在家目錄底下,不寫任何檔案`,
149  'home.unset': '讀不到家目錄(HOME 未設定),不寫任何檔案',
150  'cmd.inFlight': 'auto-handover:交接進行中。',
151  'cmd.nowTurnRunning': 'auto-handover:有 turn 在跑,等它結束就交接並 compact。',
152  'cmd.nowScheduled': p => `auto-handover:約 ${p.seconds} 秒後開始交接(寫筆記 → compact → 筆記接回對話)。`,
153  'cmd.off': 'auto-handover 已暫停:不再自動交接(/handover now 仍可手動)。/handover on 恢復。',
154  'cmd.on': 'auto-handover 已恢復。',
155  'cmd.noNote': 'auto-handover:還沒有交接筆記。',
156  'cmd.unreadable': p => `auto-handover:讀不到 ${p.file}`,
157  'cmd.usage': '用法:/handover(狀態)、/handover now(立即交接)、/handover show(看最新筆記)、/handover off|on',
158  'toast.failed': p => `⟲ auto-handover:${p.reason}`,
159  'toast.compactSkipped': p => `⟲ 筆記已寫好,但 compact 被略過:${p.reason}`,
160  'toast.done': p => `⟲ 已交接並 compact:${p.percent}%${p.after},筆記 ${p.file}`,
161  'toast.noteWritten': p => `⟲ 已寫交接筆記:${p.file}`,
162  'fail.nothingToFork': '對話還沒有內容可交接',
163  'fail.apiError': p => `寫筆記失敗:API 錯誤(${p.error}${p.status})`,
164  'fail.emptyReply': '寫筆記失敗:模型沒有回文字',
165  'fail.aborted': '寫筆記失敗:請求被中斷',
166  'fail.write': p => `寫檔失敗:${p.reason}`,
167  'compactRejected': p => `筆記已寫到 ${p.file},但 compact 被拒(${p.reason}),下一個 turn 結束再試`,
168  'note.title': '# 交接筆記',
169  'note.h1': '## 目標',
170  'note.h2': '## 目前狀態',
171  'note.h3': '## 已完成',
172  'note.h4': '## 進行中',
173  'note.h5': '## 待辦(依優先順序)',
174  'note.h6': '## 關鍵決策與原因',
175  'note.h7': '## 重要檔案與位置',
176  'note.h8': '## 注意事項 / 陷阱',
177  'note.h9': '## 下一步(第一件事)',
178  'prompt.handover': p =>
179    [
180      '請停下手邊的工作,為接手的下一位 agent 寫一份交接筆記。對方看不到這段對話,只會看到你寫的內容,所以要寫得能直接接手。',
181      '',
182      '格式:Markdown,依序使用下列標題,每個標題都要有(沒有內容就寫「無」):',
183      String(p.headings),
184      '',
185      '要求:',
186      '- 只寫事實與決策,不要客套、不要重述這個指令。',
187      '- 檔案與函式請寫完整路徑或名稱;指令請用反引號。',
188      '- 「下一步」只寫一件事,具體到可以直接執行。',
189      '- 全文不超過 600 字(或 600 words)。',
190      '- 內文使用這段對話主要使用的語言;標題照上面給的寫。',
191      '- 只輸出筆記本身,不要加前言或結語。',
192    ].join('\n'),
193  'prompt.compact':
194    '以下是這個 session 剛寫好的交接筆記。摘要時請把筆記裡的目標、目前狀態、待辦、關鍵決策、重要檔案與下一步原封不動地保留下來(可以直接引用),不要省略或改寫其中的路徑、名稱與數字。',
195  'inject.framing': '(auto-handover)這個對話剛才在 context 達到門檻時先寫了交接筆記再 compact。以下是筆記全文,請以它為準接續工作;若與摘要不一致,以筆記為準。',
196  'inject.file': p => `(檔案:${p.file})`,
197  'resume.framing': p => `上一個 session 在 ${p.ago} 前為這個專案留下了交接筆記(auto-handover 寫的,檔案:${p.file})。先讀它再開始;若使用者的要求與筆記無關,忽略即可。`,
198}
199
200const JA: Messages = {
201  'band.failure': p => `⟲ auto-handover:${p.reason}`,
202  'band.writing': '⟲ 引き継ぎ中:モデルにメモを書かせています…',
203  'band.compacting': '⟲ 引き継ぎ中:メモ作成済み、compact 中…',
204  'band.context': p => `⟲ context ${p.percent} / しきい値 ${p.threshold}%`,
205  'band.paused': '一時停止中',
206  'band.last': p => `前回の引き継ぎ ${p.ago} 前`,
207  'status.head': p => `auto-handover:しきい値 ${p.threshold}%、現在の context ${p.percent}${p.paused}`,
208  'status.noReading': '計測値なし',
209  'status.pausedSuffix': '(一時停止中)',
210  'status.dir': p => `メモの保存先:${p.dir}`,
211  'status.failure': p => `無効化の理由:${p.reason}`,
212  'status.ready': '状態:条件を満たしています。次の turn が終わり次第引き継ぎます',
213  'status.gate': p => `状態:${p.reason}`,
214  'status.last': p => `前回の引き継ぎ:${p.ago} 前(${p.trigger}、${p.percent}%${p.after})、${p.file}`,
215  'status.lastNone': '前回の引き継ぎ:この session ではまだありません',
216  'status.count': p => `この session での引き継ぎは ${p.count} 回。/handover now で今すぐ引き継ぎ、/handover show で最新のメモ、/handover off で一時停止。`,
217  'gate.paused': '/handover off で一時停止中',
218  'gate.inFlight': '引き継ぎを実行中',
219  'gate.turnRunning': 'turn の実行中。終了を待っています',
220  'gate.noReading': '使用量の計測値がまだありません',
221  'gate.below': p => `使用量 ${p.percent}% はしきい値 ${p.threshold}% 未満`,
222  'gate.cooldown': p => `クールダウン中(前回の引き継ぎ ${p.ago} 前、クールダウン ${p.minutes} 分)`,
223  'dir.empty': 'handover_dir が空です',
224  'dir.badChars': 'handover_dir に不正な文字が含まれています',
225  'dir.notAbsolute': p => `handover_dir は ~/… または絶対パスで指定してください(指定値:${p.value})`,
226  'dir.dotdot': 'handover_dir に .. は使えません',
227  'dir.outsideHome': p => `handover_dir ${p.path} はホームディレクトリの外です。何も書き込みません`,
228  'home.unset': 'ホームディレクトリが分かりません(HOME 未設定)。何も書き込みません',
229  'cmd.inFlight': 'auto-handover:引き継ぎを実行中です。',
230  'cmd.nowTurnRunning': 'auto-handover:turn の実行中です。終了後に引き継ぎと compact を行います。',
231  'cmd.nowScheduled': p => `auto-handover:約 ${p.seconds} 秒後に引き継ぎを開始します(メモ作成 → compact → メモを会話に戻す)。`,
232  'cmd.off': 'auto-handover を一時停止しました:自動引き継ぎは行いません(/handover now は使えます)。/handover on で再開。',
233  'cmd.on': 'auto-handover を再開しました。',
234  'cmd.noNote': 'auto-handover:引き継ぎメモはまだありません。',
235  'cmd.unreadable': p => `auto-handover:${p.file} を読めません`,
236  'cmd.usage': '使い方:/handover(状態)、/handover now(今すぐ引き継ぎ)、/handover show(最新のメモ)、/handover off|on',
237  'toast.failed': p => `⟲ auto-handover:${p.reason}`,
238  'toast.compactSkipped': p => `⟲ メモは作成しましたが compact はスキップされました:${p.reason}`,
239  'toast.done': p => `⟲ 引き継ぎと compact が完了:${p.percent}%${p.after}、メモ ${p.file}`,
240  'toast.noteWritten': p => `⟲ 引き継ぎメモを作成しました:${p.file}`,
241  'fail.nothingToFork': '引き継ぐ内容がまだありません',
242  'fail.apiError': p => `メモの作成に失敗:API エラー(${p.error}${p.status})`,
243  'fail.emptyReply': 'メモの作成に失敗:モデルがテキストを返しませんでした',
244  'fail.aborted': 'メモの作成に失敗:リクエストが中断されました',
245  'fail.write': p => `メモの保存に失敗:${p.reason}`,
246  'compactRejected': p => `メモは ${p.file} に保存しましたが compact は拒否されました(${p.reason})。次の turn 終了後に再試行します`,
247  'note.title': '# 引き継ぎメモ',
248  'note.h1': '## 目的',
249  'note.h2': '## 現状',
250  'note.h3': '## 完了したこと',
251  'note.h4': '## 進行中',
252  'note.h5': '## やること(優先順)',
253  'note.h6': '## 重要な判断と理由',
254  'note.h7': '## 重要なファイルと場所',
255  'note.h8': '## 注意点・落とし穴',
256  'note.h9': '## 次の一手',
257  'prompt.handover': p =>
258    [
259      '作業を止めて、次に引き継ぐ agent のための引き継ぎメモを書いてください。相手はこの会話を見られず、あなたが書いた内容だけを読むので、そのまま作業を続けられるように書いてください。',
260      '',
261      '形式:Markdown。次の見出しをこの順番で必ずすべて使うこと(書くことがなければ「なし」と書く):',
262      String(p.headings),
263      '',
264      '条件:',
265      '- 事実と判断だけを書く。前置きや、この指示の繰り返しは不要。',
266      '- ファイルや関数はフルパスか正式な名前で書き、コマンドはバッククォートで囲む。',
267      '- 「次の一手」は一つだけ、そのまま実行できる具体さで書く。',
268      '- 全体で 600 語以内。',
269      '- 本文はこの会話で主に使われている言語で書き、見出しは上記のとおりにする。',
270      '- メモ本体だけを出力し、前書きや結びは付けない。',
271    ].join('\n'),
272  'prompt.compact':
273    '以下は、この session が今書いた引き継ぎメモです。要約する際は、メモにある目的・現状・やること・重要な判断・重要なファイル・次の一手をそのまま残してください(引用して構いません)。パス・名前・数値を省略したり書き換えたりしないでください。',
274  'inject.framing': '(auto-handover)この会話は context がしきい値に達したため、引き継ぎメモを書いてから compact しました。以下がメモの全文です。これを基準に作業を続け、要約と食い違う場合はメモを優先してください。',
275  'inject.file': p => `(ファイル:${p.file})`,
276  'resume.framing': p => `前の session が ${p.ago} 前にこのプロジェクトの引き継ぎメモを残しています(auto-handover が作成、ファイル:${p.file})。まず読んでから始めてください。ユーザーの依頼がメモと無関係なら無視して構いません。`,
277}
278
279export const MESSAGES: Record<Lang, Messages> = { en: EN, 'zh-TW': ZH_TW, ja: JA }
280
281/** One message in `lang`, falling back to English when the key is missing there. */
282export function t(lang: Lang, key: MessageKey, params?: Params): string {
283  const m: Msg | undefined = MESSAGES[lang]?.[key] ?? EN[key]
284  if (m === undefined) return key
285  return typeof m === 'function' ? m(params ?? {}) : m
286}
287
types/index.d.ts 42 lines
1// auto-handover data types and the $.state contract.
2
3/** Where the handover is: idle, asking the model for a note, or compacting. */
4export type HandoverPhase = 'idle' | 'writing' | 'compacting'
5
6/** What started this handover. */
7export type HandoverTrigger = 'threshold' | 'command' | 'manual' | 'auto'
8
9/** The most recent handover, for display. */
10export type HandoverRecord = {
11  at: number
12  /** Absolute path of the note. */
13  file: string
14  trigger: HandoverTrigger
15  /** Context usage (%) when the handover started. */
16  percent: number | null
17  /** Usage (%) after compaction; null when unknown. */
18  percentAfter: number | null
19}
20
21/** The language of the UI and of the note. */
22export type HandoverLang = 'en' | 'zh-TW' | 'ja'
23
24declare module 'claude-code' {
25  interface PluginState {
26    'auto-handover': {
27      /** The most recent context usage reading (%). */
28      lastPercent: number | null
29      phase: HandoverPhase
30      last: HandoverRecord | null
31      /** Handovers completed in this session. */
32      count: number
33      /** True after /handover off: no automatic handovers (/handover now still works). */
34      isPaused: boolean
35      /** Why the folder setting is unusable or the home directory unknown; null when fine. */
36      failure: string | null
37      /** The resolved language, so render hooks can read it. */
38      lang: HandoverLang
39    }
40  }
41}
42