Band above the prompt for long context (nudging /_handoff) and for a cold prompt cache (a button that hands off without reloading); holds the first message…

Personal AI OS — a global CLI harness and desktop application for AI agent workspace automation.
agy).When submitting a prompt to the agy engine, you can toggle between Triage Mode and Worker Bee Mode using the "Pre-triage Mode" checkbox in the UI. Depending on the mode selected, the AI receives vastly different operational instructions.
When Pre-triage is unchecked, the agent operates in Worker Bee Mode. In this mode, the agent receives the full suite of system instructions and acts as a direct contributor.
Key Characteristics:
rm -rf, enforcing code constraints, and requiring precise edits)../tmp) to avoid permission prompts during file modifications.agent-logs/ file mapping out the goal, changes, and architectural discoveries made during the execution.When Pre-triage is checked, the agent operates in Triage Mode. In this mode, the agent acts as an architectural supervisor and task delegator, not a direct coder.
Key Characteristics:
create_child_thread (or equivalent sub-agent tools) to spawn fresh, scoped conversations for each sub-task. This prevents the primary context from snowballing out of control.This mode should be explicitly toggled on for complex or extensive refactoring requests where delegating work to individual "worker bee" threads will yield cleaner results.
Historically, the system rules were defined globally via a GEMINI.md file. With the introduction of multi-mode prompt routing, the GEMINI.md file is intentionally left empty. The operational rules have been migrated to src/systemPromptConfig.ts within the AI-OS source code, enabling dynamic injection at runtime based on the selected mode.
The ai-os workspace is composed of several key components:
bin/ai-os, .zshrc_aios) that intercepts destructive commands and injects quiet-run wrappers.xterm.js to render multiplexed background processes via Rust bindings (src/main.ts, src-tauri/src/main.rs).scripts/telemetry_db.py).scripts/precision_edit.py, scripts/subagent.py).scripts/memory_search.sh, scripts/memory_diff.sh).scripts/append_system_rule.py).agent-logs/ conforming to the Indexed Handoff Protocol (scripts/context_handoff.py).tree-sitter) for deeper code structure understanding.hooks/register.tsx 730 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { PromptMedia, PromptSubmitAttachment, PromptSubmitInput, PromptSubmitResult } from 'claude-code'
5
6import type { Cache, Fill, Handoff } from '../types'
7
8// Band shows from WARN; turns red from ALERT. Toasts once per threshold crossed.
9// Set high on purpose: a warm thread re-reads its context at the cache price (a tenth), while a
10// new thread pays full price to re-read files plus a minute of ramp-up. Hand off at a stopping
11// point, not mid-task; the nudge only needs to land before a long thread gets slow and sloppy.
12const WARN = 250_000
13const ALERT = 400_000
14const TOAST_AT = [250_000, 400_000, 550_000]
15
16// Cache line: only worth showing when a reload would cost something.
17const CACHE_MIN_TOKENS = 30_000
18const CACHE_SOON_MS = 15 * 60_000 // start the countdown this long before expiry
19const DEFAULT_TTL_MS = 60 * 60_000 // every request on this Mac so far used the 1-hour cache
20const SEND_ANYWAY_MS = 10 * 60_000 // after a held message, sends go through this long
21
22const fill = atom({ plugin: 'context-alert', key: 'fill' } as const, null)
23const lastToast = atom({ plugin: 'context-alert', key: 'lastToast' } as const, 0)
24const cache = atom({ plugin: 'context-alert', key: 'cache' } as const, null)
25const now = atom({ plugin: 'context-alert', key: 'now' } as const, 0)
26const handoff = atom({ plugin: 'context-alert', key: 'handoff' } as const, null)
27const sendAnywayUntil = atom({ plugin: 'context-alert', key: 'sendAnywayUntil' } as const, 0)
28const held = atom({ plugin: 'context-alert', key: 'held' } as const, '')
29const projectHeld = atom({ plugin: 'context-alert', key: 'projectHeld' } as const, '')
30const inProject = atom({ plugin: 'context-alert', key: 'inProject' } as const, false)
31const threadId = atom({ plugin: 'context-alert', key: 'threadId' } as const, '')
32const backLinked = atom({ plugin: 'context-alert', key: 'backLinked' } as const, false)
33const queued = atom({ plugin: 'context-alert', key: 'queued' } as const, [])
34const lastReply = atom({ plugin: 'context-alert', key: 'lastReply' } as const, '')
35
36export const k = (n: number) => `${Math.round(n / 1000)}k`
37
38export const ago = (ms: number) => {
39 const min = Math.round(ms / 60_000)
40 if (min < 60) return `${min} min`
41 const h = Math.floor(min / 60)
42 return h < 48 ? `${h}h` : `${Math.floor(h / 24)} days`
43}
44
45/** Minutes left on the cache (negative once expired), or null when it isn't worth a word. */
46export const cacheLeft = (c: Cache | null, at: number, tokens: number) => {
47 if (c === null || tokens < CACHE_MIN_TOKENS) return null
48 return c.lastAt + c.ttlMs - at
49}
50
51// The app runs an installed copy from ~/.claude/plugins/cache, so point at the repo itself.
52const AIOS = '/Users/matt/projects/ai-os'
53const script = (_$: EngineInterface) => `${AIOS}/scripts/cold_handoff.py`
54const HELD_DIR = `${AIOS}/tmp/held`
55
56const EXT: Record<string, string> = { 'image/jpeg': 'jpg', 'image/png': 'png', 'image/gif': 'gif', 'image/webp': 'webp', 'application/pdf': 'pdf' }
57
58/**
59 * Saves a held message's images and files to disk, so the new thread can open them.
60 * Reading their bytes is answered only to a managed settings install; otherwise each
61 * comes back by name as one to re-attach.
62 */
63export const saveAttachments = async (
64 $: EngineInterface, items: readonly PromptSubmitAttachment[], at: number,
65): Promise<{ saved: string[]; missed: string[] }> => {
66 const saved: string[] = []
67 const missed: string[] = []
68 for (const [i, item] of items.entries()) {
69 const name = item.filename ?? `${item.type} ${i + 1}`
70 let b64 = ''
71 let mediaType = item.mediaType ?? ''
72 let offset: number | undefined = 0
73 while (item.id !== undefined && offset !== undefined) {
74 const part: PromptMedia = await $.prompt.media({ id: item.id, offset }).catch(() => ({ deny: 'failed' }))
75 if (part.base64 === undefined) { b64 = ''; break }
76 b64 += part.base64
77 mediaType = part.mediaType
78 offset = part.next
79 }
80 if (!b64) { missed.push(name); continue }
81 const ext = EXT[mediaType] ?? mediaType.split('/')[1] ?? 'bin'
82 const out = `${HELD_DIR}/${at}-${i + 1}.${ext}`
83 await $.process.run(['mkdir', '-p', HELD_DIR])
84 const run = await $.process.run(['base64', '-D', '-o', out], { stdin: b64 })
85 if (run.exitCode === 0) saved.push(out)
86 else missed.push(name)
87 }
88 return { saved, missed }
89}
90
91/** The held message as the new thread should read it: the text, then what came with it. */
92export const withFiles = (text: string, files: { saved: string[]; missed: string[] }) => {
93 const lines = [text]
94 if (files.saved.length) lines.push(`(Attached in the old thread, saved here: ${files.saved.join(', ')})`)
95 if (files.missed.length) lines.push(`(Not carried over, ask me to re-attach: ${files.missed.join(', ')})`)
96 return lines.join('\n\n')
97}
98
99/** Claude project threads deliver each message wrapped in a <wake> envelope. */
100export const isProjectMessage = (text: string) => text.trimStart().startsWith('<wake')
101
102const unescape = (t: string) =>
103 t.replace(/</g, '<').replace(/>/g, '>').replace(/"|"/g, '"').replace(/'/g, "'").replace(/&/g, '&')
104
105/** What Matt wrote, and where, out of a project <wake> envelope. */
106export const wakeParts = (text: string) => {
107 const body = /<message trigger="true"[^>]*>([\s\S]*?)<\/message>/.exec(text)?.[1]
108 const id = /<message trigger="true"[^>]*\bid="(cmsg_[A-Za-z0-9]+)"/.exec(text)?.[1]
109 const thread = /<thread\b[^>]*\b(?:ts|id)="(cmsg_[A-Za-z0-9]+)"/.exec(text)?.[1]
110 return { body: unescape((body ?? text).trim()), id, thread }
111}
112
113/** The coordinator's note, after it started the fresh thread, naming that thread. The mod answers
114 * it itself so this cold thread's model never runs. */
115export const handedOff = (text: string) =>
116 isCoordinatorRelay(text) ? /context-alert handed-off:\s*(cmsg_[A-Za-z0-9]+)/.exec(text)?.[1] : undefined
117
118/** A coordinator relay. Remote Control delivers it as a task notification wrapped in
119 * <project_claude_message>; checking for a bare <relay> missed that, so the old thread never got
120 * its "handed off" line (2026-10-22). */
121export const isCoordinatorRelay = (text: string) =>
122 /^\s*(?:<project_claude_message\b[^>]*>\s*)?<relay from="coordinator"/.test(text)
123
124/** The cold handoff file a fresh thread's brief tells it to read. */
125export const handoffPath = (text: string) =>
126 isCoordinatorRelay(text) ? /(\/[^\s"'`<>]*\/docs\/handoffs\/[^\s"'`<>]*-cold(?:_\d+)?\.md)/.exec(text)?.[1] : undefined
127
128/** The marker cold_handoff.py writes at the top of a project thread's handoff, naming the old thread. */
129export const parentThread = (file: string) => /context-alert parent-thread:\s*(cmsg_[A-Za-z0-9]+)/.exec(file)?.[1]
130
131/** A project-chat message (a <wake> with no <thread>) that isn't this thread's own root. The
132 * server hands a thread started on a coordinator post the latest project-chat mention as if it
133 * were the root, so the thread answered Matt's unrelated message (2026-10-22). */
134export const isStrayWake = (text: string, thread: string) => {
135 if (!thread || !isProjectMessage(text)) return false
136 const w = wakeParts(text)
137 return w.thread === undefined && w.id !== undefined && w.id !== thread
138}
139
140/** This thread's id, out of a hearthbot reply's result. */
141export const replyThread = (result: unknown) =>
142 /thread_id\\?"\s*:\s*\\?"(cmsg_[A-Za-z0-9]+)/.exec(JSON.stringify(result ?? ''))?.[1]
143
144/** Which project wake this is: mention, reactions, message-edited, ... */
145export const wakeReason = (text: string) => /^\s*<wake\b[^>]*\breason="([^"]+)"/.exec(text)?.[1] ?? ''
146
147/** Emoji a person (not an agent) put on a message, from a reactions wake. Reactions and edits
148 * arrive as system turns (isMeta), not as Matt typing, so the person-only gate never saw them. */
149export const reactionEmoji = (text: string) =>
150 [...text.matchAll(/<reaction\b[^>]*\bemoji="([^"]+)"[^>]*\breactors="(user_[^"]*)"/g)].map(m => m[1])
151
152/** What a wake means for a held message: Matt's answer, an edit of the held text, or neither. */
153export const heldWake = (text: string, heldId?: string): 'answer' | 'edit' | 'other' => {
154 const reason = wakeReason(text)
155 if (reason === 'reactions') return reactionEmoji(text).length ? 'answer' : 'other'
156 if (reason === 'message-edited') return heldId !== undefined && wakeParts(text).id === heldId ? 'edit' : 'other'
157 return 'other'
158}
159
160/** A reply that only picks an option, so it adds nothing for the new thread to act on. */
161const bareAnswer = (body: string) =>
162 /^\W*(fresh|new( thread)?|yes|yep|yeah|ok(ay)?|sure|do it|go for it|sounds good|👍|fresh thread)?\W*$/iu.test(body)
163
164/** Matt's answer to a held message. A fresh thread from the handoff is the default (it starts on
165 * its own once the handoff is written, Matt 2026-10-22; 👍, "fresh" or any other reply sends it
166 * sooner); only ⏩ or "here"/"go" continues in this cold thread. */
167export const wantsFresh = (text: string) =>
168 !(/⏩|fast_forward/.test(text) || /^\W*(here|go)\W*$/i.test(wakeParts(text).body))
169
170/** ❌ on the hold note: drop the held message. ✋ (wait, typing more) or 👀: keep holding. */
171export const heldReaction = (text: string): 'cancel' | 'wait' | null => {
172 const emoji = reactionEmoji(text).join('')
173 if (/❌|✖/.test(emoji) && !/⏩|👍/.test(emoji)) return 'cancel'
174 if (/✋|👀/.test(emoji) && !/⏩|👍/.test(emoji)) return 'wait'
175 return null
176}
177
178/** The thread reply that tells Matt his message is moving to a fresh thread. */
179export const heldReply = (expiredMs: number, tokens: number) =>
180 `❄️ **Cache expired ${ago(expiredMs)} ago, so this moves to a fresh thread.** Nothing sent here; ` +
181 `Sonnet is writing the handoff.\n\n⏩ continue here instead (re-reads ~${k(tokens)} tokens)\n\n❌ drop it`
182
183/** The line the old thread ends on, once the coordinator says where the work went. */
184export const handedOffReply = (thread: string) =>
185 `➡️ **Handed off to [the new thread](#${thread}) because the cache expired.**`
186
187/** The Claude app link that opens a project thread; its project id is the root id's prefix. */
188export const threadLink = (thread: string) =>
189 `claude://code/project/chan_${thread.slice(5, 29)}?thread=${thread}`
190
191/** Opens the new thread on the Mac once the handoff has begun, behind a light take-control
192 * pill (Esc skips it; clicking the pill opens it too). */
193export const openThreadArgv = (thread: string) => [
194 'sh', '-c',
195 'cc=$HOME/.hammerspoon/bin/claude-control; t=$("$cc" ask "Open the new thread this one handed off to" 3 --light --thread "$1") || exit 0; ' +
196 'open "$2"; "$cc" done "${t#granted }"',
197 'open-thread', thread, threadLink(thread),
198]
199
200/** The line a fresh thread opens with, linking back to the thread it continues. */
201export const continuedReply = (thread: string) =>
202 `⬅️ **Continued from [the old thread](#${thread})** because its cache expired.`
203
204/** What the coordinator gets when a held message moves to a fresh thread. */
205export const freshAsk = (held: string, heldId: string, prompt: string, followUp = '', parent = '') =>
206 `Matt chose a fresh thread for a message he wrote into a cold thread (context-alert held it and ` +
207 `moved it by default; that thread never ran). Please start a new thread on his message ` +
208 `${heldId || '(no id)'} with a brief that says: first do this, then answer him: ${prompt}\n\n` +
209 `His held message: ${held}` +
210 (followUp ? `\n\nHe added, in the same thread while it was held: ${followUp}` : '') +
211 (parent
212 ? `\n\nLinks: keep the handoff file's path in the brief word for word; the mod in the new thread ` +
213 `reads it and posts the link back to ${parent} itself. After starting it, message_thread the old ` +
214 `thread (${parent}) with exactly: context-alert handed-off: <the new thread's id>, even if it is ` +
215 `resolved. The mod there posts the forward link without waking its model, then resolves it again.`
216 : '')
217
218/** Writes a handoff from the transcript file with Sonnet; the thread itself is never re-sent. */
219const coldHandoff = async ($: EngineInterface, open: boolean): Promise<Handoff> => {
220 await update($, handoff, () => ({ status: 'writing', text: 'Writing handoff with Sonnet (about a minute)…' }))
221 const sid = await $.session.id()
222 // Carry the message the person meant to send: the one held, else the draft in the box.
223 const draft = (await $.prompt.read()).text.trim()
224 const carry = (await read($, held)) || (draft.startsWith('/') ? '' : draft)
225 const pending = await read($, projectHeld)
226 const parent = pending ? (JSON.parse(pending) as Held).thread ?? '' : ''
227 const argv = ['python3', script($), 'write', sid, ...(open ? ['--open'] : []), ...(carry ? ['--then', carry] : []),
228 ...(parent ? ['--thread', parent] : [])]
229 let result: Handoff
230 try {
231 const run = await $.process.run(argv, { timeoutMs: 600_000 })
232 if (run.exitCode === 0) {
233 const { prompt, link } = JSON.parse(run.stdout) as { path: string; prompt: string; link: string }
234 result = { status: 'done', prompt, text: open ? 'New thread opened with the handoff typed in; press Enter to start it.' : `${prompt}\n\n[Open it in a new thread](${link})` }
235 await update($, held, () => '')
236 } else {
237 result = { status: 'error', text: (run.stderr || run.stdout).trim().slice(0, 300) }
238 }
239 } catch (err) {
240 result = { status: 'error', text: String(err).slice(0, 300) }
241 }
242 await update($, handoff, () => result)
243 return result
244}
245
246/** /cold-handoff (and its old names /haiku-handoff and /fresh). */
247const coldCommand = async ($: EngineInterface) => {
248 const result = await coldHandoff($, false)
249 if (result.status === 'error') return { text: `Cold handoff failed: ${result.text}` }
250 return { text: `Handoff written without reloading this thread. Click the link (press Enter there to start), or paste:\n\n${result.text}` }
251}
252
253type Held = { body: string; id?: string; thread?: string }
254
255/** The handoff Sonnet starts writing as soon as a project message is held. */
256let backgroundHandoff: Promise<Handoff> | null = null
257/** Bumped on every hold and every answer, so the automatic move only fires for its own hold. */
258let holdSeq = 0
259/** ✋ on the hold note: don't move it on its own, wait for Matt's next message. */
260let holdWaiting = false
261
262const startHandoff = ($: EngineInterface) => {
263 backgroundHandoff = coldHandoff($, false).catch((err): Handoff => ({ status: 'error', text: String(err) }))
264}
265
266/** Asks the coordinator for a fresh thread from the handoff. The old thread's last line, with the
267 * link, comes when the coordinator answers (see handedOff). */
268const sendFresh = async ($: EngineInterface, h: Held, added = ''): Promise<boolean> => {
269 let job = await (backgroundHandoff ?? coldHandoff($, false))
270 backgroundHandoff = null
271 if (job.status !== 'done') job = await coldHandoff($, false)
272 if (job.status !== 'done') {
273 await $.tool.call({ tool: 'mcp__hearthbot__reply', text: `⚠️ **Handoff failed:** ${job.text}\n\n⏩ continue here, or send anything to retry.` })
274 .catch(() => undefined)
275 return false
276 }
277 await $.tool.call({
278 tool: 'mcp__claude-code-remote__send_message',
279 session_id: '@parent', visibility: 'sent_to_shared_agent',
280 message: freshAsk(h.body, h.id ?? '', job.prompt ?? job.text, added, h.thread ?? ''),
281 }).catch(() => undefined)
282 return true
283}
284
285/** The default: once the handoff is written, move the held message on its own (Matt, 2026-10-22:
286 * no 👍 needed). Skipped if Matt answered, edited or said ✋ meanwhile. */
287const autoFresh = async ($: EngineInterface, seq: number) => {
288 await backgroundHandoff
289 if (seq !== holdSeq || holdWaiting) return
290 const pending = await read($, projectHeld)
291 if (!pending || seq !== holdSeq) return
292 holdSeq++
293 if (await sendFresh($, JSON.parse(pending) as Held)) await update($, projectHeld, () => '')
294 else holdSeq-- // keep it held so ⏩ or a retry still works
295}
296
297/** Matt answered a held project message: send it on here, or hand off to a fresh thread now. */
298const answerHeld = async (
299 $: EngineInterface, e: PromptSubmitInput, next: (e: PromptSubmitInput) => Promise<PromptSubmitResult>,
300 w: Held,
301): Promise<PromptSubmitResult> => {
302 const reaction = wakeReason(e.text) === 'reactions' ? heldReaction(e.text) : null
303 if (reaction === 'wait') {
304 holdWaiting = true
305 return { drop: 'Still held: Matt reacted ✋/👀, waiting for his next message.' }
306 }
307 holdSeq++
308 holdWaiting = false
309 await update($, projectHeld, () => '')
310 if (reaction === 'cancel') {
311 await update($, held, () => '')
312 backgroundHandoff = null
313 await $.tool.call({ tool: 'mcp__hearthbot__reply', text: 'Dropped. Your held message was never sent.' }).catch(() => undefined)
314 return { drop: 'Held message dropped (Matt reacted ❌).' }
315 }
316 if (!wantsFresh(e.text)) {
317 await update($, held, () => '')
318 return next({ ...e, context: [...(e.context ?? []), `[context-alert] Matt said go ahead. His message held while this thread was cold: ${w.body}`] })
319 }
320 const added = wakeReason(e.text) === 'reactions' ? '' : wakeParts(e.text).body
321 if (!(await sendFresh($, w, bareAnswer(added) ? '' : added))) {
322 await update($, projectHeld, () => JSON.stringify(w))
323 return { drop: 'Handoff failed; message still held.' }
324 }
325 return { drop: 'Fresh thread requested from the coordinator; this thread stays cold.' }
326}
327
328// ── Smart queue (Matt, 2026-10-22) ─────────────────────────────────────────────
329// Matt queues a message for this thread ("/q …" in the thread, or the coordinator's
330// "thread-queue add: …" note). It waits until the agent stops; then Haiku reads the agent's
331// last reply and sends the queued text on, unless that reply asks Matt something first.
332// ⏩ on the queued message sends it now, ❌ drops it. The HUD pill reads QUEUE_DIR.
333
334const QUEUE_DIR = `${AIOS}/tmp/thread-queue`
335
336/** "/q text" or "⏳ text" in the thread: the text to queue. "/q send" and "/q drop" act on the queue. */
337export const queueCommand = (body: string): { kind: 'add'; text: string } | { kind: 'send' | 'drop' | 'list' } | undefined => {
338 const m = /^\s*(?:\/q(?:ueue)?\b|⏳)\s*([\s\S]*)$/u.exec(body)
339 if (!m) return undefined
340 const rest = m[1].trim()
341 if (/^(send|now|go)$/i.test(rest)) return { kind: 'send' }
342 if (/^(drop|clear|cancel)$/i.test(rest)) return { kind: 'drop' }
343 if (!rest || /^list$/i.test(rest)) return { kind: 'list' }
344 return { kind: 'add', text: rest }
345}
346
347/** The coordinator's note that queues a message here without waking this thread's model. */
348export const queueNote = (text: string) =>
349 isCoordinatorRelay(text)
350 ? /thread-queue add:\s*([\s\S]*?)\s*(?:<\/note>|$)/.exec(text)?.[1]?.trim() || undefined
351 : undefined
352
353/** The keyword fallback when Haiku can't answer: does the reply ask Matt something? */
354export const asksMatt = (reply: string) => {
355 const lines = reply.split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('>') && !/^Details:/i.test(l))
356 return lines.some(l => /\?(\**|\)|_)*$/.test(l)) ||
357 /👍|❓|\b(reply|react)\b.*\b(to|with)\b|\btry (it|this|that)\b|\b(test|review|check) (it|this|that)\b|let me know|want me to|should I\b|which (one|do you)/i.test(reply)
358}
359
360export const judgePrompt = (reply: string, queuedText: string) =>
361 `An agent working for Matt just finished its turn. Its last message to Matt:\n<reply>\n${reply.slice(-4000)}\n</reply>\n\n` +
362 `Matt queued this message to send once the agent stopped:\n<queued>\n${queuedText.slice(0, 2000)}\n</queued>\n\n` +
363 `Answer WAIT if the agent's message needs Matt first: it asks him a question, offers a choice or a 👍 action, ` +
364 `or asks him to test, try or review something, AND the queued message doesn't answer that. Otherwise answer READY. ` +
365 `Reply with the one word only.`
366
367type Queued = { text: string; id?: string; at: number }
368
369/** True while this session's main agent is mid-turn. */
370let agentRunning = false
371
372const queueFile = async ($: EngineInterface, items: readonly Queued[], waiting: string) => {
373 const thread = (await read($, threadId)) || (await $.session.id())
374 const at = await $.clock.now()
375 const body = JSON.stringify({ thread, items, waiting, updatedAt: at, link: thread.startsWith('cmsg_') ? threadLink(thread) : '' })
376 await $.process.run(['sh', '-c', 'mkdir -p "$1" && cat > "$1/$2.json"', 'thread-queue', QUEUE_DIR, thread], { stdin: body })
377 .catch(() => undefined)
378}
379
380const react = ($: EngineInterface, id: string | undefined, emoji: string, off = false) =>
381 id ? $.tool.call({ tool: off ? 'mcp__hearthbot__unreact' : 'mcp__hearthbot__react', message_id: id, emoji }).catch(() => undefined) : undefined
382
383/** WAIT (with why) or READY for the queued text, from the agent's last reply. */
384const judge = async ($: EngineInterface, items: readonly Queued[]): Promise<string> => {
385 const reply = await read($, lastReply)
386 if (!reply.trim()) return ''
387 const text = items.map(i => i.text).join('\n\n')
388 const r = await $.model.complete({ model: 'haiku', prompt: judgePrompt(reply, text), maxTokens: 5, effort: 'low' })
389 .catch(() => undefined)
390 const word = r && r.isAnswered ? r.text.trim().toUpperCase() : ''
391 const wait = word.startsWith('WAIT') || (!word.startsWith('READY') && asksMatt(reply))
392 return wait ? 'the last reply asks you something' : ''
393}
394
395/** What the agent reads when the queue goes out. */
396export const releasedText = (items: readonly Queued[]) =>
397 `[thread-queue] Matt queued ${items.length === 1 ? 'this message' : 'these messages'} while you were working; ` +
398 `sent now that you've stopped. Treat it as his next message.\n\n` + items.map(i => i.text).join('\n\n---\n\n')
399
400/** Empties the queue and returns the text to send, swapping ⏳ for 📨 on Matt's messages. */
401const takeQueue = async ($: EngineInterface) => {
402 const items = await read($, queued)
403 if (!items.length) return ''
404 await update($, queued, () => [])
405 await queueFile($, [], '')
406 for (const i of items) { await react($, i.id, '⏳', true); await react($, i.id, '📨') }
407 return releasedText(items)
408}
409
410/** Re-checks the queue once the agent stopped (from turn.complete): send it, or keep it and say why. */
411const checkQueue = async ($: EngineInterface) => {
412 const items = await read($, queued)
413 if (!items.length || agentRunning) return
414 const waiting = await judge($, items)
415 if (agentRunning || (await read($, queued)).length !== items.length) return
416 if (waiting) { await queueFile($, items, waiting); return }
417 const text = await takeQueue($)
418 if (text) void $.prompt.submit({ text, wait: false })
419}
420
421/** Queues an item. While the agent is idle it's judged at once: the text to send now, or ''. */
422const addToQueue = async ($: EngineInterface, item: Queued): Promise<string> => {
423 await update($, queued, q => [...q, item])
424 await react($, item.id, '⏳')
425 const items = await read($, queued)
426 if (agentRunning) { await queueFile($, items, ''); return '' }
427 const waiting = await judge($, items)
428 if (waiting) { await queueFile($, items, waiting); return '' }
429 return takeQueue($)
430}
431
432/** Handles queue messages; undefined means the message isn't one. */
433const queueSubmit = async (
434 $: EngineInterface, e: PromptSubmitInput, next: (e: PromptSubmitInput) => Promise<PromptSubmitResult>,
435): Promise<PromptSubmitResult | undefined> => {
436 if (e.origin.kind === 'plugin') return undefined
437 const note = queueNote(e.text)
438 if (note !== undefined) {
439 const id = /<cited author="user"[^>]*\bid="(cmsg_[A-Za-z0-9]+)"/.exec(e.text)?.[1]
440 const now = await addToQueue($, { text: note, id, at: await $.clock.now() })
441 if (now) return next({ ...e, text: now })
442 return { drop: 'Queued for when this thread stops (coordinator note); the model never saw it.' }
443 }
444 if (!isProjectMessage(e.text)) return undefined
445 const items = await read($, queued)
446 if (wakeReason(e.text) === 'reactions' && items.length) {
447 const on = (emoji: RegExp) => [...e.text.matchAll(/<reaction\b[^>]*\bemoji="([^"]+)"[^>]*\bmessage_ts="(cmsg_[A-Za-z0-9]+)"[^>]*\breactors="user_/g)]
448 .some(m => emoji.test(m[1]) && items.some(i => i.id === m[2]))
449 if (on(/❌|✖/)) {
450 await update($, queued, () => [])
451 await queueFile($, [], '')
452 for (const i of items) await react($, i.id, '⏳', true)
453 return { drop: 'Queue dropped (Matt reacted ❌).' }
454 }
455 if (on(/⏩|fast_forward/)) {
456 if (agentRunning) { await update($, lastReply, () => ''); return { drop: 'Queue sends when this turn ends (Matt reacted ⏩).' } }
457 return next({ ...e, text: await takeQueue($) })
458 }
459 return undefined
460 }
461 if (wakeReason(e.text) !== 'mention') return undefined
462 const w = wakeParts(e.text)
463 const cmd = queueCommand(w.body)
464 if (cmd === undefined) return undefined
465 if (cmd.kind === 'add') {
466 const now = await addToQueue($, { text: cmd.text, id: w.id, at: await $.clock.now() })
467 if (now) return next({ ...e, text: now })
468 return { drop: 'Queued for when this thread stops.' }
469 }
470 if (cmd.kind === 'drop') {
471 await update($, queued, () => [])
472 await queueFile($, [], '')
473 for (const i of items) await react($, i.id, '⏳', true)
474 await react($, w.id, '👍')
475 return { drop: 'Queue dropped (/q drop).' }
476 }
477 if (cmd.kind === 'send') {
478 await react($, w.id, '👍')
479 if (!agentRunning && items.length) return next({ ...e, text: await takeQueue($) })
480 await update($, lastReply, () => '') // nothing to wait for: it goes when the turn ends
481 return { drop: 'Queue sends when this turn ends (/q send).' }
482 }
483 const list = items.length ? items.map((i, n) => `${n + 1}. ${i.text.slice(0, 80)}`).join('\n\n') : 'Nothing queued.'
484 await $.tool.call({ tool: 'mcp__hearthbot__reply', text: `⏳ **Queued here:**\n\n${list}` }).catch(() => undefined)
485 return { drop: 'Listed the queue (/q).' }
486}
487
488export const register: Register = on => {
489 on('turn.start', async ($, e, next) => {
490 if (e.agentId === undefined) agentRunning = true
491 return next(e)
492 })
493
494 // A decision card always needs Matt.
495 on('tool.call', { tool: 'mcp__hearthbot__ask_decision' }, async ($, e, next) => {
496 const out = await next(e)
497 if (!out.isError) await update($, lastReply, () => `${JSON.stringify(e).slice(0, 1500)}\n\nWhich option? 👍`)
498 return out
499 })
500
501 on('session.start', async ($, e, next) => {
502 const started = await next(e)
503 await $.command.register({
504 name: 'cold-handoff',
505 description: 'Sonnet writes the handoff from the saved transcript; this thread is never re-sent',
506 })
507 await $.command.register({ name: 'haiku-handoff', description: 'Old name for /cold-handoff' })
508 await $.command.register({ name: 'fresh', description: 'Old name for /cold-handoff' })
509 $.clock.every(60_000, () => {
510 void $.clock.now().then(t => update($, now, () => t))
511 })
512 // A resumed thread: read when it last answered, and its cache lifetime, from its file.
513 void (async () => {
514 const t = await $.clock.now()
515 await update($, now, () => t)
516 const run = await $.process.run(['python3', script($), 'status', await $.session.id()])
517 if (run.exitCode !== 0) return
518 const s = JSON.parse(run.stdout) as { lastAt: number | null; ttlSec: number; tokens: number; project?: boolean }
519 if (s.project) await update($, inProject, () => true)
520 if (s.lastAt === null) return
521 const fromFile: Cache = { lastAt: s.lastAt, ttlMs: s.ttlSec * 1000, tokens: s.tokens }
522 await update($, cache, c => c ?? fromFile)
523 })().catch(() => undefined)
524 return started
525 })
526
527 on('session.measure', async ($, e, next) => {
528 const { tokens, window } = e.context
529 if (e.changed.includes('context') && tokens !== undefined) {
530 const current: Fill = { tokens, window }
531 await update($, fill, () => current)
532
533 const crossed = TOAST_AT.filter(t => tokens >= t).pop() ?? 0
534 const shown = await read($, lastToast)
535 if (crossed > shown) {
536 $.ui.toast(`Context is ${k(tokens)} tokens. At your next stopping point, consider /_handoff.`)
537 }
538 if (crossed !== shown) await update($, lastToast, () => crossed)
539 }
540 return next(e)
541 })
542
543 on('turn.complete', async ($, e, next) => {
544 if (e.agentId !== undefined) return next(e)
545 const at = await $.clock.now()
546 const tokens = (await read($, fill))?.tokens ?? 0
547 await update($, cache, c => ({ lastAt: at, ttlMs: c?.ttlMs ?? DEFAULT_TTL_MS, tokens }))
548 await update($, now, () => at)
549 const out = await next(e)
550 agentRunning = false
551 // Smart queue: a stopped or failed turn never releases it; Matt looks first.
552 if (e.reason === 'answer') void checkQueue($)
553 else if ((await read($, queued)).length) await queueFile($, await read($, queued), 'the agent stopped or failed')
554 return out
555 })
556
557 // Phone fallback: the band doesn't draw on mobile, so hold the first message sent into a
558 // cold thread and say what it would cost. Sending again within 10 min goes through.
559 on('prompt.submit', async ($, e, next) => {
560 if (e.command !== undefined) return next(e)
561 // The smart queue goes first, so a queued message is never held as "cold" or seen by the model.
562 const queuedOut = await queueSubmit($, e, next)
563 if (queuedOut !== undefined) return queuedOut
564 if (isStrayWake(e.text, await read($, threadId))) {
565 return { drop: `Not this thread's message (${wakeParts(e.text).id}); the coordinator handles project-chat messages.` }
566 }
567 const moved = handedOff(e.text)
568 if (moved !== undefined) {
569 await $.tool.call({ tool: 'mcp__hearthbot__reply', text: handedOffReply(moved) }).catch(() => undefined)
570 // The note reopens a resolved thread; a handed-off thread is done, so close it again.
571 await $.tool.call({ tool: 'mcp__hearthbot__set_thread_resolved', resolved: true }).catch(() => undefined)
572 void $.process.run(openThreadArgv(moved), { timeoutMs: 300_000 }).catch(() => undefined)
573 return { drop: `Handed off to ${moved}; the coordinator's note was answered by the mod.` }
574 }
575 // A fresh thread from a cold handoff: its first line links back to the thread it continues.
576 const path = handoffPath(e.text)
577 if (path !== undefined && !(await read($, backLinked))) {
578 await update($, backLinked, () => true)
579 const grep = await $.process.run(['grep', '-m1', 'context-alert parent-thread:', path]).catch(() => undefined)
580 const parent = parentThread(grep?.stdout ?? '')
581 if (parent !== undefined) {
582 await $.tool.call({ tool: 'mcp__hearthbot__reply', text: continuedReply(parent) }).catch(() => undefined)
583 }
584 }
585 const isPerson = ['composer', 'bridge', 'sdk'].includes(e.origin.kind) && !e.isMeta
586 // A project thread is known by any earlier <wake>, not only this message's: after the Mac
587 // reconnects, a queued message can arrive without the envelope and was held the Code-tab
588 // way, so a second message slipped through as "send again" (2026-10-22).
589 if (isProjectMessage(e.text)) await update($, inProject, () => true)
590 const project = isProjectMessage(e.text) || (await read($, inProject))
591 const pending = project ? await read($, projectHeld) : ''
592 if (pending) {
593 const h = JSON.parse(pending) as Held
594 const kind = isPerson ? 'answer' : heldWake(e.text, h.id)
595 if (kind === 'answer') return answerHeld($, e, next, h)
596 if (kind === 'edit') {
597 // Matt edited the held message: hold the new text instead and rewrite the handoff.
598 const edited = { ...h, body: wakeParts(e.text).body }
599 await update($, projectHeld, () => JSON.stringify(edited))
600 await update($, held, () => edited.body)
601 startHandoff($)
602 void autoFresh($, ++holdSeq)
603 return { drop: 'Held message edited; still held with the new text.' }
604 }
605 }
606 if (!isPerson) return next(e)
607
608 const at = await $.clock.now()
609 const c = await read($, cache)
610 const tokens = Math.max((await read($, fill))?.tokens ?? 0, c?.tokens ?? 0)
611 const left = cacheLeft(c, at, tokens)
612 if (left === null || left > 0 || at < (await read($, sendAnywayUntil))) return next(e)
613
614 // A Claude project thread: the Code tab's hold note never shows on the project page, so the
615 // mod says it in the thread itself and moves the message to a fresh thread unless Matt says ⏩.
616 if (project) {
617 await $.process.run(['mkdir', '-p', HELD_DIR]).catch(() => undefined)
618 await $.process.run(['sh', '-c', `cat >> ${HELD_DIR}/holds.log`], { stdin: `${new Date(at).toISOString()} ${JSON.stringify(e.text.slice(0, 300))}\n` })
619 .catch(() => undefined)
620 const w = wakeParts(e.text)
621 const posted = await $.tool.call({ tool: 'mcp__hearthbot__reply', text: heldReply(-left, tokens) })
622 .then(() => true, () => false)
623 if (!posted) return next(e)
624 await update($, projectHeld, () => JSON.stringify(w))
625 await update($, held, () => w.body)
626 holdWaiting = false
627 startHandoff($)
628 void autoFresh($, ++holdSeq)
629 return { drop: `Held (project thread): cache expired ${ago(-left)} ago, ~${k(tokens)} tokens. Moving it to a fresh thread.` }
630 }
631
632 await update($, sendAnywayUntil, () => at + SEND_ANYWAY_MS)
633 const files = await saveAttachments($, e.attachments ?? [], at)
634 await update($, held, () => withFiles(e.text, files))
635 const extra = files.missed.length
636 ? ` ${files.missed.length} attachment(s) can't be carried over; re-attach them there.`
637 : ''
638 await $.prompt.fill({ text: e.text, mode: 'replace' }).catch(() => undefined)
639 return {
640 drop:
641 `Held: this thread's cache expired ${ago(-left)} ago, so this message would re-send ` +
642 `~${k(tokens)} tokens at full price. Send it again to go ahead, or send /cold-handoff ` +
643 `to hand off to a new thread cheaply (this message goes with it).` + extra,
644 }
645 })
646
647 // Learn this project thread's id from its first reply, for isStrayWake.
648 on('tool.call', { tool: 'mcp__hearthbot__reply' }, async ($, e, next) => {
649 const out = await next(e)
650 const id = out.isError ? undefined : replyThread(out.result)
651 if (id !== undefined && !(await read($, threadId))) await update($, threadId, () => id)
652 // The smart queue reads the agent's latest reply to decide whether Matt goes first.
653 const text = String((e as { text?: unknown }).text ?? '')
654 if (!out.isError && e.agentId === undefined && !text.startsWith('⏳ **Queued here')) await update($, lastReply, () => text)
655 return out
656 })
657
658 on('command.run', { command: 'cold-handoff' }, coldCommand)
659 on('command.run', { command: 'haiku-handoff' }, coldCommand)
660 on('command.run', { command: 'fresh' }, coldCommand)
661
662 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
663 if (e.props.hasSurvey) return next(e)
664 const current = await read($, fill)
665 const c = await read($, cache)
666 await read($, now) // subscribes the band to the minute tick
667 const at = await $.clock.now()
668 const job = await read($, handoff)
669 const tokens = Math.max(current?.tokens ?? 0, c?.tokens ?? 0)
670 const left = cacheLeft(c, at, tokens)
671 const showCache = left !== null && left <= CACHE_SOON_MS
672 const showSize = current !== null && current.tokens >= WARN
673 if (!showCache && !showSize && job === null) return next(e)
674
675 const { Box, Button, Text } = $.ui.resolve(e)
676 const isAlert = current !== null && current.tokens >= ALERT
677 const isCold = left !== null && left <= 0
678
679 const hint = isCold
680 ? `This agent would re-read ~${k(tokens)} at full price; Sonnet reads the saved file instead.`
681 : 'This agent: best notes, cheap while the cache is warm. Sonnet: never re-sends the thread.'
682
683 return (
684 <Box flexDirection="column">
685 {showSize && current !== null && (
686 <Text bold color={isAlert ? 'error' : 'warning'}>
687 ⚠ Context: {k(current.tokens)} / {k(current.window)} (
688 {isAlert ? 'hand off at the next stopping point' : 'getting long'})
689 </Text>
690 )}
691 {showCache && left !== null && (
692 <Text bold color={isCold ? 'error' : 'warning'}>
693 {isCold
694 ? `❄ Cache expired ${ago(-left)} ago: next message re-sends ~${k(tokens)} tokens at full price`
695 : `⏳ Cache expires in ${ago(left)}`}
696 </Text>
697 )}
698 {(showSize || showCache) && (
699 <Box>
700 <Button
701 key="handoff"
702 label="Handoff by this agent"
703 variant={isCold ? 'secondary' : 'primary'}
704 onPress={() => $.prompt.fill({ text: '/_handoff', mode: 'replace' })}
705 />
706 <Text> </Text>
707 <Button
708 key="sonnet"
709 label="Handoff by Sonnet"
710 variant={isCold ? 'primary' : 'secondary'}
711 onPress={() => void coldHandoff($, true)}
712 />
713 <Text dimColor> {hint}</Text>
714 </Box>
715 )}
716 {job !== null && (
717 <Box>
718 <Text color={job.status === 'error' ? 'error' : undefined} dimColor={job.status === 'writing'}>
719 {job.status === 'done' ? `✓ ${job.text} ` : `${job.text} `}
720 </Text>
721 {job.status !== 'writing' && (
722 <Button key="dismiss" label="OK" onPress={() => update($, handoff, () => null)} />
723 )}
724 </Box>
725 )}
726 </Box>
727 )
728 })
729}
730types/index.d.ts 35 lines1export type Fill = { tokens: number; window: number }
2
3/** When the main thread last got a model response, and how long its prompt cache lives. */
4export type Cache = { lastAt: number; ttlMs: number; tokens: number }
5
6/** The cold handoff the band's button or /fresh started. */
7export type Handoff = { status: 'writing' | 'done' | 'error'; text: string; prompt?: string }
8
9declare module 'claude-code' {
10 interface PluginState {
11 'context-alert': {
12 fill: Fill | null
13 lastToast: number
14 cache: Cache | null
15 now: number
16 handoff: Handoff | null
17 sendAnywayUntil: number
18 /** The message held in a cold thread, carried into the /fresh handoff. */
19 held: string
20 /** A project message held in a cold thread (JSON of { body, id }) until Matt says go or fresh. */
21 projectHeld: string
22 /** This session is a Claude project thread (it has had a <wake> message). */
23 inProject: boolean
24 /** This project thread's root message id, learned from its first hearthbot reply. */
25 threadId: string
26 /** This fresh thread already posted its "continued from" link. */
27 backLinked: boolean
28 /** Messages Matt queued for when this thread's agent stops (smart queue). */
29 queued: { text: string; id?: string; at: number }[]
30 /** The agent's latest thread reply, which the queue reads to decide whether Matt goes first. */
31 lastReply: string
32 }
33 }
34}
35