Context and usage meters, auto-resume, model router, agent cap, proof cards, context health, midnight and god modes, ship guard, Windows overlay

A Claude Code mod that keeps you and Claude aware of context and usage limits, picks the right model for each job, and keeps working while you're away, safely.
At the prompt of a Claude Code terminal session:
/plugin install mission-control --marketplace ShagnikPal123/mission-control
Answer y to add the marketplace and pick a scope. It loads straight away, and in the desktop Code tab too.
Band above the chat box
1) opens the side panel.Side panel
@doc buttons.Limits and resuming
Router
Proof card: every turn that changed files ends with ✓ tests 12 pass · ✓ build · ✗ not browser-checked · ✓ committed · – not pushed.
Context health: scores each turn. When quality drops significantly as context fills, or the trend says it soon will, Claude writes .mission-control/HANDOFF.md, the session compacts, and work continues from the handoff.
Split: a message holding several separate requests runs as one task at a time. Say "same chat" to keep them together.
Language: fixes common typos (whole words, never inside code or paths), adds a glossary of your own terms, and includes an interpret-owner skill.
/midnight [hours] [--ship]: an unattended run.
MIDNIGHT_LOG.md, keeps going, and stops at the time limit, the weekly budget, MIDNIGHT_DONE, or 3 errors in a row.--ship, which also runs the secret scan./god [minutes]: no permission prompts for a limited time, after a confirmation pop-up.
/ship: tests, build, the never-ship check (AI_HANDOFF/, .env, keys, plus your own patterns from /ship never <pattern>), secret scan, commit, push, deploy. Every git push is checked against the never-ship list, even outside /ship.
Projects: /project add <name> = <folder> registers a project. In the desktop app, each chat is filed under "<project> · Updates" or "<project> · Questions".
Overlay (Windows): a slim pill at the top centre of the screen while Claude isn't the window in use. Hover to see the details; Open Claude brings Claude back. It needs Python on PATH. /mc overlay off turns it off.
| Command | What it does | |||||||
|---|---|---|---|---|---|---|---|---|
/mc | State, and switches: panel (open the side panel), `model pin <haiku\ | sonnet\ | opus>, model auto, router on\ | off, opus on\ | off, agents <n>, resume on\ | off, alerts on\ | off, overlay on\ | off, typo <wrong> <right>, glossary <term> = <meaning>, tokens` |
/midnight | [hours] [--ship], off, status | |||||||
/god | [minutes], off, log | |||||||
/look | Quote the text you selected into the prompt and ask Claude to check it | |||||||
/ship | /ship, /ship never <pattern>, /ship never | |||||||
/project | /project, /project add <name> = <folder> |
Personal words, docs and projects live in ~/.claude/mission-control/config.json, which is never published:
{
"glossary": { "second mind": "the Second Brain panel" },
"docs": ["AI_HANDOFF/START_HERE.md"],
"projects": [{ "name": "My App", "path": "C:/code/my-app" }]
}
claude plugin validate .
claude plugin test .
python -m unittest discover overlay
Mods run with your full user permissions. Read the code before you install any mod, this one included.
MIT
hooks/register.tsx 1706 lines1import { atom, read, update } from 'claude-code'
2import type { AgentInfo, EngineInterface, PromptSubmitInput, Register, SessionMeasureInput } from 'claude-code'
3
4import type { Effort, Limited, Meter, Model, MissionSettings, Win } from '../types'
5import { DEFAULT_SETTINGS, LIMIT_LABEL, STORE, WINDOW_LABEL } from './constants'
6import { CLASSIFY_LABELS, fromLabel, pickAgent, REPORT_RULE } from './agents'
7import { biasRoute, effortUp, isEffort, isModel, isSkippable, MODEL_ID, parseRoute, ROUTER_PROMPT, routeLine, stepUp } from './route'
8import { classifyCall, deletesOutside, gitRepoOf, hasSecret, isOwnerOrigin, isPushOrDeploy, midnightStop, parseMidnightArgs, shellCommand } from './risk'
9import { BREVITY, flagTurn, tokenReportText, tokenSummary, wordCount } from './waste'
10import { CORRECTION, healthAction, healthLabel, scoreTurn } from './health'
11import { meterBar } from './zones'
12import { autocorrect, DEFAULT_GLOSSARY, glossaryHints } from './lang'
13import { autoProject, DEFAULT_NEVER, groupNameFor, projectFor, shipText, trackedForbidden } from './projects'
14import type { Project } from './projects'
15import { proofCard } from './proof'
16import type { ToolRecord } from './proof'
17import { nextTaskText, parseTasks, splitLine, tasksFromReply, wantsTogether } from './split'
18import { bandSegments, formatMeter, formatReset, pace, sparkline, topPct, ZONE_COLOR, zoneOf } from './zones'
19
20// Mission Control. Everything that touches `$` lives in this one module: the
21// engine follows `$` only into functions of the same file and allows one
22// unmatched hook per event. Pure logic lives beside it (zones.ts, ...).
23
24// ── State ────────────────────────────────────────────────────────────────
25const meterAtom = atom({ plugin: 'mission-control', key: 'meter' } as const, null)
26const ackedAtom = atom({ plugin: 'mission-control', key: 'acked' } as const, false)
27const pulseAtom = atom({ plugin: 'mission-control', key: 'pulseOn' } as const, false)
28const routeAtom = atom({ plugin: 'mission-control', key: 'route' } as const, null)
29const limitedAtom = atom({ plugin: 'mission-control', key: 'limited' } as const, null)
30const midnightAtom = atom({ plugin: 'mission-control', key: 'midnight' } as const, null)
31const settingsAtom = atom({ plugin: 'mission-control', key: 'settings' } as const, DEFAULT_SETTINGS)
32const turnsAtom = atom({ plugin: 'mission-control', key: 'turns' } as const, [])
33const godAtom = atom({ plugin: 'mission-control', key: 'god' } as const, null)
34const godLogAtom = atom({ plugin: 'mission-control', key: 'godLog' } as const, [])
35const progressAtom = atom({ plugin: 'mission-control', key: 'progress' } as const, { done: 0, total: 0 })
36const commsAtom = atom({ plugin: 'mission-control', key: 'comms' } as const, [])
37const healthAtom = atom({ plugin: 'mission-control', key: 'health' } as const, [])
38const splitAtom = atom({ plugin: 'mission-control', key: 'split' } as const, [])
39const proofAtom = atom({ plugin: 'mission-control', key: 'proof' } as const, null)
40const trendAtom = atom({ plugin: 'mission-control', key: 'trend' } as const, [])
41const staleAtom = atom({ plugin: 'mission-control', key: 'staleMeter' } as const, false)
42const agentMetaAtom = atom({ plugin: 'mission-control', key: 'agentMeta' } as const, {})
43const notesAtom = atom({ plugin: 'mission-control', key: 'notes' } as const, [])
44const agentsLiveAtom = atom({ plugin: 'mission-control', key: 'agentsLive' } as const, 0)
45
46// Module memory: what the owner and the agents are on (rebuilt after a reload).
47let lastOwnerPrompt = ''
48let currentTodo: string | null = null
49const taskSubjects = new Map<string, string>()
50let statusFileFailed = false
51let pulseTimer: { cancel: () => void } | null = null
52let lastOwnerAt: number | null = null // when the owner last typed; null until they do this session
53let turnRecords: ToolRecord[] = [] // this turn's tool calls, for the proof card and health
54let refreshStage: 'idle' | 'asked' = 'idle'
55let nudgeQueued = false // one plugin prompt per turn end: health, then split, then midnight
56let splitQueue: string[] = []
57let splitTotal = 0
58let ownerTypos: Record<string, string> = {}
59let projects: Project[] = []
60let neverShip: string[] = [...DEFAULT_NEVER]
61let sessionCwd = ''
62let sorted = false // this chat filed under its project group
63let sessionKey = ''
64let docs: string[] = ['.mission-control/HANDOFF.md', 'MIDNIGHT_LOG.md']
65let ownerGlossary: Record<string, string> = { ...DEFAULT_GLOSSARY }
66
67/** The clock, or the wall clock where a host has none. */
68async function nowOr($: EngineInterface): Promise<number> {
69 try {
70 return await $.clock.now()
71 } catch {
72 return Date.now()
73 }
74}
75
76// ── MissionSettings ─────────────────────────────────────────────────────────────
77async function loadSettings($: EngineInterface): Promise<MissionSettings> {
78 let stored: unknown
79 try {
80 stored = await $.store.get(STORE.settings)
81 } catch {
82 stored = undefined
83 }
84 const merged: MissionSettings = {
85 ...DEFAULT_SETTINGS,
86 ...(typeof stored === 'object' && stored !== null ? (stored as Partial<MissionSettings>) : {}),
87 }
88 await update($, settingsAtom, () => merged)
89 return merged
90}
91
92// ── A. Resource meter ────────────────────────────────────────────────────
93function win(e: SessionMeasureInput, kind: string): Win | null {
94 const w = e.rateLimits.find(r => r.kind === kind)
95 if (w === undefined) return null
96 return w.resetsAt === undefined ? { pct: w.percentUsed } : { pct: w.percentUsed, resetsAt: w.resetsAt }
97}
98
99function toMeter(e: SessionMeasureInput, at: number): Meter {
100 return {
101 ctxPct: e.context.percent ?? null,
102 fiveHour: win(e, 'five_hour'),
103 weekly: win(e, 'seven_day'),
104 costUsd: e.cost?.usd ?? null,
105 at,
106 }
107}
108
109/** Shared with the Phase 2 pane and the Phase 5 overlay; never fatal. */
110async function writeStatusFile($: EngineInterface): Promise<void> {
111 try {
112 let agents: AgentInfo[] = []
113 try {
114 agents = await $.agent.list()
115 } catch {
116 agents = []
117 }
118 const live = agents.filter(a => a.status === 'pending' || a.status === 'running' || a.status === 'waiting').length
119 await update($, agentsLiveAtom, () => live)
120 const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME'))
121 if (home === undefined) return
122 const status = {
123 meter: await read($, meterAtom),
124 route: await read($, routeAtom),
125 limited: await read($, limitedAtom),
126 midnight: await read($, midnightAtom),
127 god: await read($, godAtom),
128 settings: await read($, settingsAtom),
129 agents,
130 }
131 await $.fs.write(`${home.replace(/\\/g, '/')}/.claude/mission-control/status.json`, JSON.stringify(status, null, 2))
132 } catch (err) {
133 if (!statusFileFailed) {
134 statusFileFailed = true
135 $.ui.log(`mission-control: status.json not written (${String(err).slice(0, 120)})`)
136 await update($, notesAtom, n => [...n, 'status.json could not be written (overlay will be blank)'].slice(-5))
137 }
138 }
139}
140
141async function meterOnMeasure($: EngineInterface, e: SessionMeasureInput): Promise<void> {
142 const meter = toMeter(e, Date.now())
143 await update($, meterAtom, () => meter)
144 await update($, staleAtom, () => false)
145 if (meter.ctxPct !== null) {
146 const c = meter.ctxPct
147 await update($, trendAtom, t => (t.at(-1) === c ? t : [...t, c].slice(-12)))
148 }
149 try {
150 await $.store.set(STORE.lastMeter, meter)
151 if (meter.costUsd !== null && sessionKey !== '') {
152 const day = new Date().toISOString().slice(0, 10)
153 const days = ((await $.store.get(STORE.costDays)) ?? {}) as Record<string, Record<string, number>>
154 days[day] = { ...(days[day] ?? {}), [sessionKey]: meter.costUsd }
155 const keep = Object.keys(days).sort().slice(-14)
156 await $.store.set(STORE.costDays, Object.fromEntries(keep.map(k => [k, days[k] ?? {}])))
157 }
158 } catch {
159 // The cache is a convenience.
160 }
161 $.ui.status(formatMeter(meter))
162 await writeStatusFile($)
163}
164
165// ── Band pulse ───────────────────────────────────────────────────────────
166/** Toggles once a second while in danger and not acknowledged. */
167function startPulse($: EngineInterface): void {
168 try {
169 pulseTimer?.cancel()
170 pulseTimer = $.clock.every(1000, () => {
171 void (async () => {
172 const zone = zoneOf(topPct(await read($, meterAtom)))
173 if (zone !== 'danger') {
174 if (await read($, ackedAtom)) await update($, ackedAtom, () => false)
175 if (await read($, pulseAtom)) await update($, pulseAtom, () => false)
176 return
177 }
178 const acked = await read($, ackedAtom)
179 await update($, pulseAtom, lit => (acked ? false : !lit))
180 })()
181 })
182 } catch {
183 // No clock (a headless host): the band simply does not pulse.
184 }
185}
186
187// ── E. Alerts ────────────────────────────────────────────────────────────
188function currentTask(): string {
189 if (currentTodo !== null) return currentTodo
190 if (lastOwnerPrompt !== '') return lastOwnerPrompt.replace(/\s+/g, ' ').slice(0, 40)
191 return 'idle'
192}
193
194/** The owner's own prompt: typed, from the phone, or with no origin stamped. */
195function isOwner(e: PromptSubmitInput): boolean {
196 const kind = e.origin?.kind
197 return kind === undefined || kind === 'composer' || kind === 'bridge'
198}
199
200function noteOwnerPrompt(e: PromptSubmitInput): void {
201 if (isOwner(e)) lastOwnerPrompt = e.text
202}
203
204/** Once per key (a window and threshold); push to the phone, else a toast. */
205async function sendAlert($: EngineInterface, key: string, text: string): Promise<'pushed' | 'toasted' | 'skipped'> {
206 const settings = await read($, settingsAtom)
207 if (!settings.alerts) return 'skipped'
208 let alerted: string[] = []
209 try {
210 const stored = await $.store.get(STORE.alerted)
211 if (Array.isArray(stored)) alerted = stored.filter((k): k is string => typeof k === 'string')
212 } catch {
213 alerted = []
214 }
215 if (alerted.includes(key)) return 'skipped'
216 try {
217 await $.store.set(STORE.alerted, [...alerted, key].slice(-200))
218 } catch {
219 // Without the store an alert may repeat after a restart; better than none.
220 }
221 try {
222 const ran = await $.tool.call({ tool: 'PushNotification', message: text.slice(0, 200), status: 'proactive' })
223 if (ran.deny === undefined) return 'pushed'
224 } catch {
225 // Fall through to the toast.
226 }
227 $.ui.toast(text, { timeoutMs: 8000 })
228 return 'toasted'
229}
230
231async function alertsOnMeasure($: EngineInterface, e: SessionMeasureInput): Promise<void> {
232 for (const w of e.rateLimits) {
233 const label = WINDOW_LABEL[w.kind]
234 if (label === undefined || w.percentUsed < 90) continue
235 await sendAlert(
236 $,
237 `${w.kind}:${w.resetsAt ?? 'none'}:90`,
238 `${label} at 90% · resets ${formatReset(w.resetsAt, new Date())} · now: ${currentTask()}`,
239 )
240 }
241}
242
243// ── B. Limit guard and auto-resume ───────────────────────────────────────
244const MIN = 60_000
245const GOOD_MORNING =
246 "Good morning — usage has reset. Continue where you left off, using any messages I sent while you were paused, and keep going on your own until I'm back."
247const RESUME_TEXT = 'Auto-resumed after the usage reset. Continue where you left off.'
248const RETRY = /^\s*(try again|continue|retry)[.!]?\s*$/i
249let resumeTimer: { cancel: () => void } | null = null
250
251const atTime = (ms: number, now: number) => formatReset(new Date(ms).toISOString(), new Date(now))
252
253function limitFrom(kind: string, resetsAt: string | undefined, now: number): Limited {
254 const reset = resetsAt === undefined ? Number.NaN : Date.parse(resetsAt)
255 return {
256 kind: kind === 'seven_day' ? 'seven_day' : 'five_hour',
257 until: Number.isNaN(reset) ? now + 15 * MIN : reset + MIN,
258 resetsAt: resetsAt ?? null,
259 }
260}
261
262async function sessionId($: EngineInterface): Promise<string | undefined> {
263 try {
264 return await $.session.id()
265 } catch {
266 return undefined
267 }
268}
269
270async function saveLimit($: EngineInterface, l: Limited | null): Promise<void> {
271 await update($, limitedAtom, () => l)
272 try {
273 if (l === null) await $.store.delete(STORE.resumeAt)
274 else await $.store.set(STORE.resumeAt, { ...l, sid: await sessionId($) })
275 } catch {
276 // The timer still runs this session; only a restart would lose it.
277 }
278}
279
280async function onLimitHit($: EngineInterface, kind: string, resetsAt: string | undefined): Promise<void> {
281 const now = await $.clock.now()
282 const l = limitFrom(kind, resetsAt, now)
283 const prev = await read($, limitedAtom)
284 if (prev !== null && now < prev.until) return
285 await saveLimit($, l)
286 const when = atTime(l.until, now)
287 $.ui.toast(`${LIMIT_LABEL[l.kind]} limit reached · resets ${when} · I'll continue automatically.`, { timeoutMs: 10000 })
288 let running = ''
289 try {
290 running = (await $.agent.list())
291 .filter(a => a.status === 'running')
292 .map(a => ` · ${a.description}: running`)
293 .join('')
294 } catch {
295 running = ''
296 }
297 await sendAlert($, `${l.kind}:${l.resetsAt ?? l.until}:limit`, `Limit hit · resumes ${when} · main: ${currentTask()}${running}`)
298 await armResume($, l)
299}
300
301async function armResume($: EngineInterface, l: Limited): Promise<void> {
302 resumeTimer?.cancel()
303 const now = await $.clock.now()
304 resumeTimer = $.clock.after(Math.max(0, l.until - now), () => {
305 void fireResume($)
306 })
307}
308
309async function fireResume($: EngineInterface): Promise<void> {
310 resumeTimer = null
311 const l = await read($, limitedAtom)
312 if (l === null) return
313 const settings = await read($, settingsAtom)
314 const midnight = await read($, midnightAtom)
315 if (!settings.resume && midnight === null) {
316 await saveLimit($, null)
317 return
318 }
319 // No request is made while we wait, so the last reading stays at 100: trust it
320 // only while the window's own reset time is still ahead.
321 let stillLimited = false
322 try {
323 const t = await $.clock.now()
324 stillLimited = (await $.session.usage()).rateLimits.some(
325 r => r.percentUsed >= 100 && r.resetsAt !== undefined && Date.parse(r.resetsAt) > t,
326 )
327 } catch {
328 stillLimited = false
329 }
330 if (stillLimited) {
331 const again: Limited = { ...l, until: (await $.clock.now()) + 15 * MIN }
332 await saveLimit($, again)
333 await armResume($, again)
334 return
335 }
336 await saveLimit($, null)
337 await sendAlert($, `${l.kind}:${l.until}:resumed`, `Resumed after reset · continuing ${currentTask()}`)
338 const now = await nowOr($)
339 if (lastOwnerAt !== null && now - lastOwnerAt >= 30 * MIN) {
340 await $.prompt.submit({ text: GOOD_MORNING })
341 await startAutoRun($)
342 return
343 }
344 await $.prompt.submit({ text: RESUME_TEXT })
345}
346
347async function guardOnMeasure($: EngineInterface, e: SessionMeasureInput): Promise<void> {
348 const hit = e.rateLimits.find(r => r.percentUsed >= 100)
349 if (hit !== undefined) {
350 await onLimitHit($, hit.kind, hit.resetsAt)
351 return
352 }
353 if (e.rateLimits.length > 0 && (await read($, limitedAtom)) !== null) {
354 resumeTimer?.cancel()
355 resumeTimer = null
356 await saveLimit($, null)
357 }
358}
359
360async function guardOnError($: EngineInterface): Promise<void> {
361 const meter = await read($, meterAtom)
362 const near = [
363 { kind: 'five_hour', w: meter?.fiveHour },
364 { kind: 'seven_day', w: meter?.weekly },
365 ].find(x => (x.w?.pct ?? 0) >= 99)
366 if (near !== undefined) await onLimitHit($, near.kind, near.w?.resetsAt)
367}
368
369async function rearmFromStore($: EngineInterface): Promise<void> {
370 let stored: unknown
371 try {
372 stored = await $.store.get(STORE.resumeAt)
373 } catch {
374 return
375 }
376 if (typeof stored !== 'object' || stored === null || typeof (stored as Limited).until !== 'number') return
377 const l = stored as Limited
378 const sid = await sessionId($)
379 if (l.sid !== undefined && sid !== undefined && l.sid !== sid) return
380 await update($, limitedAtom, () => l)
381 await armResume($, l)
382}
383
384/** A bare "try again" while limited would only fail again: hold it. */
385async function holdRetry($: EngineInterface, e: PromptSubmitInput): Promise<string | null> {
386 if (e.origin?.kind === 'plugin' || !RETRY.test(e.text)) return null
387 const l = await read($, limitedAtom)
388 const now = await $.clock.now()
389 if (l === null || now >= l.until) return null
390 return `Still limited until ${atTime(l.until, now)}. Auto-resume is queued.`
391}
392
393// ── C. Model/effort router (main agent) ──────────────────────────────────
394let turnFails = 0
395let streakBumped = false
396let modelRaised = false
397let turnModel: Model | null = null
398let turnPinned = false
399let turnEffort: Effort | null = null
400
401/** Context lines for an owner's prompt: the route, then the resources. */
402async function routeOnSubmit($: EngineInterface, e: PromptSubmitInput): Promise<string[]> {
403 const meter = await read($, meterAtom)
404 const resources = `[resources] ${formatMeter(meter)}`
405 const settings = await read($, settingsAtom)
406 if (!isOwner(e) || !settings.router || isSkippable(e.text)) return [resources]
407 const midnight = await read($, midnightAtom)
408 const r = await $.model.complete({
409 model: 'haiku',
410 effort: 'low',
411 maxTokens: 400,
412 timeoutMs: 8000,
413 prompt: ROUTER_PROMPT(e.text.slice(0, 4000), formatMeter(meter)),
414 })
415 const parsed = r.isAnswered ? parseRoute(r.text) : null
416 if (parsed === null) {
417 await update($, routeAtom, () => null)
418 $.ui.toast('router: kept current model', { timeoutMs: 3000 })
419 return [resources]
420 }
421 const route = biasRoute(parsed, meter?.fiveHour?.pct ?? 0, settings.pin, midnight === null ? 80 : 70)
422 await update($, routeAtom, () => route)
423 const tasks = parseTasks(tasksFromReply(r.isAnswered ? r.text : ''))
424 if (tasks.length > 1 && !wantsTogether(e.text)) {
425 splitQueue = tasks.slice(1)
426 splitTotal = tasks.length
427 await update($, splitAtom, () => [...splitQueue])
428 await sortChat($, route.kind)
429 return [routeLine(route), splitLine(tasks), resources]
430 }
431 await sortChat($, route.kind)
432 return [routeLine(route), resources]
433}
434
435function resetTurn(): void {
436 turnFails = 0
437 streakBumped = false
438 modelRaised = false
439 turnModel = null
440 turnEffort = null
441}
442
443function noteToolResult(isError: boolean): void {
444 if (isError) {
445 turnFails += 1
446 } else {
447 turnFails = 0
448 streakBumped = false
449 }
450}
451
452/**
453 * What the main loop's next request should name, or null to leave it. The
454 * model is chosen on the turn's first step only (a switch drops the prompt
455 * cache); later steps may only escalate after failures.
456 */
457async function stepChoice($: EngineInterface, index: number, effort: unknown): Promise<{ model: string; effort?: Effort } | null> {
458 if (index === 0) {
459 const settings = await read($, settingsAtom)
460 if (!settings.router) {
461 turnModel = null
462 return null
463 }
464 const route = await read($, routeAtom)
465 const base: Model | undefined = settings.pin ?? route?.model
466 if (base === undefined) {
467 turnModel = null
468 return null
469 }
470 turnModel = base
471 turnPinned = settings.pin !== null
472 turnEffort = route?.effort ?? (isEffort(effort) ? effort : null)
473 } else {
474 if (turnModel === null) return null
475 if (turnFails >= 3 && !modelRaised && !turnPinned) {
476 turnModel = stepUp(turnModel)
477 modelRaised = true
478 }
479 if (turnFails >= 2 && !streakBumped && turnEffort !== null) {
480 turnEffort = effortUp(turnEffort)
481 streakBumped = true
482 }
483 }
484 const model = MODEL_ID[turnModel]
485 return turnEffort === null ? { model } : { model, effort: turnEffort }
486}
487
488// ── C2. Subagents: model per job, agent cap, short reports ───────────────
489const agentEffort = new Map<string, Effort>()
490let spawning = 0
491const LIVE = new Set(['pending', 'running', 'waiting'])
492
493async function effectiveCap($: EngineInterface): Promise<number> {
494 if ((await read($, midnightAtom)) !== null) return 2
495 if (((await read($, meterAtom))?.fiveHour?.pct ?? 0) >= 85) return 1
496 return (await read($, settingsAtom)).agentCap
497}
498
499async function runningCount($: EngineInterface): Promise<number> {
500 try {
501 return (await $.agent.list()).filter(a => LIVE.has(a.status)).length
502 } catch {
503 return 0
504 }
505}
506
507async function chooseAgent($: EngineInterface, subagentType: string, prompt: string): Promise<{ model: Model; effort: Effort }> {
508 const picked = pickAgent(subagentType, prompt, (await read($, settingsAtom)).preferOpus)
509 if (picked !== null) return picked
510 try {
511 const label = await $.model.classify(prompt.slice(0, 2000), CLASSIFY_LABELS)
512 const chosen = fromLabel(label)
513 if (chosen !== null) return chosen
514 } catch {
515 // Fall back below.
516 }
517 return { model: 'sonnet', effort: 'medium' }
518}
519
520// ── D. Token waste ───────────────────────────────────────────────────────
521let lastAnswer: string | null = null
522
523async function recordTurn($: EngineInterface, turnId: string, answer: string, outputTokens: number): Promise<void> {
524 const route = await read($, routeAtom)
525 const words = wordCount(answer)
526 const size = route?.size ?? null
527 const flag = flagTurn({ words, size }, lastAnswer, answer)
528 lastAnswer = answer
529 await update($, turnsAtom, list => [...list, { turnId, outputTokens, words, size, flag }].slice(-50))
530}
531
532// ── G. Midnight mode ─────────────────────────────────────────────────────
533const HOUR = 60 * MIN
534let midnightOn = false // a sync mirror, so a failed guard can fail closed only while on
535const NUDGE_TEXT =
536 'Midnight: continue with the next unfinished item. If none is left: optimize and test what was built this run, then write new ideas to MIDNIGHT_LOG.md under Ideas and build the small, safe ones.'
537
538/** Queue a prompt once the current hook has returned (a command cannot wait on its own turn). */
539function submitSoon($: EngineInterface, text: string): void {
540 $.clock.after(250, () => {
541 void $.prompt.submit({ text })
542 })
543}
544
545function midnightSection(ship: boolean): string {
546 return [
547 'The owner is away (midnight mode). Do not ask questions: choose the recommended option and keep going.',
548 'Log each non-trivial decision as one line in MIDNIGHT_LOG.md at the project root: time, decision, reason.',
549 `Do not push, deploy, publish, or delete outside the project${ship ? ' except as allowed by --ship after the secret scan' : ''}.`,
550 'When nothing is left to do and optimisation/tests are done, end your message with MIDNIGHT_DONE.',
551 ].join('\n')
552}
553
554async function startMidnight($: EngineInterface, args: string): Promise<string> {
555 const parsed = parseMidnightArgs(args)
556 if (parsed === 'off') {
557 await stopMidnight($, 'stopped by owner')
558 return 'Midnight off.'
559 }
560 const now = await $.clock.now()
561 await update($, midnightAtom, () => ({ endsAt: now + parsed.hours * HOUR, budgetPct: 85, ship: parsed.ship, errorsInRow: 0, turns: 0 }))
562 midnightOn = true
563 try {
564 await $.mcp.call('ccd_host', 'request_keep_awake', {})
565 } catch {
566 // Keep-awake is best effort; the run still goes while the PC is awake.
567 }
568 submitSoon($, NUDGE_TEXT)
569 return `🌙 Midnight on for ${parsed.hours}h · weekly budget 85%${parsed.ship ? ' · shipping allowed' : ''}.`
570}
571
572async function stopMidnight($: EngineInterface, why: string): Promise<void> {
573 const m = await read($, midnightAtom)
574 midnightOn = false
575 if (m === null) return
576 await update($, midnightAtom, () => null)
577 submitSoon(
578 $,
579 `Midnight run ended (${why}). Write the morning report at the end of MIDNIGHT_LOG.md: done, failed, decisions, ideas. 120 words max. Then stop.`,
580 )
581 await sendAlert($, `midnight:${m.endsAt}:done`, `🌙 Midnight done (${why}) · ${m.turns} turns · see MIDNIGHT_LOG.md`)
582}
583
584async function midnightOnTurn($: EngineInterface, reason: string, answer: string): Promise<void> {
585 const m = await read($, midnightAtom)
586 if (m === null || reason === 'aborted') return
587 if ((await read($, limitedAtom)) !== null) return // the guard resumes; not an error of the run
588 const next = { ...m, turns: m.turns + 1, errorsInRow: reason === 'error' || reason === 'refusal' ? m.errorsInRow + 1 : 0 }
589 await update($, midnightAtom, () => next)
590 const meter = await read($, meterAtom)
591 const why = midnightStop(next, await $.clock.now(), meter?.weekly?.pct ?? 0, answer)
592 if (why !== null) {
593 await stopMidnight($, why)
594 return
595 }
596 if ((meter?.ctxPct ?? 0) >= 70) {
597 try {
598 await $.session.compact()
599 } catch {
600 // Auto-compact still catches it later.
601 }
602 }
603 // A running agent's completion notice starts the next turn; nudging now
604 // would only spin empty turns while it works.
605 if ((await runningCount($)) > 0 || nudgeQueued) return
606 nudgeQueued = true
607 submitSoon($, NUDGE_TEXT)
608}
609
610/** Why a shell command is refused during midnight, or null. */
611async function midnightGuard($: EngineInterface, command: string): Promise<string | null> {
612 const m = await read($, midnightAtom)
613 if (m === null) return GIT_PUSH.test(command) ? await shipCheck($, command, false) : null
614 if (isPushOrDeploy(command)) {
615 if (!m.ship) return 'Midnight: push/deploy needs --ship.'
616 const refused = await shipCheck($, command, true)
617 if (refused !== null) return refused
618 }
619 if (deletesOutside(command, await $.session.root())) return 'Midnight: delete outside the project refused.'
620 return null
621}
622
623// ── Q. Local config: the owner's own words, docs and projects ────────────
624/** `~/.claude/mission-control/config.json`: never part of the published mod. */
625async function loadLocalConfig($: EngineInterface): Promise<void> {
626 try {
627 const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME'))
628 if (home === undefined) return
629 const raw = await $.fs.read(`${home.replace(/\\/g, '/')}/.claude/mission-control/config.json`)
630 const c = JSON.parse(raw) as { glossary?: Record<string, string>; docs?: string[]; projects?: Project[] }
631 if (c.glossary !== undefined) ownerGlossary = { ...ownerGlossary, ...c.glossary }
632 if (Array.isArray(c.docs)) docs = [...new Set([...c.docs, ...docs])]
633 if (Array.isArray(c.projects)) projects = [...c.projects, ...projects.filter(p => !c.projects?.some(q => q.name === p.name))]
634 } catch {
635 // No local config: the generic defaults stand.
636 }
637}
638
639// ── P. Overlay: the top-centre pill outside Claude (Windows) ──────────────
640async function launchOverlay($: EngineInterface): Promise<void> {
641 try {
642 if (!(await read($, settingsAtom)).overlay) return
643 if ((await $.env.get('OS')) !== 'Windows_NT') return
644 const script = `${$.plugin.root.replace(/\//g, '\\')}\\overlay\\mc_overlay.py`
645 // `start` detaches it; the overlay keeps a single instance itself.
646 await $.process.run(['cmd', '/c', 'start', '', 'pythonw', script])
647 } catch {
648 await update($, notesAtom, n => [...n, 'overlay did not start (is Python on PATH?)'].slice(-5))
649 }
650}
651
652// ── O. Projects, chat sorting, and the ship check ─────────────────────────
653const GIT_PUSH = /\bgit(?:\.exe)?\b[^\n]*\bpush\b/i
654
655async function loadProjects($: EngineInterface): Promise<void> {
656 try {
657 const p = await $.store.get(STORE.projects)
658 if (Array.isArray(p)) projects = p as Project[]
659 const n = await $.store.get(STORE.never)
660 if (Array.isArray(n)) neverShip = [...new Set([...DEFAULT_NEVER, ...(n as string[])])]
661 } catch {
662 // Defaults only.
663 }
664}
665
666/** Why a push must not go out, or null. `failClosed` refuses when git cannot answer. */
667async function shipCheck($: EngineInterface, command: string, failClosed: boolean): Promise<string | null> {
668 const repo = gitRepoOf(command)
669 const at = repo === undefined ? [] : ['-C', repo]
670 const cannot = failClosed ? 'Midnight: could not verify the push (git check failed); refused.' : null
671 try {
672 const tracked = await $.process.run(['git', ...at, 'ls-files'])
673 if (tracked.exitCode !== 0) return cannot
674 const bad = trackedForbidden(tracked.stdout.split('\n'), neverShip)
675 if (bad.length > 0) {
676 return `Ship check: ${bad.slice(0, 3).join(', ')} is tracked and must never be pushed. Untrack it (git rm --cached) first.`
677 }
678 const outgoing = await $.process.run(['git', ...at, 'log', '-p', 'HEAD', '--not', '--remotes'])
679 if (outgoing.exitCode !== 0) return cannot
680 if (hasSecret(outgoing.stdout)) return 'Ship check: a secret-like string is in the commits to push; push refused.'
681 return null
682 } catch {
683 return cannot
684 }
685}
686
687function mcpText(r: { content: readonly { type: string; text?: string }[] }): string {
688 return r.content.map(c => c.text ?? '').join('')
689}
690
691/** File this chat under "<project> · Updates|Questions", once per session; best effort. */
692async function sortChat($: EngineInterface, kind: string): Promise<void> {
693 if (sorted || sessionCwd === '') return
694 sorted = true
695 const project = projectFor(sessionCwd, projects) ?? autoProject(sessionCwd)
696 const name = groupNameFor(project.name, kind)
697 try {
698 const groups = JSON.parse(mcpText(await $.mcp.call('ccd_sidebar', 'list_groups', {})) || '[]') as { id: string; name: string }[]
699 let id = groups.find(g => g.name === name)?.id
700 if (id === undefined) id = (JSON.parse(mcpText(await $.mcp.call('ccd_sidebar', 'create_group', { name }))) as { id: string }).id
701 await $.mcp.call('ccd_sidebar', 'move_sessions', { session_ids: ['self'], group_id: id })
702 } catch {
703 await update($, notesAtom, n => [...n, `chat not filed under "${name}" (no sidebar here)`].slice(-5))
704 }
705}
706
707async function shipCommand($: EngineInterface, args: string): Promise<string> {
708 const a = args.trim()
709 const never = /^never\s+(\S+)$/.exec(a)
710 if (never?.[1] !== undefined) {
711 neverShip = [...new Set([...neverShip, never[1]])]
712 try {
713 await $.store.set(STORE.never, neverShip.filter(p => !DEFAULT_NEVER.includes(p)))
714 } catch {
715 // Kept for this session.
716 }
717 return `Never shipping: ${never[1]}`
718 }
719 if (a === 'never') return `Never shipped: ${neverShip.join(', ')}`
720 if (a !== '') return 'Usage: /ship | /ship never <pattern> | /ship never'
721 submitSoon($, shipText(neverShip))
722 return 'Ship checklist queued.'
723}
724
725async function projectCommand($: EngineInterface, args: string): Promise<string> {
726 const add = /^add\s+(.+?)\s*=\s*(.+)$/.exec(args.trim())
727 if (add?.[1] !== undefined && add[2] !== undefined) {
728 projects = [...projects.filter(p => p.name !== add[1]), { name: add[1], path: add[2] }]
729 try {
730 await $.store.set(STORE.projects, projects)
731 } catch {
732 // Kept for this session.
733 }
734 return `Project "${add[1]}" = ${add[2]}`
735 }
736 const here = projectFor(sessionCwd, projects) ?? autoProject(sessionCwd)
737 return [`This chat: ${here.name}`, ...projects.map(p => `• ${p.name} — ${p.path}`), 'Add one: /project add <name> = <folder>'].join('\n')
738}
739
740// ── H. God mode ──────────────────────────────────────────────────────────
741let godTimer: { cancel: () => void } | null = null
742
743function callSummary(tool: string, input: unknown): string {
744 const o = typeof input === 'object' && input !== null ? (input as Record<string, unknown>) : {}
745 const pick = o.command ?? o.file_path ?? o.url ?? o.pattern
746 return `${tool} ${typeof pick === 'string' ? pick : JSON.stringify(input) ?? ''}`.replace(/\s+/g, ' ').slice(0, 80)
747}
748
749/** Only after the owner says "Turn on" in the pop-up. */
750async function startGod($: EngineInterface, minutes: number): Promise<string> {
751 const m = Math.max(1, Math.min(480, Math.round(minutes)))
752 let answer = ''
753 try {
754 answer = await $.ui.ask(`Turn on God mode for ${m} min? Claude will run tools without asking. Hard stops still ask.`, ['Turn on', 'Cancel'])
755 } catch {
756 answer = ''
757 }
758 if (answer !== 'Turn on') return 'God mode stays off.'
759 const now = await $.clock.now()
760 await update($, godAtom, () => ({ endsAt: now + m * MIN }))
761 godTimer?.cancel()
762 godTimer = $.clock.after(m * MIN, () => {
763 void stopGod($, 'time up')
764 })
765 $.ui.toast(`⚡ God mode on for ${m} min. Hard stops still ask.`, { timeoutMs: 6000 })
766 return `⚡ God mode on for ${m} min.`
767}
768
769async function stopGod($: EngineInterface, why: string): Promise<void> {
770 godTimer?.cancel()
771 godTimer = null
772 if ((await read($, godAtom)) === null) return
773 await update($, godAtom, () => null)
774 $.ui.toast(`⚡ God mode off (${why}).`, { timeoutMs: 6000 })
775}
776
777/** Haiku's yes/no: does this medium-risk call match what the owner asked for? */
778async function fishyOk($: EngineInterface, tool: string, input: unknown): Promise<boolean> {
779 const o = typeof input === 'object' && input !== null ? (input as Record<string, unknown>) : {}
780 const r = await $.model.complete({
781 model: 'haiku',
782 effort: 'low',
783 maxTokens: 5,
784 timeoutMs: 6000,
785 prompt: [
786 `The owner asked: ${lastOwnerPrompt.slice(0, 1500) || '(nothing recorded)'}`,
787 `The assistant wants to run: ${callSummary(tool, input)}`,
788 `Its stated purpose: ${typeof o.description === 'string' ? o.description : '(none)'}`,
789 'Does this action match what the owner asked for? Answer yes or no.',
790 ].join('\n'),
791 })
792 return r.isAnswered && /^\s*yes\b/i.test(r.text)
793}
794
795/** God mode's verdict for a tool call, or null to leave it to the normal decision. */
796async function godVerdict($: EngineInterface, tool: string, input: unknown): Promise<'allow' | null> {
797 const g = await read($, godAtom)
798 if (g === null) return null
799 const now = await $.clock.now()
800 if (now >= g.endsAt) {
801 await stopGod($, 'time up')
802 return null
803 }
804 let root = ''
805 try {
806 root = await $.session.root()
807 } catch {
808 root = ''
809 }
810 const risk = classifyCall(tool, input, root)
811 if (risk === 'hard-stop') return null
812 if (risk === 'medium' && !(await fishyOk($, tool, input))) return null
813 const stamp = new Date(now).toISOString().slice(11, 16)
814 await update($, godLogAtom, log => [...log, `${stamp} ${callSummary(tool, input)}`].slice(-200))
815 return 'allow'
816}
817
818async function godCommand($: EngineInterface, args: string, ownerKind: string | undefined): Promise<string> {
819 const a = args.trim().toLowerCase()
820 if (a === 'off') {
821 await stopGod($, 'stopped by owner')
822 return 'God mode off.'
823 }
824 if (a === 'log') {
825 const log = await read($, godLogAtom)
826 return log.length === 0 ? 'God mode log is empty.' : log.slice(-20).join('\n')
827 }
828 if (!isOwnerOrigin(ownerKind)) return 'God mode can only be turned on by the owner.'
829 const minutes = Number(/^\d+$/.test(a) ? a : 60)
830 return startGod($, minutes)
831}
832
833// ── F. /mc ───────────────────────────────────────────────────────────────
834const MC_USAGE = 'Usage: /mc [panel | model pin <haiku|sonnet|opus> | model auto | agents <1-10> | resume on|off | alerts on|off | tokens]'
835
836async function saveSettings($: EngineInterface, s: MissionSettings): Promise<void> {
837 await update($, settingsAtom, () => s)
838 try {
839 await $.store.set(STORE.settings, s)
840 } catch {
841 // Holds for this session; it just will not survive a restart.
842 }
843}
844
845async function mcStatus($: EngineInterface): Promise<string> {
846 const settings = await read($, settingsAtom)
847 const route = await read($, routeAtom)
848 const limited = await read($, limitedAtom)
849 const midnight = await read($, midnightAtom)
850 const god = await read($, godAtom)
851 const now = await $.clock.now().catch(() => Date.now())
852 const router = !settings.router ? 'off' : settings.pin !== null ? `pinned ${settings.pin}` : 'on (auto)'
853 const lines = [
854 formatMeter(await read($, meterAtom)),
855 `Router: ${router}${route !== null && settings.router ? ` · ${routeLine(route)}` : ''}`,
856 `Agents: ${await read($, agentsLiveAtom)}/${await effectiveCap($)}`,
857 `Auto-resume: ${settings.resume ? 'on' : 'off'}${limited !== null ? ` · limited until ${atTime(limited.until, now)}` : ''}`,
858 `Alerts: ${settings.alerts ? 'on' : 'off'}`,
859 `Midnight: ${midnight === null ? 'off' : `on · ${Math.max(0, Math.round((midnight.endsAt - now) / MIN))}m left`}`,
860 `God mode: ${god === null ? 'off' : `on · ${Math.max(0, Math.round((god.endsAt - now) / MIN))}m left`}`,
861 ]
862 return lines.join('\n')
863}
864
865async function mcCommand($: EngineInterface, args: string): Promise<string> {
866 const a = args.trim().toLowerCase()
867 const settings = await read($, settingsAtom)
868 if (a === '') return mcStatus($)
869 let m = /^model pin (haiku|sonnet|opus)$/.exec(a)
870 if (m?.[1] !== undefined && isModel(m[1])) {
871 await saveSettings($, { ...settings, pin: m[1], router: true })
872 return `Pinned ${m[1]}.`
873 }
874 const ty = /^typo (\S+) (\S+)$/.exec(args.trim())
875 if (ty?.[1] !== undefined && ty[2] !== undefined) {
876 ownerTypos = { ...ownerTypos, [ty[1].toLowerCase()]: ty[2] }
877 await saveWords($)
878 return `Will correct "${ty[1]}" to "${ty[2]}".`
879 }
880 const gl = /^glossary (.+?)\s*=\s*(.+)$/.exec(args.trim())
881 if (gl?.[1] !== undefined && gl[2] !== undefined) {
882 ownerGlossary = { ...ownerGlossary, [gl[1].toLowerCase()]: gl[2] }
883 await saveWords($)
884 return `Glossary: "${gl[1]}" = ${gl[2]}.`
885 }
886 if (a === 'panel') return openSide($)
887 const ov = /^overlay (on|off)$/.exec(a)
888 if (ov !== null) {
889 await saveSettings($, { ...settings, overlay: ov[1] === 'on' })
890 if (ov[1] === 'on') await launchOverlay($)
891 return `Overlay ${ov[1]}.`
892 }
893 const o = /^opus (on|off)$/.exec(a)
894 if (o !== null) {
895 await saveSettings($, { ...settings, preferOpus: o[1] === 'on' })
896 return `Opus preference ${o[1]}.`
897 }
898 const r = /^router (on|off)$/.exec(a)
899 if (r !== null) {
900 await saveSettings($, { ...settings, router: r[1] === 'on' })
901 return `Router ${r[1]}.`
902 }
903 if (a === 'model auto') {
904 await saveSettings($, { ...settings, pin: null, router: true })
905 return 'Router choosing automatically.'
906 }
907 m = /^agents (\d+)$/.exec(a)
908 const cap = Number(m?.[1])
909 if (m !== null && cap >= 1 && cap <= 10) {
910 await saveSettings($, { ...settings, agentCap: cap })
911 return `Agent cap ${cap}.`
912 }
913 m = /^(resume|alerts) (on|off)$/.exec(a)
914 if (m !== null) {
915 const on = m[2] === 'on'
916 if (m[1] === 'resume') {
917 await saveSettings($, { ...settings, resume: on })
918 return `Auto-resume ${on ? 'on' : 'off'}.`
919 }
920 await saveSettings($, { ...settings, alerts: on })
921 return `Alerts ${on ? 'on' : 'off'}.`
922 }
923 if (a === 'tokens') return tokenReportText(await read($, turnsAtom))
924 return MC_USAGE
925}
926
927async function registerCommands($: EngineInterface): Promise<void> {
928 try {
929 await $.command.register({
930 name: 'god',
931 description: 'God mode: run tools without asking, for a while (confirmation first)',
932 argumentHint: '[minutes] | off | log',
933 })
934 } catch {
935 // A host without slash commands.
936 }
937 try {
938 await $.command.register({
939 name: 'look',
940 description: 'Quote what you selected into the prompt and ask Claude to check it',
941 })
942 await $.command.register({
943 name: 'ship',
944 description: 'Ship: tests, build, never-ship check, secret scan, commit, push, deploy',
945 argumentHint: '| never <pattern> | never',
946 })
947 await $.command.register({
948 name: 'project',
949 description: 'Projects: which project this chat is in; add one',
950 argumentHint: '| add <name> = <folder>',
951 })
952 await $.command.register({
953 name: 'midnight',
954 description: 'Unattended run: AI decides, keeps working, watches budget',
955 argumentHint: '[hours] [--ship] | off | status',
956 })
957 } catch {
958 // A host without slash commands.
959 }
960 try {
961 await $.command.register({
962 name: 'mc',
963 description: 'Mission Control: meters, router, agent cap, resume, alerts, tokens',
964 argumentHint: '[model pin <m>|model auto|agents <n>|resume on|off|alerts on|off|tokens]',
965 immediate: true,
966 })
967 } catch {
968 // A host without slash commands: everything else still runs.
969 }
970}
971
972// ── N. The owner's words: typos and glossary ─────────────────────────────
973async function loadWords($: EngineInterface): Promise<void> {
974 try {
975 const t = await $.store.get(STORE.typos)
976 if (typeof t === 'object' && t !== null) ownerTypos = t as Record<string, string>
977 const g = await $.store.get(STORE.glossary)
978 if (typeof g === 'object' && g !== null) ownerGlossary = { ...DEFAULT_GLOSSARY, ...(g as Record<string, string>) }
979 } catch {
980 // Defaults only.
981 }
982}
983
984async function saveWords($: EngineInterface): Promise<void> {
985 try {
986 await $.store.set(STORE.typos, ownerTypos)
987 await $.store.set(STORE.glossary, ownerGlossary)
988 } catch {
989 // Kept for this session.
990 }
991}
992
993// ── I. Layout: bar, side panel, compact; lock ─────────────────────────────
994const LAYOUTS = ['bar', 'side', 'compact'] as const
995const LAYOUT_NAME: Record<(typeof LAYOUTS)[number], string> = { bar: 'Bar', side: 'Side', compact: 'Compact' }
996const nextLayout = (l: (typeof LAYOUTS)[number]) => LAYOUTS[(LAYOUTS.indexOf(l) + 1) % LAYOUTS.length] ?? 'bar'
997const SIDE = 'mc-side'
998
999/** Opens the side panel; says why when the surface cannot seat it. */
1000async function openSide($: EngineInterface): Promise<string> {
1001 try {
1002 const r = await $.ui.open({ id: SIDE, title: 'Mission Control' })
1003 return r.isPlaced
1004 ? 'Side panel opened.'
1005 : `The side panel could not open: ${r.reason}. Use the Side layout, or widen the window.`
1006 } catch {
1007 return 'This window has no side panels; the band above the chat box has everything.'
1008 }
1009}
1010
1011async function cycleLayout($: EngineInterface): Promise<void> {
1012 const s = await read($, settingsAtom)
1013 if (s.layoutLocked) return
1014 const layout = nextLayout(s.layout)
1015 await saveSettings($, { ...s, layout })
1016 if (layout === 'side') await openSide($)
1017 else if (s.layout === 'side') {
1018 try {
1019 await $.ui.close({ id: SIDE })
1020 } catch {
1021 // Already closed.
1022 }
1023 }
1024}
1025
1026/** The owner's choice in a pop-up, or '' when there is no one to ask. */
1027async function askOwner($: EngineInterface, question: string, options: string[]): Promise<string> {
1028 try {
1029 return await $.ui.ask(question, options)
1030 } catch {
1031 return ''
1032 }
1033}
1034
1035// ── J. Approvals for new agents ──────────────────────────────────────────
1036async function approveSpawn($: EngineInterface, what: string): Promise<boolean> {
1037 const s = await read($, settingsAtom)
1038 if (s.approvals === 'auto' || (await read($, midnightAtom)) !== null || (await read($, godAtom)) !== null) return true
1039 const answer = await askOwner($, `Start a new agent: ${what}?`, ['Approve', 'Deny', 'Auto-approve'])
1040 if (answer === 'Deny') return false
1041 if (answer === 'Auto-approve') await saveSettings($, { ...(await read($, settingsAtom)), approvals: 'auto' })
1042 return true
1043}
1044
1045// ── K. Context health ────────────────────────────────────────────────────
1046const HANDOFF_ASK =
1047 'Context is getting heavy. Write a handoff summary to .mission-control/HANDOFF.md in the project: goal, done, in progress, next, decisions, file paths. 150 words max. Then stop.'
1048const FRESH = 'Fresh context: read .mission-control/HANDOFF.md and continue.'
1049
1050async function healthOnTurn($: EngineInterface, reason: string): Promise<void> {
1051 if (refreshStage === 'asked') {
1052 refreshStage = 'idle'
1053 try {
1054 await $.session.compact({ instructions: 'Use the handoff in .mission-control/HANDOFF.md as the summary; keep file paths, decisions and next steps.' })
1055 } catch {
1056 // Auto-compact will still catch it.
1057 }
1058 await update($, healthAtom, () => [])
1059 nudgeQueued = true
1060 submitSoon($, FRESH)
1061 return
1062 }
1063 if (reason !== 'answer') return
1064 const ctxPct = (await read($, meterAtom))?.ctxPct ?? 0
1065 const toolErrors = turnRecords.filter(r => r.isError).length
1066 const editMisses = turnRecords.filter(r => r.tool === 'Edit' && r.isError && /not found|did not match|no match/i.test(r.text)).length
1067 const wasted = ((await read($, turnsAtom)).at(-1)?.flag ?? null) !== null
1068 const score = scoreTurn({ toolErrors, editMisses, corrected: false, wasted })
1069 const list = [...(await read($, healthAtom)), { score, ctxPct }].slice(-40)
1070 await update($, healthAtom, () => list)
1071 if (healthAction(list) === 'refresh' && !nudgeQueued) {
1072 refreshStage = 'asked'
1073 nudgeQueued = true
1074 submitSoon($, HANDOFF_ASK)
1075 }
1076}
1077
1078/** The owner's push-back counts against the answer it pushes back on. */
1079async function noteCorrection($: EngineInterface, text: string): Promise<void> {
1080 if (!CORRECTION.test(text)) return
1081 await update($, healthAtom, list => {
1082 const last = list.at(-1)
1083 return last === undefined ? list : [...list.slice(0, -1), { ...last, score: Math.max(0, last.score - 0.3) }]
1084 })
1085}
1086
1087// ── L. Split ─────────────────────────────────────────────────────────────
1088async function splitOnTurn($: EngineInterface, reason: string): Promise<void> {
1089 if (reason !== 'answer' || splitQueue.length === 0 || nudgeQueued) return
1090 const task = splitQueue.shift() ?? ''
1091 const k = splitTotal - splitQueue.length
1092 await update($, splitAtom, () => [...splitQueue])
1093 nudgeQueued = true
1094 submitSoon($, nextTaskText(task, k, splitTotal))
1095}
1096
1097// ── M. Good morning: an auto run after a reset ───────────────────────────
1098async function startAutoRun($: EngineInterface): Promise<void> {
1099 const now = await nowOr($)
1100 await update($, midnightAtom, () => ({ endsAt: now + 8 * 60 * MIN, budgetPct: 85, ship: false, errorsInRow: 0, turns: 0 }))
1101 midnightOn = true
1102}
1103
1104// ── Wiring: one hook per event ───────────────────────────────────────────
1105export const register: Register = on => {
1106 on('session.start', async ($, e, next) => {
1107 await loadSettings($)
1108 startPulse($)
1109 await loadWords($)
1110 sessionCwd = e.cwd
1111 sessionKey = (await sessionId($)) ?? `s${Date.now()}`
1112 if ((await read($, meterAtom)) === null) {
1113 try {
1114 const last = await $.store.get(STORE.lastMeter)
1115 if (typeof last === 'object' && last !== null) {
1116 await update($, meterAtom, () => last as Meter)
1117 await update($, staleAtom, () => true)
1118 }
1119 } catch {
1120 // Nothing cached yet.
1121 }
1122 }
1123 await loadProjects($)
1124 await loadLocalConfig($)
1125 await launchOverlay($)
1126 midnightOn = (await read($, midnightAtom)) !== null
1127 if ((await read($, settingsAtom)).layout === 'side') await openSide($)
1128 await rearmFromStore($)
1129 await registerCommands($)
1130 return next(e)
1131 })
1132
1133 on('session.measure', async ($, e, next) => {
1134 await meterOnMeasure($, e)
1135 await alertsOnMeasure($, e)
1136 await guardOnMeasure($, e)
1137 return next(e)
1138 })
1139
1140 on('prompt.submit', async ($, e, next) => {
1141 const held = await holdRetry($, e)
1142 if (held !== null) return { drop: held }
1143 noteOwnerPrompt(e)
1144 if (isOwner(e)) {
1145 lastOwnerAt = await nowOr($)
1146 await noteCorrection($, e.text)
1147 if ((await read($, midnightAtom)) !== null) await stopMidnight($, 'owner is back')
1148 }
1149 const fixed = isOwner(e) ? autocorrect(e.text, ownerTypos).text : e.text
1150 const hint = isOwner(e) ? glossaryHints(fixed, ownerGlossary) : null
1151 const lines = await routeOnSubmit($, { ...e, text: fixed })
1152 return next({ ...e, text: fixed, context: [...(e.context ?? []), ...lines, ...(hint === null ? [] : [hint])] })
1153 }).catch(($, e, next) => next(e))
1154
1155 on('turn.start', ($, e, next) => {
1156 resetTurn()
1157 turnRecords = []
1158 return next(e)
1159 })
1160
1161 on('turn.step', async function* ($, e, next) {
1162 if (e.agentId !== undefined) {
1163 const effort = agentEffort.get(e.agentId)
1164 return yield* next(effort === undefined ? e : { ...e, effort })
1165 }
1166 const choice = await stepChoice($, e.index, e.effort)
1167 if (choice === null) return yield* next(e)
1168 return yield* next({ ...e, ...choice })
1169 })
1170
1171 on('agent.spawn', async ($, e, next) => {
1172 if (e.workflow !== undefined) return next(e)
1173 const ahead = spawning // spawns of this same message still being decided
1174 spawning += 1
1175 try {
1176 const cap = await effectiveCap($)
1177 const live = (await runningCount($)) + ahead
1178 if (live >= cap) return { deny: `Agent cap reached (${live} running). Wait for one to finish.` }
1179 if (!(await approveSpawn($, e.description || e.prompt.slice(0, 50)))) return { deny: 'The owner denied starting this agent.' }
1180 if (e.fork) return next(e)
1181 const prompt = e.prompt.endsWith(REPORT_RULE) ? e.prompt : e.prompt + REPORT_RULE
1182 if (e.model !== undefined) {
1183 const started = await next({ ...e, prompt })
1184 if (started.agentId !== undefined) {
1185 const id = started.agentId
1186 await update($, agentMetaAtom, m => ({ ...m, [id]: { model: e.model ?? 'inherit', effort: '–' } }))
1187 }
1188 return started
1189 }
1190 const chosen = await chooseAgent($, e.subagentType, e.prompt)
1191 const started = await next({ ...e, prompt, model: chosen.model })
1192 if (started.agentId !== undefined) {
1193 const id = started.agentId
1194 agentEffort.set(id, chosen.effort)
1195 await update($, agentMetaAtom, m => ({ ...m, [id]: { model: chosen.model, effort: chosen.effort } }))
1196 }
1197 return started
1198 } finally {
1199 spawning -= 1
1200 }
hooks/constants.ts 30 lines1import type { MissionSettings } from '../types'
2
3export const DEFAULT_SETTINGS: MissionSettings = {
4 router: true,
5 pin: null,
6 agentCap: 3,
7 resume: true,
8 alerts: true,
9 preferOpus: true,
10 layout: 'bar',
11 layoutLocked: false,
12 approvals: 'ask',
13 overlay: true,
14}
15
16export const STORE = {
17 settings: 'mc.settings',
18 resumeAt: 'mc.resumeAt',
19 alerted: 'mc.alerted',
20 typos: 'mc.typos',
21 glossary: 'mc.glossary',
22 projects: 'mc.projects',
23 never: 'mc.never',
24 lastMeter: 'mc.lastMeter',
25 costDays: 'mc.costDays',
26} as const
27
28export const WINDOW_LABEL: Record<string, string> = { five_hour: '5h', seven_day: 'Weekly' }
29export const LIMIT_LABEL: Record<string, string> = { five_hour: '5-hour', seven_day: 'Weekly' }
30hooks/agents.ts 28 lines1import type { Effort, Model } from '../types'
2
3export const REPORT_RULE = '\n\nReport back in ≤80 words: result, files touched, blockers. No recap.'
4
5const SEARCH = /\b(find|search|locate|list|grep)\b/i
6const HARD = /\b(architect|design|root cause|hard bug)\b/i
7const BUILD = /\b(implement|fix|write|test|review)\b/i
8
9/** The cheapest model that fits the agent's job, or null to ask Haiku. */
10export function pickAgent(subagentType: string, prompt: string, preferOpus = false): { model: Model; effort: Effort } | null {
11 // The agent's type decides first; words in the prompt only break ties.
12 if (subagentType === 'Explore') return { model: 'haiku', effort: 'low' }
13 if (subagentType === 'Plan' || HARD.test(prompt)) return { model: 'opus', effort: 'high' }
14 if (BUILD.test(prompt)) return { model: preferOpus ? 'opus' : 'sonnet', effort: 'medium' }
15 if (SEARCH.test(prompt)) return { model: 'haiku', effort: 'low' }
16 if (subagentType === 'general-purpose' || subagentType === 'claude') return { model: preferOpus ? 'opus' : 'sonnet', effort: 'medium' }
17 return null
18}
19
20export const CLASSIFY_LABELS = ['haiku/low', 'sonnet/medium', 'opus/high'] as const
21
22export function fromLabel(label: string | undefined): { model: Model; effort: Effort } | null {
23 if (label === 'haiku/low') return { model: 'haiku', effort: 'low' }
24 if (label === 'sonnet/medium') return { model: 'sonnet', effort: 'medium' }
25 if (label === 'opus/high') return { model: 'opus', effort: 'high' }
26 return null
27}
28hooks/route.ts 94 lines1import type { Effort, Model, Route, RouteAgent } from '../types'
2
3const MODELS: readonly Model[] = ['haiku', 'sonnet', 'opus']
4const EFFORTS: readonly Effort[] = ['low', 'medium', 'high', 'xhigh']
5const SIZES: readonly Route['size'][] = ['tiny', 'small', 'medium', 'large']
6
7/** Full ids for model requests; aliases stay internal. */
8export const MODEL_ID: Record<Model, string> = {
9 haiku: 'claude-haiku-5-5',
10 sonnet: 'claude-sonnet-5-5',
11 opus: 'claude-opus-5-5',
12}
13
14export const isModel = (m: unknown): m is Model => typeof m === 'string' && (MODELS as readonly string[]).includes(m)
15export const isEffort = (x: unknown): x is Effort => typeof x === 'string' && (EFFORTS as readonly string[]).includes(x)
16
17export function ROUTER_PROMPT(text: string, meterLine: string): string {
18 return [
19 "You route a coding request to the right model for the job; opus is the owner's preferred model for big work. Reply with JSON only:",
20 '{"size":"tiny|small|medium|large","kind":"question|edit|feature|debug|research|release",',
21 ' "model":"haiku|sonnet|opus","effort":"low|medium|high|xhigh","plan":true|false,',
22 ' "agents":[{"role":"...","model":"haiku|sonnet|opus","effort":"low|medium|high","why":"..."}],"tasks":["..."]}',
23 'tiny: reading, a question, or a one-line change -> haiku/low, no agents.',
24 'small: an everyday edit in one file -> sonnet/medium.',
25 'medium: several files or a bug to find -> sonnet/high, or opus/high if it touches the owner\'s big projects (Nyx, mission-control).',
26 'large: a feature, redesign or anything in the owner\'s big projects -> opus/high, plan true, suggest at most 3 agents.',
27 'Use agents only when parts can run in parallel. Searchers are haiku/low.',
28 'tasks: list 2-5 separate requests only when the message clearly holds unrelated work (for example one for the mod and one for the AI); otherwise [].',
29 `Resources now: ${meterLine}`,
30 'Request:',
31 text,
32 ].join('\n')
33}
34
35function parseAgent(a: unknown): RouteAgent | null {
36 if (typeof a !== 'object' || a === null) return null
37 const o = a as Record<string, unknown>
38 if (typeof o.role !== 'string' || !isModel(o.model) || !isEffort(o.effort)) return null
39 return { role: o.role, model: o.model, effort: o.effort, why: typeof o.why === 'string' ? o.why : '' }
40}
41
42/** The first {...} in the reply (fences allowed); null when it is not a valid route. */
43export function parseRoute(raw: string): Route | null {
44 const start = raw.indexOf('{')
45 const end = raw.lastIndexOf('}')
46 if (start < 0 || end <= start) return null
47 let o: Record<string, unknown>
48 try {
49 o = JSON.parse(raw.slice(start, end + 1)) as Record<string, unknown>
50 } catch {
51 return null
52 }
53 if (!(SIZES as readonly unknown[]).includes(o.size) || !isModel(o.model) || !isEffort(o.effort)) return null
54 const agents = Array.isArray(o.agents)
55 ? o.agents.map(parseAgent).filter((a): a is RouteAgent => a !== null).slice(0, 5)
56 : []
57 return {
58 size: o.size as Route['size'],
59 kind: typeof o.kind === 'string' ? o.kind : 'task',
60 model: o.model,
61 effort: o.effort,
62 plan: o.plan === true,
63 agents,
64 }
65}
66
67export const stepUp = (m: Model): Model => MODELS[Math.min(MODELS.indexOf(m) + 1, MODELS.length - 1)] ?? m
68const stepDown = (m: Model): Model => MODELS[Math.max(MODELS.indexOf(m) - 1, 0)] ?? m
69export const effortUp = (x: Effort): Effort => EFFORTS[Math.min(EFFORTS.indexOf(x) + 1, EFFORTS.length - 1)] ?? x
70const effortDown = (x: Effort): Effort => EFFORTS[Math.max(EFFORTS.indexOf(x) - 1, 0)] ?? x
71
72/** Cheaper as the 5-hour window fills; a pin always wins. */
73export function biasRoute(r: Route, fivePct: number, pin: Model | null, cheapFrom: number): Route {
74 if (pin !== null) return { ...r, model: pin }
75 let { model, effort } = r
76 if (fivePct >= cheapFrom) {
77 model = stepDown(model)
78 effort = effortDown(effort)
79 }
80 if (fivePct >= 95 && model === 'opus') model = 'sonnet'
81 return model === r.model && effort === r.effort ? r : { ...r, model, effort }
82}
83
84export function isSkippable(text: string): boolean {
85 const t = text.trim()
86 return t.length < 15 || /^(continue|try again|yes|y|ok|go|retry)\b/i.test(t)
87}
88
89export function routeLine(r: Route): string {
90 const plan = r.plan ? ', plan first' : ''
91 const agents = r.agents.length > 0 ? `; suggested agents: ${r.agents.map(a => `${a.role}(${a.model}/${a.effort})`).join(', ')}` : ''
92 return `[router] ${r.size} ${r.kind} → ${r.model}/${r.effort}${plan}${agents}`
93}
94hooks/risk.ts 112 lines1import type { Midnight } from '../types'
2
3// git, however it is spelled: `git.exe`, and global options before the verb.
4const GIT = String.raw`\bgit(?:\.exe)?(?:\s+(?:-C\s+(?:"[^"]*"|\S+)|-c\s+\S+|--[\w-]+(?:=\S+)?))*`
5const PUSH = new RegExp(String.raw`${GIT}\s+push\b|\bvercel\b|\bnpm\s+publish\b|\bgh\s+release\b`, 'i')
6const FORCE_PUSH = new RegExp(String.raw`${GIT}\s+push\b[^\n]*(?:--force|\s-[a-z]*f[a-z]*\b|\s\+\S)`, 'i')
7const RECURSIVE_DELETE =
8 /\brm\b[^\n]*?\s(?:-[a-z]*r[a-z]*|--recursive)\b|\b(?:rd|rmdir)\b[^\n]*\s\/s\b|\bdel\b[^\n]*\s\/s\b|\b(?:Remove-Item|ri)\b[^\n]*\s-r(?:ecurse)?\b/i
9const SECRET = /sk-ant-|\bsk-[A-Za-z0-9_-]{20,}|AKIA[0-9A-Z]{16}|AIza[0-9A-Za-z_-]{30,}|xox[bpas]-|ghp_[A-Za-z0-9]{20,}|-----BEGIN [A-Z ]*PRIVATE KEY|nvapi-/
10
11export const isPushOrDeploy = (cmd: string): boolean => PUSH.test(cmd)
12export const hasSecret = (text: string): boolean => SECRET.test(text)
13
14const norm = (p: string) => p.replace(/^["']/, '').replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
15
16/** Where a git command runs: `git -C <dir>` or `cd <dir> &&`; undefined for the session folder. */
17export function gitRepoOf(cmd: string): string | undefined {
18 const c = /\bgit(?:\.exe)?\s+-C\s+("[^"]+"|\S+)/i.exec(cmd)?.[1] ?? /\bcd\s+(?:\/d\s+)?("[^"]+"|[^\s&;|]+)\s*(?:&&|;)/i.exec(cmd)?.[1]
19 return c?.replace(/^"|"$/g, '')
20}
21
22/** A recursive delete aimed outside `root`: an absolute path elsewhere, `~`, or a variable. */
23export function deletesOutside(cmd: string, root: string): boolean {
24 if (!RECURSIVE_DELETE.test(cmd)) return false
25 const r = norm(root)
26 const tokens = [...cmd.matchAll(/"([^"]*)"|'([^']*)'|(\S+)/g)].map(m => m[1] ?? m[2] ?? m[3] ?? '')
27 return tokens.some(t => {
28 if (/^\/[a-z]$/i.test(t)) return false // a cmd switch such as /s or /q
29 if (/^[~$%]/.test(t)) return true // home or an environment variable
30 if (!/^(?:[A-Za-z]:[\\/]|\/)/.test(t)) return false // relative: inside the project
31 const n = norm(t)
32 return n !== r && !n.startsWith(`${r}/`)
33 })
34}
35
36// ── God mode: what still asks, and what gets a second look ────────────────
37const HARD_STOP: readonly RegExp[] = [
38 /\bformat\s+[a-z]:|\bcipher\s+\/w|\b(diskpart|bcdedit|shutdown|cmdkey)\b/i,
39 /\breg\s+(add|delete)\s+HKLM/i,
40 /Set-ExecutionPolicy/i,
41 /Set-MpPreference|netsh\s+advfirewall\s+set\s+\S+\s+state\s+off/i,
42 /(curl|wget|iwr|Invoke-WebRequest)[^|\n]*\|\s*(sh|bash|iex|Invoke-Expression)\b/i,
43 FORCE_PUSH,
44 /\.ssh[\\/]|Login Data|Credential/i,
45]
46const MEDIUM: readonly RegExp[] = [
47 /curl[^\n]*(-X\s*POST|-T\s|--upload-file|-F\s)|\bscp\s|Invoke-WebRequest[^\n]*-Method\s+Post/i,
48 /\b(taskkill|Stop-Process)\b|\bkill\s+-9\b/i,
49 /\b(winget|choco)\s+install\b|\bnpm\s+(i|install)\b[^\n]*\s-g\b|\bpip\s+install\b(?![^\n]*--user)/i,
50]
51const FILE_TOOLS = new Set(['Read', 'Edit', 'Write', 'NotebookEdit'])
52const WRITE_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit'])
53/** Tools from other servers that run a shell command. */
54export const SHELL_TOOL = /(?:^|__)(?:start_process|run_in_terminal|run_session_command|interact_with_process)$/
55/** Tool names that spend money or publish (spec H.2 "Money"). */
56const MONEY_TOOL = /(?:^|_)(?:buy|purchase|pay|transfer|trade|deploy_to|create_deployment|send_message|send)(?:_|$)/
57
58const field = (input: unknown, key: string): string => {
59 const v = typeof input === 'object' && input !== null ? (input as Record<string, unknown>)[key] : undefined
60 return typeof v === 'string' ? v : ''
61}
62
63/** The command a shell-running tool was asked to run, or null for other tools. */
64export function shellCommand(tool: string, input: unknown): string | null {
65 if (tool === 'Bash' || tool === 'PowerShell') return field(input, 'command')
66 if (SHELL_TOOL.test(tool)) return field(input, 'command') || field(input, 'input')
67 return null
68}
69
70const inside = (path: string, root: string) => {
71 const p = norm(path)
72 const r = norm(root)
73 return r !== '' && (p === r || p.startsWith(`${r}/`))
74}
75
76/** spec H.2/H.3: `hard-stop` keeps the normal prompt, `medium` gets Haiku's look. */
77export function classifyCall(tool: string, input: unknown, projectRoot: string): 'hard-stop' | 'medium' | 'normal' {
78 if (tool.startsWith('mcp__') && MONEY_TOOL.test(tool.split('__').pop() ?? '')) return 'hard-stop'
79 const cmd = shellCommand(tool, input)
80 if (cmd !== null) {
81 if (HARD_STOP.some(r => r.test(cmd)) || deletesOutside(cmd, projectRoot)) return 'hard-stop'
82 if (MEDIUM.some(r => r.test(cmd))) return 'medium'
83 return 'normal'
84 }
85 if (FILE_TOOLS.has(tool)) {
86 const path = field(input, 'file_path') || field(input, 'notebook_path')
87 if (/\.ssh[\\/]/i.test(path)) return 'hard-stop'
88 if (WRITE_TOOLS.has(tool) && path !== '' && !inside(path, projectRoot)) return 'medium'
89 }
90 return 'normal'
91}
92
93/** Only the owner's own hand: typed at the prompt or sent from the phone. */
94export const isOwnerOrigin = (kind: string | undefined): boolean => kind === 'composer' || kind === 'bridge'
95
96/** `[hours] [--ship]`, hours 1–24 (default 8), or `off`. */
97export function parseMidnightArgs(args: string): { hours: number; ship: boolean } | 'off' {
98 const a = args.trim().toLowerCase()
99 if (a === 'off') return 'off'
100 const n = Number(/\b(\d+)\b/.exec(a)?.[1] ?? 8)
101 return { hours: Math.max(1, Math.min(24, n)), ship: /--ship\b/.test(a) }
102}
103
104/** Why the run should end now, or null to keep going. */
105export function midnightStop(m: Midnight, now: number, weeklyPct: number, answer: string): string | null {
106 if (now >= m.endsAt) return 'time up'
107 if (weeklyPct >= m.budgetPct) return 'weekly budget reached'
108 if (answer.includes('MIDNIGHT_DONE')) return 'work finished'
109 if (m.errorsInRow >= 3) return '3 errors in a row'
110 return null
111}
112hooks/waste.ts 42 lines1import type { Route, TurnRecord } from '../types'
2
3export const BREVITY = [
4 'Answer first. A yes/no question gets yes or no first.',
5 'Final reports: 120 words or fewer unless the owner asks for detail.',
6 'Do not restate unchanged things or repeat the previous report.',
7 'Messages to other agents: terse, facts only.',
8].join('\n')
9
10export const wordCount = (text: string): number => text.split(/\s+/).filter(w => w !== '').length
11
12/** Why a turn's report wasted tokens, or null. */
13export function flagTurn(rec: { words: number; size: Route['size'] | null }, prevText: string | null, text: string): string | null {
14 if (rec.words > 250 && (rec.size === 'tiny' || rec.size === 'small')) return `long report for a ${rec.size} task`
15 if (prevText !== null && text.length >= 40 && prevText.slice(0, 200) === text.slice(0, 200)) return 'repeats previous report'
16 return null
17}
18
19const n = (x: number) => x.toLocaleString('en-US')
20
21/** `/mc tokens`: totals, flagged turns (last 10), estimated waste; ≤25 lines. */
22export function tokenReportText(turns: readonly TurnRecord[]): string {
23 const total = turns.reduce((sum, t) => sum + t.outputTokens, 0)
24 const flagged = turns.filter(t => t.flag !== null)
25 const wasted = flagged.reduce((sum, t) => sum + t.outputTokens, 0)
26 const lines = [
27 `Turns: ${turns.length} · output tokens: ${n(total)}`,
28 `Flagged: ${flagged.length} · est. wasted output tokens: ${n(wasted)}`,
29 ...flagged.slice(-10).map(t => ` ${t.turnId}: ${t.flag ?? ''} (${t.words} words)`),
30 ]
31 return lines.join('\n')
32}
33
34/** One line for the side panel: spend, padded turns, estimated waste. */
35export function tokenSummary(turns: readonly TurnRecord[]): string {
36 if (turns.length === 0) return 'no turns yet'
37 const total = turns.reduce((sum, t) => sum + t.outputTokens, 0)
38 const flagged = turns.filter(t => t.flag !== null)
39 const wasted = flagged.reduce((sum, t) => sum + t.outputTokens, 0)
40 return `output ${n(total)} · ${flagged.length} of ${turns.length} turns padded · ~${n(wasted)} wasted`
41}
42hooks/health.ts 71 lines1/** Context health: is the session getting meaningfully worse as context fills? */
2
3export type TurnSignals = { toolErrors: number; editMisses: number; corrected: boolean; wasted: boolean }
4export type ScoredTurn = { score: number; ctxPct: number }
5
6/** 1.0 for a clean turn; each sign of struggle takes some off. */
7export function scoreTurn(s: TurnSignals): number {
8 const score = 1 - Math.min(0.4, 0.1 * s.toolErrors) - 0.15 * s.editMisses - (s.corrected ? 0.3 : 0) - (s.wasted ? 0.1 : 0)
9 return Math.max(0, Math.round(score * 1000) / 1000)
10}
11
12/** The owner pushing back on the last answer. */
13export const CORRECTION = /^\s*(no\b|wrong|that'?s not|try again|you (didn'?t|forgot))/i
14
15/** Mean of the first five turns scored while context was under 40%; null until there are five. */
16export function healthBaseline(turns: readonly ScoredTurn[]): number | null {
17 const early = turns.filter(t => t.ctxPct < 40).slice(0, 5)
18 if (early.length < 5) return null
19 return early.reduce((sum, t) => sum + t.score, 0) / early.length
20}
21
22export function ema(values: readonly number[], alpha = 0.3): number {
23 let e = values[0] ?? 1
24 for (const v of values.slice(1)) e = alpha * v + (1 - alpha) * e
25 return e
26}
27
28/** Least-squares slope per turn. */
29export function trendSlope(values: readonly number[]): number {
30 const n = values.length
31 if (n < 2) return 0
32 const mx = (n - 1) / 2
33 const my = values.reduce((a, b) => a + b, 0) / n
34 let cov = 0
35 let varx = 0
36 values.forEach((y, x) => {
37 cov += (x - mx) * (y - my)
38 varx += (x - mx) ** 2
39 })
40 return cov / varx
41}
42
43const DROP = 0.25
44
45/**
46 * `refresh` only for a significant drop, or one the trend says is three turns
47 * away; a small dip is `none`. Past 85% context it always refreshes.
48 */
49export function healthAction(turns: readonly ScoredTurn[]): 'none' | 'refresh' {
50 const last = turns.at(-1)
51 if (last === undefined) return 'none'
52 if (last.ctxPct >= 85) return 'refresh'
53 const base = healthBaseline(turns)
54 if (base === null) return 'none'
55 const scores = turns.map(t => t.score)
56 const now = ema(scores)
57 if (last.ctxPct >= 60 && now <= base - DROP) return 'refresh'
58 if (last.ctxPct >= 50 && now + 3 * trendSlope(scores.slice(-4)) <= base - DROP) return 'refresh'
59 return 'none'
60}
61
62/** `▰▰▰▱▱ steady` / `falling` for the side pane. */
63export function healthLabel(turns: readonly ScoredTurn[]): string {
64 if (turns.length === 0) return 'no data yet'
65 const scores = turns.map(t => t.score)
66 const now = ema(scores)
67 const cells = Math.max(0, Math.min(5, Math.round(now * 5)))
68 const slope = trendSlope(scores.slice(-4))
69 return `${'▰'.repeat(cells)}${'▱'.repeat(5 - cells)} ${slope < -0.03 ? 'falling' : slope > 0.03 ? 'rising' : 'steady'}`
70}
71hooks/zones.ts 93 lines1import type { Meter, Zone } from '../types'
2
3export const ZONE_COLOR: Record<Zone, string> = {
4 ok: 'green',
5 watch: 'yellow',
6 warn: '#ff8700',
7 high: 'red',
8 danger: 'red',
9}
10
11export function zoneOf(pct: number): Zone {
12 if (pct >= 95) return 'danger'
13 if (pct >= 90) return 'high'
14 if (pct >= 80) return 'warn'
15 if (pct >= 60) return 'watch'
16 return 'ok'
17}
18
19export function topPct(m: Meter | null): number {
20 if (m === null) return 0
21 return Math.max(0, m.ctxPct ?? 0, m.fiveHour?.pct ?? 0, m.weekly?.pct ?? 0)
22}
23
24function dayKey(d: Date, timeZone?: string): string {
25 return new Intl.DateTimeFormat('en-CA', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit' }).format(d)
26}
27
28function clock(d: Date, timeZone?: string): string {
29 const parts = new Intl.DateTimeFormat('en-US', { timeZone, hour: 'numeric', minute: '2-digit', hour12: true }).formatToParts(d)
30 const get = (type: string) => parts.find(p => p.type === type)?.value ?? ''
31 return `${get('hour')}:${get('minute')}${get('dayPeriod').toLowerCase().startsWith('p') ? 'p' : 'a'}`
32}
33
34/** `10:20p` today, `Sat 10:20p` on another day, `soon` when unknown. */
35export function formatReset(iso: string | undefined, now: Date, timeZone?: string): string {
36 if (iso === undefined) return 'soon'
37 const at = new Date(iso)
38 if (Number.isNaN(at.getTime())) return 'soon'
39 if (dayKey(at, timeZone) === dayKey(now, timeZone)) return clock(at, timeZone)
40 const day = new Intl.DateTimeFormat('en-US', { timeZone, weekday: 'short' }).format(at)
41 return `${day} ${clock(at, timeZone)}`
42}
43
44const pct = (n: number | null | undefined) => (n === null || n === undefined ? '–' : `${Math.round(n)}%`)
45
46/** `Context 42% · 5h 63% ↻10:20p · Week 31%`; a missing figure is `–`. */
47export function formatMeter(m: Meter | null, now: Date = new Date(), timeZone?: string): string {
48 const five = m?.fiveHour
49 const fiveText = five ? `${pct(five.pct)}${five.resetsAt ? ` ↻${formatReset(five.resetsAt, now, timeZone)}` : ''}` : '–'
50 return `Context ${pct(m?.ctxPct)} · 5h ${fiveText} · Week ${pct(m?.weekly?.pct)}`
51}
52
53/** Five cells, one per 20%: `▰▰▱▱▱`. */
54export function meterBar(p: number | null, cells = 5): string {
55 const filled = p === null ? 0 : Math.max(0, Math.min(cells, Math.round(p / (100 / cells))))
56 return '▰'.repeat(filled) + '▱'.repeat(cells - filled)
57}
58
59export type BandSegment = { label: string; pct: number | null; bar: string; note?: string }
60
61/** The band's three meters, labelled plainly, each with its bar. */
62export function bandSegments(m: Meter | null, now: Date = new Date(), timeZone?: string): BandSegment[] {
63 const seg = (label: string, p: number | null | undefined, resetsAt?: string): BandSegment => {
64 const value = p ?? null
65 const base = { label, pct: value, bar: meterBar(value) }
66 return resetsAt === undefined ? base : { ...base, note: `resets ${formatReset(resetsAt, now, timeZone)}` }
67 }
68 return [
69 seg('Context', m?.ctxPct),
70 seg('5-hour limit', m?.fiveHour?.pct, m?.fiveHour?.resetsAt),
71 seg('Weekly limit', m?.weekly?.pct, m?.weekly?.resetsAt),
72 ]
73}
74
75const SPARK = '▁▂▃▄▅▆▇█'
76
77/** One cell per value (0–100), eight levels. */
78export function sparkline(values: readonly number[]): string {
79 return values.map(v => SPARK[Math.max(0, Math.min(7, Math.floor((v / 100) * 7.999)))] ?? '▁').join('')
80}
81
82/**
83 * How far usage runs ahead (+) or behind (−) the clock in a window: percent
84 * used minus percent of the window elapsed. Null without a reset time.
85 */
86export function pace(pct: number, resetsAt: string | undefined, now: number, windowMs = 5 * 3600_000): number | null {
87 if (resetsAt === undefined) return null
88 const left = Date.parse(resetsAt) - now
89 if (Number.isNaN(left)) return null
90 const elapsed = Math.max(0, Math.min(1, 1 - left / windowMs)) * 100
91 return Math.round(pct - elapsed)
92}
93hooks/lang.ts 60 lines1/** The owner's frequent typos, from the session audit; whole words only. */
2export const TYPOS: Readonly<Record<string, string>> = {
3 teh: 'the', hte: 'the', yhe: 'the', tje: 'the', adn: 'and', amke: 'make', mkae: 'make', simialr: 'similar',
4 wnat: 'want', waht: 'what', taht: 'that', thier: 'their', tehir: 'their', ahve: 'have', liek: 'like',
5 wiht: 'with', mroe: 'more', anotehr: 'another', evrything: 'everything', becuase: 'because', recieve: 'receive',
6 coudl: 'could', shoudl: 'should', woudl: 'would', teh_: 'the', tehre: 'there', guithub: 'github', giuthub: 'github',
7 downlaod: 'download', donwlaod: 'download', redessign: 'redesign', redesgin: 'redesign', optimzie: 'optimize',
8 efficently: 'efficiently', ensurre: 'ensure', aalso: 'also', alsoo: 'also', contionue: 'continue', continiue: 'continue',
9 finsih: 'finish', fininshed: 'finished', aplly: 'apply', baisc: 'basic', basicall: 'basically', dsign: 'design',
10 intergrate: 'integrate', strucutre: 'structure', progam: 'program', makign: 'making', somehwere: 'somewhere',
11}
12
13// Spans left untouched: inline code, URLs, Windows and POSIX paths, @mentions.
14const PROTECTED = /`[^`]*`|https?:\/\/\S+|[A-Za-z]:[\\/]\S*|(?:^|\s)\/\S+|@\S+|\S+\.(?:ts|tsx|js|py|md|json)\b/g
15
16function fixWord(word: string, map: Readonly<Record<string, string>>): string | null {
17 const right = map[word.toLowerCase()]
18 if (right === undefined) return null
19 if (word === word.toUpperCase() && word.length > 1) return right.toUpperCase()
20 if (word[0] === word[0]?.toUpperCase()) return right.charAt(0).toUpperCase() + right.slice(1)
21 return right
22}
23
24/** Whole-word typo fixes outside code, paths and links. */
25export function autocorrect(text: string, extra: Readonly<Record<string, string>> = {}): { text: string; fixes: string[] } {
26 const map = { ...TYPOS, ...extra }
27 const fixes: string[] = []
28 const fixRun = (run: string) =>
29 run.replace(/\b[A-Za-z]+\b/g, w => {
30 const r = fixWord(w, map)
31 if (r === null) return w
32 fixes.push(`${w}→${r}`)
33 return r
34 })
35 let out = ''
36 let last = 0
37 for (const m of text.matchAll(PROTECTED)) {
38 const at = m.index ?? 0
39 out += fixRun(text.slice(last, at)) + m[0]
40 last = at + m[0].length
41 }
42 out += fixRun(text.slice(last))
43 return { text: out, fixes }
44}
45
46/** The owner's words for things, and what they mean. */
47export const DEFAULT_GLOSSARY: Readonly<Record<string, string>> = {
48 'the mod': 'Mission Control, this Claude Code mod',
49 'handoff': '.mission-control/HANDOFF.md, the summary written before a context refresh',
50 'publish': 'the ship checklist: tests, never-ship and secret checks, commit, push, deploy',
51}
52
53/** One context line naming each glossary term the text uses, or null. */
54export function glossaryHints(text: string, glossary: Readonly<Record<string, string>>): string | null {
55 const lower = text.toLowerCase()
56 const hits = Object.entries(glossary).filter(([term]) => new RegExp(`\\b${term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`).test(lower))
57 if (hits.length === 0) return null
58 return `[glossary] ${hits.map(([t, m]) => `"${t}" = ${m}`).join(' · ')}`
59}
60hooks/projects.ts 49 lines1export type Project = { name: string; path: string }
2
3/** What a ship must never push unless the owner adds more (`/ship never <pattern>`). */
4export const DEFAULT_NEVER: readonly string[] = ['AI_HANDOFF/', '.env', '.env.*', '*.pem', '*.key', 'secrets/']
5
6const norm = (p: string) => p.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
7
8/** The registered project that holds `cwd`, the deepest path winning; null when none. */
9export function projectFor(cwd: string, projects: readonly Project[]): Project | null {
10 const c = norm(cwd)
11 const hits = projects.filter(p => c === norm(p.path) || c.startsWith(`${norm(p.path)}/`))
12 return hits.sort((a, b) => norm(b.path).length - norm(a.path).length)[0] ?? null
13}
14
15/** An unregistered folder is its own project, named after the folder. */
16export function autoProject(cwd: string): Project {
17 const path = cwd.replace(/\\/g, '/').replace(/\/+$/, '')
18 return { name: path.split('/').pop() || path, path }
19}
20
21/** Chats sort into "<project> · Questions" or "<project> · Updates". */
22export function groupNameFor(project: string, kind: string): string {
23 return `${project} · ${kind === 'question' || kind === 'research' ? 'Questions' : 'Updates'}`
24}
25
26function matches(file: string, pattern: string): boolean {
27 const f = file.replace(/\\/g, '/')
28 const base = f.split('/').pop() ?? f
29 if (pattern.endsWith('/')) return f.startsWith(pattern) || f.includes(`/${pattern}`)
30 if (pattern.startsWith('*.')) return base.endsWith(pattern.slice(1))
31 if (pattern.endsWith('.*')) return base.startsWith(pattern.slice(0, -1))
32 return base === pattern || f === pattern
33}
34
35/** Tracked files that a never-ship pattern forbids. */
36export function trackedForbidden(files: readonly string[], patterns: readonly string[]): string[] {
37 return files.filter(f => f.trim() !== '' && patterns.some(p => matches(f.trim(), p))).map(f => f.trim())
38}
39
40export function shipText(never: readonly string[]): string {
41 return [
42 "Ship it: 1) run the project's tests and build;",
43 `2) check git status and never commit or push: ${never.join(', ')};`,
44 '3) scan for secrets (the push guard checks too);',
45 '4) commit with a clear message; 5) push; 6) deploy the site if the project has one;',
46 '7) end with the proof card. Stop at the first failure and say why.',
47 ].join(' ')
48}
49hooks/proof.ts 33 lines1/** One tool call of a turn, as the proof card reads it. */
2export type ToolRecord = { tool: string; command?: string; isError: boolean; text: string }
3
4const EDITS = new Set(['Edit', 'Write', 'NotebookEdit'])
5const TEST = /pytest|npm (run )?test|plugin test|vitest|jest|cargo test|go test/i
6const BUILD = /npm run build|\btsc\b|vite build|cargo build/i
7
8const runs = (records: readonly ToolRecord[], pattern: RegExp) => records.filter(r => r.command !== undefined && pattern.test(r.command))
9
10/** `✓ tests 114 pass · ✓ build · …` for a turn that changed files; null otherwise. */
11export function proofCard(records: readonly ToolRecord[]): string | null {
12 if (!records.some(r => EDITS.has(r.tool))) return null
13
14 const test = runs(records, TEST).at(-1)
15 let tests = '– no tests'
16 if (test !== undefined) {
17 const failed = Number(/(\d+)\s+fail/i.exec(test.text)?.[1] ?? 0)
18 const passed = /(\d+)\s+pass/i.exec(test.text)?.[1]
19 tests =
20 test.isError || failed > 0
21 ? `✗ tests ${failed > 0 ? `${failed} failed` : 'failed'}`
22 : `✓ tests${passed === undefined ? '' : ` ${passed} pass`}`
23 }
24
25 const build = runs(records, BUILD).at(-1)
26 const buildText = build === undefined ? '– no build' : build.isError ? '✗ build' : '✓ build'
27 const browser = records.some(r => r.tool.startsWith('mcp__Claude_Browser__')) ? '✓ browser-checked' : '✗ not browser-checked'
28 const committed = runs(records, /\bgit\s+commit\b/).some(r => !r.isError) ? '✓ committed' : '– not committed'
29 const pushed = runs(records, /\bgit\b[^\n]*\bpush\b/).some(r => !r.isError) ? '✓ pushed' : '– not pushed'
30
31 return [tests, buildText, browser, committed, pushed].join(' · ')
32}
33hooks/split.ts 31 lines1/** The owner asked for everything in one place: do not split. */
2export const wantsTogether = (text: string): boolean => /\b(together|same chat|in one go|at once|all in one)\b/i.test(text)
3
4/** Two to five non-empty task strings from the router's `tasks`, else none. */
5export function parseTasks(raw: unknown): string[] {
6 if (!Array.isArray(raw)) return []
7 const tasks = raw.map(t => (typeof t === 'string' ? t.trim() : ''))
8 if (tasks.length < 2 || tasks.length > 5 || tasks.some(t => t === '')) return []
9 return tasks
10}
11
12/** The `tasks` array from the router's raw reply, if any. */
13export function tasksFromReply(raw: string): unknown {
14 const start = raw.indexOf('{')
15 const end = raw.lastIndexOf('}')
16 if (start < 0 || end <= start) return undefined
17 try {
18 return (JSON.parse(raw.slice(start, end + 1)) as { tasks?: unknown }).tasks
19 } catch {
20 return undefined
21 }
22}
23
24export function splitLine(tasks: readonly string[]): string {
25 return `[split] This message holds ${tasks.length} separate tasks. Do task 1 now: ${tasks[0]}. The rest are queued and will be sent one by one.`
26}
27
28export function nextTaskText(task: string, k: number, n: number): string {
29 return `Next task split from your message (${k}/${n}): ${task}`
30}
31