Before the context window fills, writes a handoff note and hands it once to your next fresh chat in this repo

Before the context window fills, writes a handoff note and hands it once to your next fresh chat in this repo, so you can start clean without losing the thread.
/plugin marketplace add partalemre/MODZ
/plugin install handoff@modz
Or in one step: /plugin install handoff --marketplace partalemre/MODZ.
HANDOFF · Context is 82% full, a context meter, and three buttons. Write handoff writes the note. Compact instead runs the same compaction /compact does. Not now waits until the fill has grown another 10 points. Once the fill drops back under the threshold (after a compaction), the next climb asks again.Handoff saved, where the file is, and to start fresh with /clear. The mod never runs /clear for you. Show note opens the Handoff pane./clear, or a new claude in that folder) gets the note in its system prompt, once: the first conversation that has no reply yet takes it and keeps it for each of its requests; no other conversation sees it after that. The chat that wrote the note never gets it, and a note older than a day is not handed on./handoff show; it never opens by itself): the latest note's preview and age, whether the next chat gets it, and Don't hand it on / Hand it to the next chat, Write handoff now and Delete note (removes the file and the kept copy).Context 42% · note ready.Written to .claude/handoff/<yyyy-mm-dd-hhmm>.md in the repo (add .claude/handoff/ to .gitignore if you do not want the notes committed):
git status --short --branch, when the folder is a git repo;| Command | Effect |
|---|---|
/handoff | Writes a handoff note now and prints its path |
/handoff show | Opens the Handoff pane |
| Setting | Default | Effect |
|---|---|---|
threshold | 80 | The context fill (in %, 50 to 99) at which the card asks |
summarize | true | Adds the model's short summary to each note |
At most one Haiku call per handoff, and only with summarize on: it reads the newest 30 messages, cut to 12,000 characters, and writes at most 500 tokens. The call goes through your own session's client. If it fails or is off, the note is built from the session alone and costs nothing. Handing the note on adds it (at most 8,000 characters) to the next conversation's system prompt, once.
The interface and the note's headings are English. Set MODZ_LANG=tr in the env block of ~/.claude/settings.json for Turkish.
hooks/register.tsx 259 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On, Register } from 'claude-code'
3
4import { compact, dismiss, measured, noteSection, remove, resetConversation, snooze, toggleInject, write, writeHub } from './actions'
5import { bandCard, withTheirs } from './band'
6import { cardOf, isBandQuiet } from './gate'
7import { isOpenFor } from './hub'
8import { reasonOf } from './io'
9import type { Io, ValueKey, Values } from './io'
10import { withHome } from './kit/home'
11import { setLang } from './kit/lang'
12import { pinned } from './kit/pin'
13import { isSame } from './kit/same'
14import { isOffStage, shownOf } from './kit/stage'
15import { resolveLight } from './kit/tokens'
16import type { DrawCtx } from './kit/ui'
17import { COMMAND, PANE, settingsOf } from './limits'
18import type { Settings } from './limits'
19import { motion, paneTree } from './pane'
20import { loadNote } from './state'
21import { errorLine, touchedPath, withFile } from './track'
22import { words } from './words'
23
24// Watches the context fill; at the threshold one card above the prompt offers a handoff note. The note goes to
25// `.claude/handoff/` and, once, into the system prompt of the next fresh conversation in the same repo.
26
27const percentAtom = atom({ plugin: 'handoff', key: 'percent' } as const, null)
28const snoozeAtom = atom({ plugin: 'handoff', key: 'snoozeUntil' } as const, null)
29const stageAtom = atom({ plugin: 'handoff', key: 'stage' } as const, 'idle')
30const failureAtom = atom({ plugin: 'handoff', key: 'failure' } as const, null)
31const latestAtom = atom({ plugin: 'handoff', key: 'latest' } as const, null)
32const filesAtom = atom({ plugin: 'handoff', key: 'files' } as const, [])
33const errorAtom = atom({ plugin: 'handoff', key: 'lastError' } as const, null)
34const noticeAtom = atom({ plugin: 'handoff', key: 'notice' } as const, null)
35const hubRef = { plugin: 'handoff', key: 'hub' } as const
36const homeRef = { plugin: 'handoff', key: 'home' } as const
37const hubOnRef = { plugin: 'modz', key: 'isOn' } as const
38const tasksRef = { plugin: 'plan-steps', key: 'tasks' } as const
39// Louder notices share the band's notice slot (DESIGN.md §6): while one shows, the handoff card waits.
40const cacheTaxNotice = { plugin: 'cache-tax', key: 'notice' } as const
41const knowNotice = { plugin: 'you-should-know', key: 'notice' } as const
42const reflectNotice = { plugin: 'reflect', key: 'notice' } as const
43const budgetNotice = { plugin: 'session-budget', key: 'notice' } as const
44
45type Cells = { [K in ValueKey]: { get: () => Promise<Values[K]>; set: (value: Values[K]) => Promise<unknown> } }
46
47// Each value of this mod, read and written through its own atom (the scan reads each by name).
48function cellsOf($: EngineInterface): Cells {
49 return {
50 percent: { get: () => read($, percentAtom), set: value => update($, percentAtom, () => value) },
51 snoozeUntil: { get: () => read($, snoozeAtom), set: value => update($, snoozeAtom, () => value) },
52 stage: { get: () => read($, stageAtom), set: value => update($, stageAtom, () => value) },
53 failure: { get: () => read($, failureAtom), set: value => update($, failureAtom, () => value) },
54 latest: { get: () => read($, latestAtom), set: value => update($, latestAtom, () => value) },
55 files: { get: () => read($, filesAtom), set: value => update($, filesAtom, () => value) },
56 lastError: { get: () => read($, errorAtom), set: value => update($, errorAtom, () => value) },
57 notice: { get: () => read($, noticeAtom), set: value => update($, noticeAtom, () => value) },
58 }
59}
60
61// Every open of the pane is a person's (the card's button, /handoff show, the hub's row). Placed, it takes a running
62// hub's one panel, so the hub and every other pane step aside (kit/stage.ts); a failed note of that is only logged.
63async function openPane($: EngineInterface): Promise<void> {
64 motion.reset()
65 const opened = await $.ui.open({ id: PANE, title: words().title, focus: true })
66 try {
67 if (!opened.isPlaced || (await $.state.get({ plugin: 'modz', key: 'isOn' })).value !== true) return
68 await $.state.set({ plugin: 'handoff', key: 'shown' }, shownOf(PANE, await $.clock.now()))
69 } catch (error) {
70 $.ui.log(`handoff: shown failed (${reasonOf(error)})`, { to: 'debug' })
71 }
72}
73
74// The engine calls the other files use, as closures over this hook's `$`.
75function ioOf($: EngineInterface): Io {
76 const cells = cellsOf($)
77
78 return {
79 get: key => cells[key].get(),
80 put: async (key, value) => {
81 if (!isSame(await cells[key].get(), value)) await cells[key].set(value)
82 },
83 hub: { get: async () => (await $.state.get(hubRef)).value, set: async card => void (await $.state.set(hubRef, card)) },
84 tasks: async () => (await $.state.get(tasksRef)).value,
85 store: { get: key => $.store.get(key), set: (key, note) => $.store.set(key, note), delete: key => $.store.delete(key) },
86 now: () => $.clock.now(),
87 root: () => $.session.root(),
88 sessionId: () => $.session.id(),
89 messages: () => $.session.messages(),
90 complete: request => $.model.complete(request),
91 run: (argv, options) => $.process.run(argv, options),
92 writeFile: (path, text) => $.fs.write(path, text),
93 compact: () => $.session.compact(),
94 openPane: () => openPane($),
95 log: text => $.ui.log(`handoff: ${text}`, { to: 'debug' }),
96 }
97}
98
99// Light or dark for Box/Text hex: MODZ_THEME wins, then Claude's own theme setting.
100async function isLightNow($: EngineInterface): Promise<boolean> {
101 const override = await $.env.get('MODZ_THEME').catch(() => undefined)
102 const rows = await $.config.list().catch(() => [])
103
104 return resolveLight({ override, theme: rows.find(row => row.key === 'theme')?.value })
105}
106
107function hookSession(on: On, settings: Settings): void {
108 on('session.start', async ($, e, next) => {
109 setLang(await $.env.get('MODZ_LANG').catch(() => undefined))
110 const io = ioOf($)
111 try {
112 await $.command.register({ name: COMMAND, description: words().commandHelp, argumentHint: '[show]' })
113 await loadNote(io, await $.session.root())
114 await writeHub(io, settings)
115 } catch (error) {
116 io.log(`could not start (${reasonOf(error)})`)
117 }
118
119 return next(e)
120 })
121
122 on('session.end', async ($, e, next) => {
123 const io = ioOf($)
124 if (e.reason === 'clear') await resetConversation(io, settings).catch(error => io.log(`reset skipped (${reasonOf(error)})`))
125
126 return next(e)
127 })
128
129 on('session.measure', async ($, e, next) => {
130 const done = await next(e)
131 if (e.changed.includes('context')) await measured(ioOf($), settings, e.context.percent === undefined ? null : Math.round(e.context.percent))
132
133 return done
134 }).catch(($, e, next) => next(e))
135}
136
137function hookTools(on: On): void {
138 on('tool.call', async ($, e, next) => {
139 const result = await next(e)
140 if (result.deny !== undefined) return result
141 const tool = String(e.tool)
142 const path = touchedPath(tool, e)
143 if (result.isError === true) await ioOf($).put('lastError', errorLine(tool, result.text))
144 else if (path !== null) await update($, filesAtom, files => withFile(files, path))
145
146 return result
147 }).catch(($, e, next) => next(e))
148}
149
150async function runCommand(io: Io, settings: Settings, arg: string): Promise<string> {
151 if (arg === 'show') {
152 await io.openPane()
153
154 return words().commandShown
155 }
156 try {
157 const path = await write(io, settings)
158
159 return path === null ? words().writingHeadline : words().commandSaved(path)
160 } catch (error) {
161 return words().commandFailed(reasonOf(error))
162 }
163}
164
165function hookCommands(on: On, settings: Settings): void {
166 on('command.run', { command: COMMAND }, async ($, e) => ({ text: await runCommand(ioOf($), settings, e.args.trim().toLowerCase()) }))
167
168 // The main panel's open request opens this pane, or brings it forward; it never closes it.
169 on('state.set', { plugin: 'modz', key: 'open' }, async ($, e, next) => {
170 const done = await next(e)
171 if (isOpenFor(e.value, PANE)) await ioOf($).openPane()
172
173 return done
174 }).catch(($, e, next) => next(e))
175
176 // Another pane took the hub's one panel: this one steps aside rather than wait as a tab.
177 on('state.set', { plugin: 'modz', key: 'stage' }, async ($, e, next) => {
178 const done = await next(e)
179 if (isOffStage(e.value, PANE)) await $.ui.close({ id: PANE })
180
181 return done
182 }).catch(($, e, next) => next(e))
183
184 on('prompt.compose', async ($, e, next) => {
185 const composed = await next(e)
186 const section = await noteSection(ioOf($), settings)
187
188 return section === null ? composed : { sections: [...composed.sections, section] }
189 }).catch(($, e, next) => next(e))
190}
191
192async function hasLouderNotice($: EngineInterface): Promise<boolean> {
193 const others: { value?: unknown }[] = [
194 await $.state.get(cacheTaxNotice as never),
195 await $.state.get(knowNotice as never),
196 await $.state.get(reflectNotice as never),
197 await $.state.get(budgetNotice as never),
198 ]
199
200 return others.some(other => other.value !== undefined && other.value !== null)
201}
202
203// A press runs its action in the background; a failure only goes to the debug log.
204const pressed = (io: Io, run: () => Promise<unknown>) => () => void run().catch(error => io.log(`press failed (${reasonOf(error)})`))
205
206function hookBand(on: On, settings: Settings): void {
207 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
208 const theirs = await next(e)
209 if (isBandQuiet({ isWorking: e.props.isWorking, hasSurvey: e.props.hasSurvey })) return theirs
210 if (await hasLouderNotice($)) return theirs
211 const io = ioOf($)
212 const percent = await io.get('percent')
213 const kind = cardOf({ percent, threshold: settings.threshold, snoozeUntil: await io.get('snoozeUntil'), stage: await io.get('stage') })
214 if (kind === null) return theirs
215 const draw: DrawCtx = { ui: $.ui.resolve(e), surface: e.surface, isLight: await isLightNow($) }
216 const card = bandCard(draw, {
217 kind, percent, threshold: settings.threshold, note: await io.get('latest'), failure: await io.get('failure'), columns: e.props.bodyColumns,
218 actions: {
219 onWrite: pressed(io, () => write(io, settings)),
220 onCompact: pressed(io, () => compact(io, settings)),
221 onSnooze: pressed(io, () => snooze(io, settings)),
222 onShow: pressed(io, () => io.openPane()),
223 onDismiss: pressed(io, () => dismiss(io, settings)),
224 },
225 })
226
227 return pinned(e, withTheirs(draw, card, theirs))
228 })
229}
230
231function hookPane(on: On, settings: Settings): void {
232 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
233 const io = ioOf($)
234 const draw: DrawCtx = { ui: $.ui.resolve(e), surface: e.surface, isLight: await isLightNow($), motion }
235 const isHubOn = (await $.state.get(hubOnRef)).value === true
236 const goHome = pressed(io, async () => $.state.set(homeRef, { at: await $.clock.now() }))
237 const tree = paneTree(draw, {
238 note: await io.get('latest'), now: await io.now(), percent: await io.get('percent'), threshold: settings.threshold,
239 stage: await io.get('stage'), columns: e.props.bodyColumns,
240 actions: {
241 onWrite: pressed(io, () => write(io, settings)),
242 onToggle: pressed(io, () => toggleInject(io, settings)),
243 onDelete: pressed(io, () => remove(io, settings)),
244 },
245 })
246
247 return pinned(e, withHome(draw.ui, tree, isHubOn ? goHome : null))
248 })
249}
250
251export const register: Register = (on, options) => {
252 const settings = settingsOf(options)
253 hookSession(on, settings)
254 hookTools(on)
255 hookCommands(on, settings)
256 hookBand(on, settings)
257 hookPane(on, settings)
258}
259hooks/actions.ts 132 lines1import type { PromptComposeSection } from 'claude-code'
2
3import { isAskDue, rearmed, snoozeFrom } from './gate'
4import { hubCard, hubOf } from './hub'
5import { injectMove, sectionText } from './inject'
6import { reasonOf } from './io'
7import type { Io } from './io'
8import { NOTICE_FOREVER, NOTICE_PRIORITY, SECTION_ID } from './limits'
9import type { Settings } from './limits'
10import { forgetNote, loadNote, saveNote } from './state'
11import { words } from './words'
12import { deleteNote, writeHandoff } from './write'
13
14// What a press, the command, a measurement and the prompt do. Each runs from a handler or an event, never
15// while drawing.
16
17// The MODZ main panel's card, written only when what it shows changes (DESIGN.md §9).
18export async function writeHub(io: Io, settings: Settings): Promise<void> {
19 const shown = hubOf({
20 percent: await io.get('percent'), threshold: settings.threshold, note: await io.get('latest'), stage: await io.get('stage'), now: await io.now(),
21 })
22 const before = await io.hub.get()
23 const isUnchanged = before !== undefined && before.detail === shown.detail && before.tone === shown.tone && before.title === words().title
24 if (!isUnchanged) await io.hub.set(hubCard(shown, await io.now()))
25}
26
27// The shared notice line holds this mod's place while the ask is due, and lets go once it is answered.
28async function syncNotice(io: Io, settings: Settings): Promise<void> {
29 const isDue = isAskDue({ percent: await io.get('percent'), threshold: settings.threshold, snoozeUntil: await io.get('snoozeUntil'), stage: await io.get('stage') })
30 await io.put('notice', isDue ? { priority: NOTICE_PRIORITY, expiresAt: NOTICE_FOREVER } : null)
31}
32
33export async function measured(io: Io, settings: Settings, percent: number | null): Promise<void> {
34 await io.put('percent', percent)
35 const next = rearmed({ percent, threshold: settings.threshold, snoozeUntil: await io.get('snoozeUntil'), stage: await io.get('stage') })
36 await io.put('snoozeUntil', next.snoozeUntil)
37 await io.put('stage', next.stage)
38 await syncNotice(io, settings)
39 await writeHub(io, settings)
40}
41
42// A /clear starts a new conversation in the same process: what the last one tracked starts over.
43export async function resetConversation(io: Io, settings: Settings): Promise<void> {
44 await io.put('percent', null)
45 await io.put('snoozeUntil', null)
46 await io.put('stage', 'idle')
47 await io.put('failure', null)
48 await io.put('files', [])
49 await io.put('lastError', null)
50 await io.put('notice', null)
51 await writeHub(io, settings)
52}
53
54// Writes the note; a second press while one is being written does nothing. Resolves the note's path, or null.
55export async function write(io: Io, settings: Settings): Promise<string | null> {
56 if ((await io.get('stage')) === 'writing') return null
57 await io.put('stage', 'writing')
58 await syncNotice(io, settings)
59 await writeHub(io, settings)
60 try {
61 const note = await writeHandoff(io, settings)
62 await io.put('failure', null)
63 await io.put('stage', 'saved')
64
65 return note.path
66 } catch (error) {
67 io.log(`note not written (${reasonOf(error)})`)
68 await io.put('failure', reasonOf(error))
69 await io.put('stage', 'failed')
70 throw new Error(reasonOf(error), { cause: error })
71 } finally {
72 await writeHub(io, settings)
73 }
74}
75
76export async function snooze(io: Io, settings: Settings): Promise<void> {
77 await io.put('snoozeUntil', snoozeFrom((await io.get('percent')) ?? settings.threshold))
78 await io.put('stage', 'idle')
79 await syncNotice(io, settings)
80}
81
82// Compaction runs between turns only, and the band offers it only then. The ask is done for this climb.
83export async function compact(io: Io, settings: Settings): Promise<void> {
84 await dismiss(io, settings)
85 try {
86 await io.compact()
87 } catch (error) {
88 io.log(`compaction refused (${reasonOf(error)})`)
89 }
90}
91
92export async function dismiss(io: Io, settings: Settings): Promise<void> {
93 await io.put('stage', 'done')
94 await syncNotice(io, settings)
95}
96
97export async function toggleInject(io: Io, settings: Settings): Promise<void> {
98 const note = await io.get('latest')
99 if (note === null) return
100 await saveNote(io, { ...note, isInjectOn: !note.isInjectOn })
101 await writeHub(io, settings)
102}
103
104export async function remove(io: Io, settings: Settings): Promise<void> {
105 const note = await io.get('latest')
106 if (note === null) return
107 try {
108 await deleteNote(io, note)
109 } catch (error) {
110 io.log(`note file kept (${reasonOf(error)})`)
111 }
112 await forgetNote(io, note.root)
113 await writeHub(io, settings)
114}
115
116// The note's section for this conversation, or null: a fresh one claims it once, then keeps it for every request,
117// so its system prompt stays the same from the first request on.
118export async function noteSection(io: Io, settings: Settings): Promise<PromptComposeSection | null> {
119 const note = await loadNote(io, await io.root())
120 const sessionId = await io.sessionId()
121 const move = injectMove({ note, sessionId, now: await io.now() })
122 if (note === null || move === 'skip') return null
123 if (move === 'claim') {
124 const isFresh = (await io.messages()).every(message => message.role !== 'assistant')
125 if (!isFresh) return null
126 await saveNote(io, { ...note, consumedBy: sessionId })
127 await writeHub(io, settings)
128 }
129
130 return { id: SECTION_ID, text: sectionText(note), scope: 'session' }
131}
132hooks/band.tsx 106 lines1import type { RenderElement } from 'claude-code'
2
3import { ButtonRow, Card, Meter, cardInnerWidth } from './kit/ui'
4import type { ButtonSpec, DrawCtx } from './kit/ui'
5import type { CardKind } from './gate'
6import { relativeTo } from './note'
7import { words } from './words'
8import type { HandoffNote } from '../types'
9
10// The one card this mod puts above the prompt (DESIGN.md §6, slot 2, a notification): the ask at the threshold,
11// then what became of it. It sits above `theirs`, which it never swallows.
12
13const PERCENT = 100
14
15export type BandActions = {
16 onWrite: () => void
17 onCompact: () => void
18 onSnooze: () => void
19 onShow: () => void
20 onDismiss: () => void
21}
22
23export type BandView = {
24 kind: Exclude<CardKind, null>
25 percent: number | null
26 threshold: number
27 note: HandoffNote | null
28 failure: string | null
29 columns: number
30 actions: BandActions
31}
32
33function askCard(draw: DrawCtx, view: BandView, width: number): RenderElement {
34 const says = words()
35 const percent = view.percent ?? view.threshold
36 const buttons: ButtonSpec[] = [
37 { key: 'handoff-write', label: says.write, variant: 'primary', onPress: view.actions.onWrite },
38 { key: 'handoff-compact', label: says.compact, onPress: view.actions.onCompact },
39 { key: 'handoff-snooze', label: says.notNow, variant: 'plain', onPress: view.actions.onSnooze },
40 ]
41
42 return Card(draw, {
43 role: 'warn',
44 header: { icon: 'flag', eyebrow: says.eyebrow, meta: says.askMeta(view.threshold), headline: says.askHeadline(percent), sub: says.askSub, width },
45 children: [
46 Meter(draw, { label: says.context, value: percent / PERCENT, width }),
47 ButtonRow(draw, { buttons }),
48 ],
49 })
50}
51
52function savedCard(draw: DrawCtx, view: BandView, width: number): RenderElement {
53 const says = words()
54 const { Text } = draw.ui
55 const path = view.note === null ? '' : relativeTo(view.note.root, view.note.path)
56 const buttons: ButtonSpec[] = [
57 { key: 'handoff-show', label: says.showNote, variant: 'primary', onPress: view.actions.onShow },
58 { key: 'handoff-dismiss', label: says.gotIt, variant: 'plain', onPress: view.actions.onDismiss },
59 ]
60
61 return Card(draw, {
62 role: 'ok',
63 header: { icon: 'check', eyebrow: says.eyebrow, headline: says.savedHeadline, sub: says.savedSub, width },
64 children: [<Text key="handoff-path" dimColor wrap="truncate-middle">{path}</Text>, ButtonRow(draw, { buttons })],
65 })
66}
67
68function failedCard(draw: DrawCtx, view: BandView, width: number): RenderElement {
69 const says = words()
70 const buttons: ButtonSpec[] = [
71 { key: 'handoff-retry', label: says.tryAgain, variant: 'primary', onPress: view.actions.onWrite },
72 { key: 'handoff-snooze', label: says.notNow, variant: 'plain', onPress: view.actions.onSnooze },
73 ]
74
75 return Card(draw, {
76 role: 'danger',
77 header: { icon: 'x', eyebrow: says.failedEyebrow, headline: says.failedHeadline, sub: view.failure ?? undefined, width },
78 children: [ButtonRow(draw, { buttons })],
79 })
80}
81
82function writingCard(draw: DrawCtx, width: number): RenderElement {
83 return Card(draw, { role: 'run', header: { icon: 'pencil', eyebrow: words().eyebrow, headline: words().writingHeadline, width } })
84}
85
86export function bandCard(draw: DrawCtx, view: BandView): RenderElement {
87 const width = cardInnerWidth(view.columns)
88 if (view.kind === 'ask') return askCard(draw, view, width)
89 if (view.kind === 'saved') return savedCard(draw, view, width)
90 if (view.kind === 'failed') return failedCard(draw, view, width)
91
92 return writingCard(draw, width)
93}
94
95// The card above whatever the other mods drew.
96export function withTheirs(draw: DrawCtx, card: RenderElement, theirs: RenderElement | null): RenderElement {
97 const { Box } = draw.ui
98
99 return (
100 <Box flexDirection="column">
101 {card}
102 {theirs}
103 </Box>
104 )
105}
106hooks/gate.ts 48 lines1import { SNOOZE_POINTS } from './limits'
2import type { HandoffStage } from '../types'
3
4// When the band asks, and what "Not now" waits for. Pure, so the rules are tested alone.
5
6export type AskFacts = {
7 percent: number | null
8 threshold: number
9 snoozeUntil: number | null
10 stage: HandoffStage
11}
12
13// The fill has reached the threshold, no snooze holds it back, and nothing else is on the card.
14export function isAskDue(facts: AskFacts): boolean {
15 if (facts.percent === null || facts.stage !== 'idle') return false
16 if (facts.percent < facts.threshold) return false
17
18 return facts.snoozeUntil === null || facts.percent >= facts.snoozeUntil
19}
20
21// "Not now" at 82 % waits for 92 %.
22export const snoozeFrom = (percent: number): number => percent + SNOOZE_POINTS
23
24export type Rearm = { snoozeUntil: number | null; stage: HandoffStage }
25
26// Once the fill falls back under the threshold (a compaction), the snooze and a seen handoff are let go,
27// so the next climb asks again.
28export function rearmed(facts: AskFacts): Rearm {
29 const isBelow = facts.percent !== null && facts.percent < facts.threshold
30 if (!isBelow) return { snoozeUntil: facts.snoozeUntil, stage: facts.stage }
31
32 return { snoozeUntil: null, stage: facts.stage === 'done' ? 'idle' : facts.stage }
33}
34
35export type BandFacts = { isWorking: boolean; hasSurvey: boolean }
36
37// The band shows nothing of this mod while a turn runs or a survey is open (DESIGN.md §6).
38export const isBandQuiet = (facts: BandFacts): boolean => facts.isWorking || facts.hasSurvey
39
40// What the card draws now: the ask, a stage of its own, or nothing.
41export type CardKind = 'ask' | 'writing' | 'saved' | 'failed' | null
42
43export function cardOf(facts: AskFacts): CardKind {
44 if (facts.stage === 'writing' || facts.stage === 'saved' || facts.stage === 'failed') return facts.stage
45
46 return isAskDue(facts) ? 'ask' : null
47}
48hooks/hub.ts 39 lines1import { isReady } from './inject'
2import { HUB_DETAIL_MAX, PANE } from './limits'
3import { words } from './words'
4import type { HandoffNote, HandoffStage, HubCard, HubTone } from '../types'
5
6// The MODZ main panel's side of this mod (DESIGN.md §9): its one line ("Context 42% · note ready") and its own
7// row's title, hint, icon and section, since the hub's catalog does not know this mod yet.
8
9export const HUB_ICON = 'flag'
10export const HUB_SECTION = 'meter'
11
12export type HubFacts = { percent: number | null; threshold: number; note: HandoffNote | null; stage: HandoffStage; now: number }
13
14export type HubShown = { detail: string; tone: HubTone }
15
16function toneOf(facts: HubFacts): HubTone {
17 if (facts.stage === 'writing') return 'live'
18 if (facts.stage === 'failed') return 'bad'
19 if (facts.percent !== null && facts.percent >= facts.threshold) return 'warn'
20
21 return isReady(facts.note, facts.now) ? 'ok' : 'idle'
22}
23
24export function hubOf(facts: HubFacts): HubShown {
25 const says = words()
26 const parts = [facts.percent === null ? '' : says.hubContext(facts.percent), isReady(facts.note, facts.now) ? says.hubReady : '']
27 const detail = parts.filter(part => part !== '').join(' · ').slice(0, HUB_DETAIL_MAX)
28
29 return { detail, tone: toneOf(facts) }
30}
31
32export const hubCard = (shown: HubShown, at: number): HubCard => ({
33 pane: PANE, ...shown, at, title: words().title, hint: words().hint, icon: HUB_ICON, section: HUB_SECTION,
34})
35
36export function isOpenFor(value: unknown, pane: string): boolean {
37 return typeof value === 'object' && value !== null && (value as { pane?: unknown }).pane === pane
38}
39hooks/io.ts 33 lines1import type { ModelCompleteRequest, ModelCompleteResult, PluginState, SessionMessage } from 'claude-code'
2
3import type { HandoffNote, HubCard } from '../types'
4
5// The engine calls the rest of the mod needs, as closures: only register.tsx holds `$`.
6
7export type Values = Omit<PluginState['handoff'], 'hub' | 'home'>
8export type ValueKey = keyof Values
9
10export type RunOut = { exitCode: number; stdout: string }
11
12export type Io = {
13 get: <K extends ValueKey>(key: K) => Promise<Values[K]>
14 // Writes only a value that changed (kit/same.ts).
15 put: <K extends ValueKey>(key: K, value: Values[K]) => Promise<void>
16 hub: { get: () => Promise<HubCard | undefined>; set: (card: HubCard) => Promise<void> }
17 // plan-steps' task list, unchecked: another mod's value.
18 tasks: () => Promise<unknown>
19 store: { get: (key: string) => Promise<unknown>; set: (key: string, note: HandoffNote) => Promise<void>; delete: (key: string) => Promise<void> }
20 now: () => Promise<number>
21 root: () => Promise<string>
22 sessionId: () => Promise<string>
23 messages: () => Promise<readonly SessionMessage[]>
24 complete: (request: ModelCompleteRequest) => Promise<ModelCompleteResult>
25 run: (argv: string[], options: { cwd?: string; timeoutMs: number }) => Promise<RunOut>
26 writeFile: (path: string, text: string) => Promise<void>
27 compact: () => Promise<unknown>
28 openPane: () => Promise<unknown>
29 log: (text: string) => void
30}
31
32export const reasonOf = (error: unknown): string => (error instanceof Error ? error.message : 'unknown')
33hooks/kit/home.tsx 33 lines1// MODZ kit — the way back to the hub from a mod's pane (DESIGN.md §9). While the hub runs it keeps
2// `{ plugin: 'modz', key: 'isOn' }` true; a pane then draws `HomeButton` first, and its press writes the mod's
3// own `{ plugin: '<mod>', key: 'home' }` value (`{ at }`). The hub hears it, comes forward, and opens again if
4// it was closed, so the person always has a way back, even with one pane left and no tab to click.
5
6// The resolved element table of the surface that draws the pane.
7type Table = Record<string, any>
8
9export const HOME_LABEL = '← MODZ'
10
11export function HomeButton(ui: Table, onPress: () => void) {
12 const { Box, Button } = ui
13
14 return (
15 <Box key="modz-home-row" flexDirection="row">
16 <Button key="modz-home" label={HOME_LABEL} variant="secondary" onPress={onPress} />
17 </Box>
18 )
19}
20
21// The pane's tree with the way back above it; null while the hub is off draws the pane as it is.
22export function withHome<T>(ui: Table, tree: T, onHome: (() => void) | null) {
23 if (onHome === null) return tree
24 const { Box } = ui
25
26 return (
27 <Box flexDirection="column" rowGap={1}>
28 {HomeButton(ui, onHome)}
29 {tree}
30 </Box>
31 )
32}
33hooks/kit/lang.ts 24 lines1// MODZ kit — the interface language: English, or Turkish when the person sets MODZ_LANG=tr (the "env" of
2// ~/.claude/settings.json). One value per mod: its register file reads the variable at session.start with
3// `setLang(await $.env.get('MODZ_LANG'))` (the engine only admits an env read whose name is a literal), and every
4// drawing reads it back through `pick` and `upper`.
5export type Lang = 'en' | 'tr'
6
7let current: Lang = 'en'
8
9export const langOf = (value: unknown): Lang => (typeof value === 'string' && /^tr/i.test(value.trim()) ? 'tr' : 'en')
10
11export function setLang(value: unknown): Lang {
12 current = langOf(value)
13
14 return current
15}
16
17export const lang = (): Lang => current
18
19// The current language's entry of a table that fills in both: `pick({ en: 'Open', tr: 'Aç' })`.
20export const pick = <T>(table: Readonly<Record<Lang, T>>): T => table[current]
21
22// Capitals for eyebrows and labels: Turkish keeps its dotted İ, English never gets one.
23export const upper = (text: string): string => text.toLocaleUpperCase(current)
24hooks/kit/pin.ts 48 lines1// MODZ kit — pinned press handles. Every element the JSX runtime builds gets a fresh handle, and the
2// host releases a drawing's old handles the moment it redraws. A desktop holds the tree it last
3// painted, so a click that lands just after any redraw (a state write, a focus flip, a poll) names a
4// released handle and the engine drops it ("ui_press not handled"). The host keeps a handle alive
5// across redraws when the new tree carries the same one, so each element here keeps the first handle
6// its site and key ever got: every tree a surface still holds stays pressable, with the latest closure.
7import type { RenderElement } from 'claude-code'
8
9export type PinSite = { surface: string; component?: string; requestId?: string }
10
11type Node = { type?: string; props?: { key?: unknown }; press?: { plugin: string; handle: number }; children?: unknown[] }
12
13// Far above the runtime's own counter, which starts at 1 and climbs by one per element drawn.
14const FIRST_PINNED = 1_000_000_000
15const pins = new Map<string, number>()
16
17function handleFor(id: string): number {
18 let handle = pins.get(id)
19 if (handle === undefined) {
20 handle = FIRST_PINNED + pins.size
21 pins.set(id, handle)
22 }
23
24 return handle
25}
26
27function pinNode(node: unknown, ctx: { site: string; seen: Map<string, number> }): unknown {
28 if (typeof node !== 'object' || node === null) return node
29 const el = node as Node
30 const children = el.children?.map(child => pinNode(child, ctx))
31 const withKids = children === undefined ? el : { ...el, children }
32 // Only this plugin's own elements: the runtime names the plugin of every tree `next` returned.
33 if (el.press?.plugin !== '' || typeof el.props?.key !== 'string') return withKids
34 const base = `${ctx.site}\u0000${el.type}\u0000${el.props.key}`
35 const nth = ctx.seen.get(base) ?? 0
36 ctx.seen.set(base, nth + 1)
37
38 return { ...withKids, press: { plugin: '', handle: handleFor(`${base}\u0000${nth}`) } }
39}
40
41// Return a render hook's tree through this: `return pinned(e, tree)`.
42export function pinned<T extends RenderElement | null | undefined>(site: PinSite, tree: T): T {
43 if (tree === null || tree === undefined) return tree
44 const ctx = { site: `${site.surface}\u0000${site.component ?? ''}\u0000${site.requestId ?? ''}`, seen: new Map<string, number>() }
45
46 return pinNode(tree, ctx) as T
47}
48hooks/kit/same.ts 15 lines1// MODZ kit — structural equality for plain state values (JSON-like: objects, arrays, primitives).
2// A `$.state` write redraws every reader, and every redraw hands out new press handles, so a
3// value that did not change is not written again.
4export function isSame(a: unknown, b: unknown): boolean {
5 if (Object.is(a, b)) return true
6 if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false
7 if (Array.isArray(a) !== Array.isArray(b)) return false
8 const left = a as Record<string, unknown>
9 const right = b as Record<string, unknown>
10 const keys = Object.keys(left)
11 if (keys.length !== Object.keys(right).length) return false
12
13 return keys.every(key => Object.prototype.hasOwnProperty.call(right, key) && isSame(left[key], right[key]))
14}
15hooks/kit/stage.ts 30 lines1// MODZ kit — one panel (DESIGN.md §9). The hub and every mod's pane take turns in one place instead of
2// piling up as tabs: a mod that opens its pane writes its own `{ plugin: '<mod>', key: 'shown' }` (`{ pane, at }`);
3// the hub then steps aside and writes `{ plugin: 'modz', key: 'stage' }` naming that pane, and every other mod
4// whose pane is open closes it. The hub coming back (its row, "← MODZ", /modz) writes the stage as `modz`.
5
6export const HUB_PANE = 'modz'
7
8export type Stage = { pane: string; at: number }
9
10// The pane a `modz.open`, `modz.stage` or `shown` value names; null for anything malformed.
11export const paneOf = (value: unknown): string | null => {
12 if (typeof value !== 'object' || value === null) return null
13 const pane = (value as { pane?: unknown }).pane
14
15 return typeof pane === 'string' && pane !== '' ? pane : null
16}
17
18// Whether a `modz.open` write asks for this pane.
19export const isAskedFor = (value: unknown, pane: string): boolean => paneOf(value) === pane
20
21// Whether a `modz.stage` write puts another pane in the panel, so this one closes.
22export function isOffStage(value: unknown, pane: string): boolean {
23 const staged = paneOf(value)
24
25 return staged !== null && staged !== pane
26}
27
28// What a mod writes under its own `shown` key once its pane is open.
29export const shownOf = (pane: string, at: number): Stage => ({ pane, at })
30hooks/kit/tokens.ts 263 lines1// MODZ kit (Tidemark) — source of truth is kit/ in the MODZ repo; bin/kit-sync copies it
2// into each mod's hooks/kit/. Edit there, never in a copy. Pure: no `$`, no state.
3
4// ---------------------------------------------------------------------------
5// Palettes: DESIGN.md §2, both themes, exactly.
6
7export const ROLES = ['read', 'edit', 'run', 'ok', 'warn', 'danger', 'plan'] as const
8export type Role = (typeof ROLES)[number]
9export type RoleColors = { fg: string; tint: string; line: string }
10
11export type Neutrals = {
12 sheet: string
13 card: string
14 inset: string
15 line: string
16 lineStrong: string
17 track: string
18 hi: string
19 mid: string
20 lo: string
21}
22
23export type Palette = Neutrals & { roles: Record<Role, RoleColors> }
24
25const role = (fg: string, tint: string, line: string): RoleColors => ({ fg, tint, line })
26
27export const DARK: Palette = {
28 sheet: '#191917',
29 card: '#1F1F1C',
30 inset: '#171715',
31 line: '#2E2E2A',
32 lineStrong: '#45443F',
33 track: '#34332F',
34 hi: '#F0EEE8',
35 mid: '#C2BEB4',
36 lo: '#8E8A80',
37 roles: {
38 read: role('#7FB6E8', '#1E2B37', '#2F4A63'),
39 edit: role('#E58A5F', '#38251D', '#6A3E2D'),
40 run: role('#B19CF3', '#29243D', '#4A3F75'),
41 ok: role('#6FC593', '#1B2F25', '#2F5A42'),
42 warn: role('#E9B949', '#332D19', '#66552A'),
43 danger: role('#F2727F', '#3B2023', '#74343C'),
44 plan: role('#E07BC0', '#351F2E', '#6B3A5C'),
45 },
46}
47
48export const LIGHT: Palette = {
49 sheet: '#F4F2EC',
50 card: '#FFFFFF',
51 inset: '#F6F4EF',
52 line: '#E4E0D6',
53 lineStrong: '#CFC9BC',
54 track: '#ECE8DF',
55 hi: '#1C1B19',
56 mid: '#4F4B44',
57 lo: '#77726A',
58 roles: {
59 read: role('#2464A0', '#E7F0F9', '#B9D3EC'),
60 edit: role('#A94A26', '#FBEDE6', '#EFC6B4'),
61 run: role('#6243C4', '#F0ECFC', '#CFC3F3'),
62 ok: role('#1F6E43', '#E4F4EA', '#B5DEC4'),
63 warn: role('#8A6000', '#FBF2D9', '#EBD48F'),
64 danger: role('#C0263A', '#FCE8EA', '#F1B9C0'),
65 plan: role('#A8337F', '#FBE9F4', '#EDBFDC'),
66 },
67}
68
69export const palette = (isLight: boolean): Palette => (isLight ? LIGHT : DARK)
70
71// ---------------------------------------------------------------------------
72// File-type badges: soft, one colour per kind; the activity colour (read/edit) wins on a row.
73
74export type FileKind = 'code' | 'py' | 'doc' | 'data' | 'pdf' | 'img' | 'misc'
75export type BadgeColors = { fg: string; tint: string }
76
77const KIND_ROLE: Record<FileKind, Role | null> = {
78 code: 'warn',
79 py: 'read',
80 doc: null,
81 data: 'ok',
82 pdf: 'danger',
83 img: 'plan',
84 misc: null,
85}
86
87// Kinds with no role of their own: md/txt are a pale Iris, sh/git and the unknown are text.lo.
88// Light misc uses text.mid on track: text.lo misses 4.5:1 on every light tint.
89const OWN_BADGES: Record<'doc' | 'misc', { dark: BadgeColors; light: BadgeColors }> = {
90 doc: { dark: { fg: '#B8AADA', tint: '#252230' }, light: { fg: '#5A4691', tint: '#F6F4FD' } },
91 misc: { dark: { fg: DARK.lo, tint: DARK.inset }, light: { fg: LIGHT.mid, tint: LIGHT.track } },
92}
93
94const EXT_KIND: Record<string, FileKind> = {
95 ts: 'code', tsx: 'code', js: 'code', jsx: 'code', mjs: 'code', cjs: 'code', mts: 'code', cts: 'code',
96 py: 'py', pyi: 'py',
97 md: 'doc', mdx: 'doc', txt: 'doc', rst: 'doc',
98 json: 'data', csv: 'data', yaml: 'data', yml: 'data', toml: 'data', xml: 'data',
99 pdf: 'pdf',
100 png: 'img', jpg: 'img', jpeg: 'img', gif: 'img', svg: 'img', webp: 'img', ico: 'img',
101}
102
103const EXT_LABEL: Record<string, string> = { json: '{}', yaml: 'YML', jpeg: 'JPG' }
104const BADGE_CHARS = 3
105
106export type FileType = { kind: FileKind; label: string }
107
108// `src/api/client.ts` → code "TS"; `.gitignore` → misc "GIT"; `Makefile` → misc "MAK".
109export function fileType(name: string): FileType {
110 const base = name.split('/').pop() ?? name
111 const dot = base.lastIndexOf('.')
112 const ext = (dot >= 0 ? base.slice(dot + 1) : base).toLowerCase()
113 const isGit = base.toLowerCase().startsWith('.git')
114 const kind = isGit ? 'misc' : EXT_KIND[ext] ?? 'misc'
115 const label = isGit ? 'GIT' : EXT_LABEL[ext] ?? ext.slice(0, BADGE_CHARS).toUpperCase()
116
117 return { kind, label: label || '·' }
118}
119
120export function badgeColors(kind: FileKind, isLight: boolean): BadgeColors {
121 const owner = KIND_ROLE[kind]
122
123 if (owner !== null) {
124 const { fg, tint } = palette(isLight).roles[owner]
125 return { fg, tint }
126 }
127
128 return OWN_BADGES[kind === 'doc' ? 'doc' : 'misc'][isLight ? 'light' : 'dark']
129}
130
131// The CSS variable names a badge paints with inside an Svg (see svgStyle).
132export function badgePaints(kind: FileKind): { fg: string; tint: string } {
133 const owner = KIND_ROLE[kind]
134 const name = owner ?? `file-${kind === 'doc' ? 'doc' : 'misc'}`
135
136 return { fg: name, tint: `${name}-tint` }
137}
138
139// ---------------------------------------------------------------------------
140// Terminal: theme keys, never hex (DESIGN.md §2). Meta text is `dimColor`, not a key.
141
142export type ThemeKey = 'suggestion' | 'claude' | 'permission' | 'success' | 'warning' | 'error' | 'merged' | 'subtle'
143
144export const ROLE_KEY: Record<Role, ThemeKey> = {
145 read: 'suggestion',
146 edit: 'claude',
147 run: 'permission',
148 ok: 'success',
149 warn: 'warning',
150 danger: 'error',
151 plan: 'merged',
152}
153
154export const LINE_KEY: ThemeKey = 'subtle'
155
156// ---------------------------------------------------------------------------
157// Motion (DESIGN.md §5).
158
159export const MOTION = {
160 quickMs: 120,
161 stateMs: 200,
162 travelMs: 320,
163 ease: 'cubic-bezier(.2,.8,.2,1)',
164 breatheS: 1.8,
165 shimmerS: 1.4,
166 breatheLow: 0.45,
167} as const
168
169// ---------------------------------------------------------------------------
170// Svg style: every colour as a CSS variable for both schemes, a fill (`f-*`) and stroke
171// (`s-*`) class per variable, the two loops, and the reduced-motion rule last.
172
173const kebab = (name: string): string => name.replace(/[A-Z]/g, char => `-${char.toLowerCase()}`)
174
175export function paintVars(isLight: boolean): Record<string, string> {
176 const p = palette(isLight)
177 const vars: Record<string, string> = {}
178 const { roles, ...neutrals } = p
179
180 for (const [name, hex] of Object.entries(neutrals)) vars[kebab(name)] = hex
181 for (const name of ROLES) {
182 vars[name] = roles[name].fg
183 vars[`${name}-tint`] = roles[name].tint
184 vars[`${name}-line`] = roles[name].line
185 }
186 for (const kind of ['doc', 'misc'] as const) {
187 const own = OWN_BADGES[kind][isLight ? 'light' : 'dark']
188 vars[`file-${kind}`] = own.fg
189 vars[`file-${kind}-tint`] = own.tint
190 }
191
192 return vars
193}
194
195export const PAINTS = Object.keys(paintVars(false))
196
197const LOOPS: Record<string, string> = {
198 breathe: `@keyframes breathe{50%{opacity:${MOTION.breatheLow}}}.breathe{animation:breathe ${MOTION.breatheS}s ease-in-out infinite}`,
199 shimmer: `@keyframes shimmer{0%,100%{opacity:1}50%{opacity:.4}}.shimmer{animation:shimmer ${MOTION.shimmerS}s linear infinite}`,
200}
201
202export const FONT = `-apple-system, BlinkMacSystemFont, 'SF Pro Text', Inter, 'Segoe UI', system-ui, sans-serif`
203export const MONO = `ui-monospace, 'SF Mono', SFMono-Regular, Menlo, Consolas, monospace`
204
205const REDUCED = '@media (prefers-reduced-motion: reduce){*{animation:none!important;transition:none!important}}'
206
207const varBlock = (vars: Record<string, string>, names: readonly string[]): string =>
208 `svg{${names.map(name => `--${name}:${vars[name]}`).join(';')}}`
209
210// The <style> body for an Svg. Given the markup it styles, only the paints it names are
211// written, so a small pill does not carry the whole palette.
212export function svgStyle(body?: string): string {
213 const used = body === undefined ? PAINTS : PAINTS.filter(name => new RegExp(`[fs]-${name}\\b(?!-)`).test(body))
214 const rules = used.map(name => `.f-${name}{fill:var(--${name})}.s-${name}{stroke:var(--${name})}`).join('')
215 const loops = Object.keys(LOOPS)
216 .filter(name => body === undefined || body.includes(name))
217 .map(name => LOOPS[name])
218 .join('')
219
220 return [
221 varBlock(paintVars(false), used),
222 `@media (prefers-color-scheme: light){${varBlock(paintVars(true), used)}}`,
223 `text{font-family:${FONT}}.mono{font-family:${MONO};font-variant-numeric:tabular-nums}`,
224 rules,
225 loops,
226 REDUCED,
227 ].join('')
228}
229
230// ---------------------------------------------------------------------------
231// Light or dark, for Box/Text hex. Adapted from skins' light.ts (mods/skins/hooks/light.ts,
232// MODZ): an override (`MODZ_THEME`) wins, then a theme name that says light or dark, then
233// for `auto` the terminal's COLORFGBG, then the system's appearance. Dark when nothing says.
234
235export type ThemeHints = {
236 override?: string
237 theme?: unknown
238 colorfgbg?: string
239 systemDark?: boolean
240}
241
242const LIGHT_ANSI_BACKGROUNDS = [7, 15]
243
244const named = (value: unknown): boolean | undefined => {
245 const name = typeof value === 'string' ? value.trim().toLowerCase() : ''
246
247 if (name.includes('light')) return true
248
249 return name.includes('dark') ? false : undefined
250}
251
252const terminalLight = (colorfgbg: string | undefined): boolean | undefined => {
253 const background = Number(colorfgbg?.split(';').at(-1))
254
255 return colorfgbg === undefined || Number.isNaN(background) ? undefined : LIGHT_ANSI_BACKGROUNDS.includes(background)
256}
257
258export const resolveLight = (hints: ThemeHints): boolean =>
259 named(hints.override) ??
260 named(hints.theme) ??
261 terminalLight(hints.colorfgbg) ??
262 (hints.systemDark === undefined ? false : !hints.systemDark)
263