SLOPSHOPPER

context-alert

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…

bandguardcommandtoastpromptmodel
v0.5.0no licenseupdated 2026-10-26mattdanielmurphy/ai-os/mods/context-alert
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · context-alert
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /cold-handoff ⎿ context-alert: Cold handoff failed: SyntaxError: JSON Parse error: Unexpected identifier "dev" SyntaxError: JSON Parse error: Unexpected identifier "dev" [ OK ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
SyntaxError: JSON Parse error: Unexpected identifier "dev" [ OK ]
README

AI OS

Personal AI OS — a global CLI harness and desktop application for AI agent workspace automation.

Features

  • Multi-Engine Support: Choose between Deepseek V4 Flash (Claude Code) and Gemini (agy).
  • Workspace Automation: Easily switch between active projects and manage sessions.
  • Triage Mode vs. Worker Bee Mode: Advanced prompt routing system for handling tasks of varying complexity.

Engine Modes (Triage vs. Worker Bee)

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.

1. Worker Bee Mode (Default)

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:

  • Expected to write code directly, run terminal commands, and perform actions on the local filesystem.
  • Receives strict rules on file editing (e.g., prohibiting rm -rf, enforcing code constraints, and requiring precise edits).
  • Uses a local temporary folder (./tmp) to avoid permission prompts during file modifications.
  • At the end of its session, it creates an agent-logs/ file mapping out the goal, changes, and architectural discoveries made during the execution.
  • Capable of context self-healing by triggering automated handoffs for complex tasks.

2. Triage Mode

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:

  • Analyzes the user's prompt to determine if it is complex, multi-part, or requires a long-running process.
  • Strictly prohibited from executing the coding tasks itself or modifying the codebase directly.
  • Deconstructs the original request into a set of bounded, highly specific sub-tasks.
  • Uses tools like 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.
  • Carefully preserves the user's original constraints, intent, quoted text, file paths, and explicit code blocks when delegating to subagents.
  • Summarizes the results once the delegated subagents complete their work.

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.

Configuration & Customization

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.

Architecture Overview

The ai-os workspace is composed of several key components:

  • Orchestration & Bootloading: A shell-wrapping bootloader (bin/ai-os, .zshrc_aios) that intercepts destructive commands and injects quiet-run wrappers.
  • Tauri GUI & PTY Layer: A React/TS frontend utilizing xterm.js to render multiplexed background processes via Rust bindings (src/main.ts, src-tauri/src/main.rs).
  • Cost & Quota Telemetry Engine: Sub-model cost math and token refresh flows (scripts/telemetry_db.py).
  • Triage Editing System: Surgical text mutations and LLM-driven patch applications (scripts/precision_edit.py, scripts/subagent.py).
  • Git Memory Pipeline: Multi-layer indexing for safe history retrieval (scripts/memory_search.sh, scripts/memory_diff.sh).
  • Dynamic Rules Injection: Manages context routing to active models and global rulesets (scripts/append_system_rule.py).
  • Automated Context Handoff: Creates standardized context log files in agent-logs/ conforming to the Indexed Handoff Protocol (scripts/context_handoff.py).

Roadmap & Planned Features

  • Feature Backlog: The .devtool/features directory contains active todo items and planned features formatted as markdown files.
  • VSCode Extension Transition: Migrating from the standalone Tauri GUI to a native VSCode Extension to take advantage of stable terminals, complete editor features, autocomplete, and native markdown rendering. See vscode-transition-plan.md for details.
  • Stable Anchor Context Strategy: Active planning for request orchestration, caching efficiency, and codebase structure indexing (see stable-anchor-context-strategy.md).
  • Codebase Ingestion Parser (AST Upgrades): Upgrading from regex/while-loop parsing to formal AST parsing (e.g., tree-sitter) for deeper code structure understanding.
  • Browser Extension / Web Context Sync: Siphoning web sessions directly into the local repo.
  • Automated Auth Rotation Daemon: Headless account-swapping component to rotate Google Accounts.
  • Semantic Thought Layer: Integrating local vector embeddings and native macOS automation (JXA/AppleScript wrappers).
Source 2 files
hooks/register.tsx 730 lines
1import { 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(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;|&#34;/g, '"').replace(/&#39;/g, "'").replace(/&amp;/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}
730
types/index.d.ts 35 lines
1export 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