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

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
It works as soon as it is installed. To change the threshold, folder or language, use the settings page under /plugin.
| Command | What it does |
|---|---|
/handover | Status: threshold, current usage, whether the next turn will hand over, when and where the last handover was |
/handover now | Hands over and compacts about 1.5 s later, ignoring the threshold and cooldown (waits for a running turn to end) |
/handover show | Prints the latest handover note |
/handover off / on | Pauses or resumes automatic handovers; /handover now still works while paused |
session.measure) is checked against the threshold.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.
| Field | Default | Meaning |
|---|---|---|
threshold | 75 | Trigger threshold (%). Clamped to 40..95 |
handover_dir | ~/.claude/handovers | Notes folder; must be ~/… or an absolute path under your home directory, otherwise the mod disables itself and says why in the band |
cooldown_minutes | 10 | Minimum gap between two automatic handovers |
resume_hours | 24 | A new session in the same project gets latest.md as opening context when it is younger than this; 0 disables it |
inject_after_compact | true | Append the note to the conversation after compaction |
language | auto | Language 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_style | box | How 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.
<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/.
---
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.
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.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.latest.md in that folder (for a new session); files over 256 KB are not read.$.model.fork to write the note, and compaction's own summary request.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 languageThe 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:
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.
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.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.
hooks/register.tsx 556 lines1// 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}
556hooks/logic.ts 292 lines1// 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}
292hooks/i18n.ts 287 lines1// 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}
287types/index.d.ts 42 lines1// 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