SLOPSHOPPER

mission-control

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

newpanebandspinnerrowsguard
v1.0.4MITupdated 2026-10-09ShagnikPal123/mission-control
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mission-control
│ ┃ mc-side ✕ › fix the failing auth test and add an╭────────────────────────────╮ │ ┃ OK │ mission-control │ │ ┃ Context ▰▰▱▱▱ 49% ⏺ Read(src/auth.ts) │ router: kept current model │ │ ┃ 5-hour limit ▰▰▱▱▱ 31% ⎿ Read 6 lines ╰────────────────────────────╯ │ ┃ Weekly limit ▱▱▱▱▱ – ⏺ Update(src/auth.ts) │ ┃ Session cost $0.42 ⎿ Added 2 lines, removed 1 line │ ┃ Cost today $0.42 ▄ ⏺ Bash(bun test) │ ┃ Model session default ⎿ 3 pass, 1 fail │ ┃ Quality ▰▰▰▰▰ steady │ ┃ Agents 0/2 · approvals ask ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ none running │ ┃ Progress ✻ Worked for 42s · done 4:20 PM │ ┃ no task list yet – no tests · – no build · ✗ not browser-checked · – not committed · ✓ │ ┃ Last proof │ ┃ – no tests · – no build · ✗ not › /god │ ┃ browser-checked · – not committed · ✓ pushed ⎿ mission-control: God mode stays off. │ ┃ Projects │ ┃ ▶ app · this chat │ ┃ Tokens output 1,480 · 0 of 1 turns padded · │ ┃ wasted │ ┃ [ ⚡ God mode ] 🌙 midnight · 8h0m left[ Stop │ ┃ [ @ HANDOFF.md ][ @ MIDNIGHT_LOG.md ][ Appro │ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────── │ OK Context ▰▰▱▱▱ 49% 5-hour limit ▰▰▱▱▱ 31% resets Thu 9:53a Weekly limit ▱▱▱▱▱ – │ 0/2 agents │ $0.42[ ⚡ Go ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts● ok ⚠ mission-control: Context 49% · 5h 31% ↻Thu 9:53a · Week –

Draws

Band
╭─────────────────────────────────────────────────────────────────────────────────────────────────── │ OK Context ▰▰▱▱▱ 49% 5-hour limit ▰▰▱▱▱ 31% resets Thu 9:53a Weekly limit ▱▱▱▱▱ – │ 0/2 agent ╰───────────────────────────────────────────────────────────────────────────────────────────────────
Pane · mc-side
OK Context ▰▰▱▱▱ 49% 5-hour limit ▰▰▱▱▱ 31% Weekly limit ▱▱▱▱▱ – Session cost $0.42 Cost today $0.42 ▄ Model session default Quality ▰▰▰▰▰ steady Agents 0/2 · approvals ask none running Progress no task list yet Last proof – no tests · – no build · ✗ not browser-checked · – not committed · ✓ pushed Projects ▶ app · this chat Tokens output 1,480 · 0 of 1 turns padded · ~0 wasted [ ⚡ God mode ] 🌙 midnight · 8h0m left[ Stop ] [ @ HANDOFF.md ][ @ MIDNIGHT_LOG.md ][ Approvals: ask ][ ⇄ B
Prompt hint
? for shortcuts● ok
README

Mission Control

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.

Install

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.

What you get

Band above the chat box

  • A zone badge: OK / WATCH / WARN / HIGH / DANGER. In DANGER it pulses until you press Got it.
  • Context, 5-hour limit and weekly limit, each with a bar and its reset time.
  • A context sparkline.
  • A pace marker that shows whether you are burning through the 5-hour window faster than the clock.
  • Agents running against the cap, and session cost.
  • ⚡ God mode, 🌙 Midnight and a Stop for each stay on the band in every layout, and in the side panel.
  • ⇄ Layout cycles Bar / Side / Compact; 🔒 locks the layout.
  • ▤ panel (key 1) opens the side panel.

Side panel

  • Meters, model, a quality trend for the session, live agents and approvals, task progress, and split tasks still queued.
  • Each agent's model, effort and type with a tell button; projects (this chat marked); a token-analysis line.
  • Agent messages, the last proof card, cost today with a 14-day sparkline, and quick @doc buttons.
  • Notes: anything that failed, said plainly.

Limits and resuming

  • Phone and desktop alerts at 90% and when you hit a limit.
  • Automatic resume after the reset. A bare "try again" sent while you are still limited is held.
  • Good morning: if you've been away, the resume starts an unattended run that stops as soon as you type.

Router

  • Haiku reads each prompt and picks the model and effort: Haiku for reading, Sonnet for everyday edits, Opus for big work.
  • It steps up after repeated failures, and gets thriftier as the 5-hour window fills.
  • Subagents get the cheapest model that fits, a cap on how many run at once, approve / deny / auto-approve, and ≤80-word reports.

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.

  • Claude decides on its own, logs each decision to MIDNIGHT_LOG.md, keeps going, and stops at the time limit, the weekly budget, MIDNIGHT_DONE, or 3 errors in a row.
  • It won't push, deploy or delete outside the project without --ship, which also runs the secret scan.

/god [minutes]: no permission prompts for a limited time, after a confirmation pop-up.

  • Hard stops still ask: deleting outside the project, disk and registry changes, credentials, money, force-push, piping a downloaded script into a shell.
  • Medium-risk calls get a quick Haiku check against what you asked for.
  • It never overrides your deny rules.

/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.

Commands

CommandWhat it does
/mcState, 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
/lookQuote 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>

Your own settings

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" }]
}

Development

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.

License

MIT

Source 13 files
hooks/register.tsx 1706 lines
1import { 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 lines
1import 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' }
30
hooks/agents.ts 28 lines
1import 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}
28
hooks/route.ts 94 lines
1import 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}
94
hooks/risk.ts 112 lines
1import 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}
112
hooks/waste.ts 42 lines
1import 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}
42
hooks/health.ts 71 lines
1/** 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}
71
hooks/zones.ts 93 lines
1import 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}
93
hooks/lang.ts 60 lines
1/** 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}
60
hooks/projects.ts 49 lines
1export 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}
49
hooks/proof.ts 33 lines
1/** 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}
33
hooks/split.ts 31 lines
1/** 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