SLOPSHOPPER

mm-runtime

Optional Claude Code runtime adapter for the mm skill: status line, PM write fence, dispatch approval dialog

newpaneguardcommandtoaststatus
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mm-runtime
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ mm-runtime: MM runtime: not configured
README

claude-skills

Personal Claude Code skills, babysitter processes, agents, hooks, and commands.

Install on a new machine

git clone git@github.com:deedeeharris/claude-skills.git ~/claude-skills
cd ~/claude-skills && chmod +x install.sh && ./install.sh

Symlinks everything into the right locations. git pull to update — no re-install needed.

Structure

FolderSymlinked toWhat goes here
skills/~/.claude/skills/Claude Code skills (SKILL.md per folder)
processes/~/.a5c/processes/Babysitter process JS files
agents/~/.a5c/agents/Babysitter agent definitions
hooks/~/.claude/hooks/Claude Code hook scripts
commands/~/.claude/commands/Custom slash commands (.md files)

Current contents

Skills

SkillCommandDescription
babysitter-multi-session/babysitter-multi-sessionGenerate a run-sessions.sh script to chain multiple babysitter yolo sessions sequentially
bh/bhBug Hunter — scan, TDD-fix, conventions gate, code review, DoD gate, commit
bh-forever/bh-foreverContinuous bug hunting loop until convergence score ≥ 90
codex-cli/codex-cliCodex CLI integration
codex-review/codex-reviewIndependent, out-of-family Codex review of an implementation diff, a PRD or a spec, with a PASS / FAIL / NEEDS_HUMAN verdict where blocking findings are independently validated by a second Codex call. Requires the OpenAI Codex CLI; used by gated-pipeline
gated-pipeline/gated-pipelineTakes a feature from a raw task to a reviewed PR via in-session Claude Code Workflows: PRD → codex review → spec → codex review → test-first implementation → codex review + security pass → fix loop → full suite last → PR to the integration branch, with the human doing the merge. Works in any repo via a per-repo .claude/pipeline.yaml (template included). Requires the OpenAI Codex CLI and the codex-review skill (its scripts/run-review.js runner), which is not part of this repo.
hebrew-rtl/hebrew-rtlApply RTL Hebrew rules when generating any document with Hebrew text — fixes BiDi, punctuation, layout mirroring, comma placement. Use alongside pptx-generator, minimax-docx, minimax-xlsx, or minimax-pdf.
gemini/geminiGemini CLI integration
langtalk/langtalkHybrid LLM-engineering research — runs LangTalks-podcast NotebookLM query and live WebSearch in parallel, then synthesizes a single answer with inline [LT*] / [W*] source tags and clickable YouTube URLs. Beats pure web search by +3.50/50 on a sealed 6-question blind eval (upstream + eval)
deep-verify-plan/deep-verify-planDeep Verify Plan — runs iterative plan QA (6-dimension scan → dedup → prove gaps → self-answer → 3-judge review → quality score 95/100) without any coding
plan-gap-finder/plan-gap-finderPlan Gap Finder — spawns parallel agents (one per codebase area) to cross-reference a plan file against actual code; outputs a structured gap report: planned-but-missing, implemented-but-not-planned, partial
prd-to-spec/prd-to-specConvert an approved PRD into a phase-gated implementation SPEC with verification ledger, TDD breakpoints, and quality gates. Dispatches to prd-to-spec.js process via /babysitter:call or /babysitter:yolo
task-to-prd/task-to-prdConvert a raw task (tracker ticket / email / text) into a fully characterized PRD via Five Whys + interactive clarification + adversarial review. Dispatches to task-to-prd.js process via /babysitter:call or /babysitter:yolo
mm/mmMicromanager — Living PM skill that turns Claude into a Project Manager that maintains a continuous HANDOFF.md across sessions, sequences tasks, writes babysitter prompts for engineering agents, consumes status updates from a file-based inbox, captures user-preference / codebase / mistake insights and promotes survivors to durable Claude memory at task close, and never writes production code itself. Prefers .private/pm/ base path; enforces strict PM-mode role guard. Auto-detects fresh vs continuing tasks; survives /compact via § 0 session-opener contract.
cloud-mailbox/cloud-mailboxHand work to Claude Code cloud sessions and keep talking to them from your local session. Each cloud worker gets one GitHub issue as a two-way mailbox: workers comment [cloud:<name>] <TYPE>, you answer [pm], and both sides wait with a background poll loop that costs no model tokens. One-time setup.sh seeds the skills you choose into the repo, which is the only way cloud sessions get skills. It can also pin the repo's cloud environment (--env env_...) and auto-compact window (--autocompact 500000) in .claude/settings.json. Launchers start sessions without leaving console windows open. Workers obey only [pm] comments from your GitHub account, comment only, and never close their issue; you do. Needs gh and claude --cloud. Tested on Windows; the macOS/Linux launcher is untested.
to-claude-code-cloud/to-claude-code-cloudTo-claude-code-cloud — dispatch a self-contained GitHub task to a Claude Code cloud background session through one standing routine, collect and review the result locally, then open the PR. Includes environment.md; configure its routine and environment IDs before the first dispatch. Requires Claude Code and the RemoteTrigger tool.
bg-it/bg-itSpawn any background claude session — collects prompt path, skill prefix, and session name (asks human if any unknown), shows the exact claude --bg command, waits for confirmation, then spawns. Supports all archetypes including subagent-driven-development and interactive prompts. Claude Code only.
websearch-multilang/websearch-multilangParallel multilingual web research — spawns one research subagent per language (English, Chinese Simplified, Chinese Traditional, French, Russian, Hebrew, Spanish), each searching native technical terms + native forums (CSDN/Zhihu/Juejin, Habr, Developpez, iT 邦幫忙, Foros del Web, …), then runs a coordinator synthesis preferring solutions that recur independently across languages. For finding ideas / solutions / root causes / workarounds / prior art / benchmarks online — not trivial single-doc lookups.
tg/tg <message or filepath> [caption]Send text messages and files to a configured Telegram chat (group, channel, or DM with bot) via bot API. Supports forum/topic groups. Reads TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID / optional TELEGRAM_TOPIC_ID from ~/.claude/.env so the skill itself contains no secrets — ships with .env.example template. UTF-8 safe (Hebrew, emoji, em-dash) on Windows Git Bash.

Note: the prd-to-spec and task-to-prd skills are thin user-facing wrappers — they parse inputs, ask the user to pick /babysitter:call (interactive) or /babysitter:yolo (auto-approve), and dispatch to the matching process below.

Processes

ProcessDescription
bug-hunter.jsBabysitter process driving the full BH pipeline
deep-plan-verification.jsPhase 0 plan verifier: 6-dimension parallel gap scan → dedup → prove gaps → self-answer → 3-judge review → consistency gate → quality score (target 95/100)
prd-to-spec.jsBabysitter process that orchestrates the prd-to-spec skill: discovery (Verification Ledger) → SPEC generation → self-review (+ optional secondary reviewer) → user-approval breakpoint → execution prompt. Stack-agnostic.
task-to-prd.jsBabysitter process that orchestrates the task-to-prd skill: source-load + Five Whys → interactive clarification → scope-lock breakpoint → PRD draft → 5 parallel verification checks (+ optional secondary review) → per-finding gate → final approval → optional tracker update + follow-up prompt. Stack-agnostic.

Scripts

Standalone bash scripts and tools. See each folder for its own install instructions.

ScriptDescription
scripts/issue-loop.shAuto-fix GitHub issues in a loop using a 3-session babysitter pipeline: Session 1 writes a spec + runs /deep-verify-plan (≥95/100), Session 2 uses /writing-plans to produce a TDD task list, Session 3 implements with TDD + /verification-before-completion then commits and pushes. Quality gates validate each artifact. Rate limits are detected by multi-pattern regex, sleep until reset (parsed from Claude's output), and retry up to 5×. Rate-limit exhaustion skips the issue without marking it failed. Closes issues on success, labels needs-review on session failure. Stops when no open issues remain. Works on any git repo with gh + claude + jq + python3.
scripts/deedeeharris-launch-cc-agents/Interactive menu to launch named Claude Code background agents. Menu-driven: pick by number or start all, detect already-running sessions, add/edit/remove agents. Works on Windows (Git Bash), macOS, Linux. Copy folder to ~/.claude/, run bash launch-agents.sh.

Adding something new

New skill:

cp -r ~/.claude/skills/my-skill ~/claude-skills/skills/
cd ~/claude-skills && git add . && git commit -m "add skill: my-skill" && git push

New process:

cp ~/.a5c/processes/my-process.js ~/claude-skills/processes/
cd ~/claude-skills && git add . && git commit -m "add process: my-process" && git push

New agent / hook / command: same pattern — drop into the right folder, commit, push.

On any other machine: git pull and it's live instantly via symlinks.

Source 3 files
hooks/register.ts 792 lines
1// mm-runtime: optional Claude Code runtime adapter for the mm skill.
2//
3// It reaches mm only through runMm (one process.run, argv PYTHON, MM_PY, ...) and keeps no MM state:
4// module variables hold the last status snapshot and what the hooks saw this session.
5// mm.py stays the only writer of MM state, and guard.py stays the portable fence.
6import type { EngineInterface, Register } from 'claude-code'
7import { MM_PY, PYTHON } from './mm-config'
8import {
9  errorsOf, inFlightIds, isBound, isUnbound, modeOf, paneLines, readStatus, rowItem, statusLine, taskOf,
10  type Snapshot, type StatusRead,
11} from './present'
12
13type Dollar = EngineInterface
14type Run = { exitCode: number; stdout: string; stderr: string }
15
16const configured = PYTHON !== '' && MM_PY !== ''
17const PANE_ID = 'mm-runtime'
18const DISPATCH_TOOL = 'mcp__mm-runtime__dispatch'
19const WORKERS = ['subagent', 'workflow', 'codex', 'bg', 'other']
20const TIMEOUT = { status: 20000, fence: 15000, changes: 15000, approve: 30000, dispatch: 30000 }
21const INCOMPATIBLE = 'MM runtime: mm.py too old for this plugin (needs status --json); reinstall the plugin from the current skill'
22
23// Session cache, none of it MM state.
24let sid = ''
25let interactive = false
26let lastValid: Snapshot | undefined
27let current: StatusRead = { reason: 'no mm status was read yet in this session' }
28let refreshing: Promise<void> | undefined
29let refreshQueued = false
30const agentIdsSeen: string[] = []
31type WorkerCall = { label: string }
32const workersInProgress = new Set<WorkerCall>()
33// `flying`: the in-flight dispatch ids the window opened with; a refresh that finds others marks it
34// UNCERTAIN (WPMOD-r4 F2).
35type ShellWindow = { reasons: string[]; flying: string[] }
36const openWindows = new Set<ShellWindow>()
37// Dispatch tool calls from handler entry until they return; each makes every open PM window UNCERTAIN.
38const dispatchCallsInProgress = new Set<object>()
39let pendingWarnings: string[] = []
40let boundaryFiles: string[] = []
41let monitorFailed = false
42// A PM shell call that kept running after its tool call returned (WPMOD-r2 F1): its window stays
43// open, holding its baseline, and is re-checked until it is known finished. See checkBackground.
44type BackgroundWindow = {
45  win: ShellWindow
46  root: string
47  before: ChangesDoc
48  taskId?: string
49  reported: string[]
50  checks: number
51  finished: boolean
52  failureReported: boolean
53}
54const backgroundWindows = new Set<BackgroundWindow>()
55let backgroundChecks: Promise<void> = Promise.resolve()
56// The fallback bound: a background window no completion signal closed is closed after this many
57// re-checks (main-loop turn.complete, prompt.submit and PM shell calls each count one).
58const MAX_BACKGROUND_CHECKS = 30
59// A worker (agentId) shell that kept running after its tool call returned (WPMOD-r3 F3). While any
60// may still run, every PM shell window is UNCERTAIN. Closed like a background window: by classic.Stop
61// no longer listing its task id, or after MAX_BACKGROUND_CHECKS checks.
62type WorkerShell = { label: string; taskId?: string; checks: number }
63const workerShells = new Set<WorkerShell>()
64
65function errorText(err: unknown): string {
66  if (err instanceof Error) return err.message
67  return String(err)
68}
69
70function firstLine(s: string): string {
71  const line = s.split('\n').find((l) => l.trim() !== '')
72  return line === undefined ? '' : line.trim()
73}
74
75// The one way the plugin reaches mm: an argv array, never a shell string.
76async function runMm($: Dollar, args: string[], init: { stdin?: string; cwd?: string; timeoutMs: number }): Promise<Run> {
77  const result = await $.process.run([PYTHON, MM_PY, ...args], init)
78  return { exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr }
79}
80
81function addReason(win: ShellWindow, reason: string): void {
82  if (!win.reasons.includes(reason)) win.reasons.push(reason)
83}
84
85function seeAgent(agentId: unknown): void {
86  if (typeof agentId === 'string' && agentId !== '' && !agentIdsSeen.includes(agentId)) agentIdsSeen.push(agentId)
87}
88
89function describeCall(e: Record<string, unknown>): string {
90  const target = [e.file_path, e.notebook_path, e.command].find((v) => typeof v === 'string' && v !== '')
91  return String(e.tool) + (target === undefined ? '' : ' ' + String(target).slice(0, 120)) + ' by agent ' + String(e.agentId)
92}
93
94// A worker (agentId) tool call is in progress from handler entry until its next settles.
95function workerStarted(e: Record<string, unknown>): WorkerCall {
96  const call = { label: describeCall(e) }
97  workersInProgress.add(call)
98  for (const win of openWindows) addReason(win, 'a delegated worker call started during the window (' + call.label + ')')
99  return call
100}
101
102// The latest completed status read positively proved an mm.py older than this plugin. The handlers
103// then pass through, so the portable guard.py classic hook stays the fence; any other failed read
104// keeps the mod fence fail-closed.
105function incompatible(): boolean {
106  return current.incompatible === true
107}
108
109function pinStatus($: Dollar): void {
110  $.ui.status(incompatible() ? INCOMPATIBLE : statusLine(current.snapshot, boundaryFiles.length, monitorFailed))
111}
112
113// WPMOD-r4 F1: the dispatch state is unknown when the latest status read failed or reports
114// DISPATCHES-UNREADABLE. A worker may then be writing, so no change can be attributed to the PM.
115function dispatchUnknown(): string | undefined {
116  if (current.snapshot === undefined) return 'dispatch state is unknown (mm status is unavailable: ' + current.reason + ')'
117  if (errorsOf(current.snapshot).some((x) => x.code === 'DISPATCHES-UNREADABLE')) {
118    return 'dispatch state is unknown (the status reports DISPATCHES-UNREADABLE)'
119  }
120  return undefined
121}
122
123// After every status read: an unknown dispatch state (F1) or in-flight dispatches a window did not
124// open with (F2) make every open PM window UNCERTAIN.
125function markOpenWindows(): void {
126  const unknown = dispatchUnknown()
127  const flying = inFlightIds(current.snapshot)
128  for (const win of openWindows) {
129    if (unknown !== undefined) addReason(win, unknown)
130    const fresh = flying.filter((id) => !win.flying.includes(id))
131    if (fresh.length > 0) addReason(win, 'dispatches started during the window (' + fresh.join(', ') + ')')
132  }
133}
134
135async function refreshOnce($: Dollar): Promise<void> {
136  try {
137    const run = await runMm($, ['status', '--json', '--session', sid], { timeoutMs: TIMEOUT.status })
138    current = readStatus(run)
139  } catch (err) {
140    current = { reason: 'mm.py status could not run (' + errorText(err) + ')' }
141  }
142  if (current.snapshot !== undefined) lastValid = current.snapshot
143  markOpenWindows()
144  pinStatus($)
145  $.ui.invalidate('ui.render')
146}
147
148// At most one status read in flight; triggers that arrive meanwhile coalesce into one follow-up.
149function refresh($: Dollar): Promise<void> {
150  if (refreshing !== undefined) {
151    refreshQueued = true
152    return refreshing
153  }
154  refreshing = (async () => {
155    try {
156      do {
157        refreshQueued = false
158        await refreshOnce($)
159      } while (refreshQueued)
160    } finally {
161      refreshing = undefined
162    }
163  })()
164  return refreshing
165}
166
167async function startSession($: Dollar): Promise<void> {
168  const id = await $.session.id()
169  // WPMOD-r4 F3: a different session id (classic.SessionStart after /clear) is a new session; the
170  // previous one's pending warnings, boundary status prefix and windows do not carry over.
171  if (sid !== '' && id !== sid) {
172    pendingWarnings = []
173    boundaryFiles = []
174    monitorFailed = false
175    openWindows.clear()
176    backgroundWindows.clear()
177  }
178  sid = id
179  await $.env.set('MM_CC_RUNTIME_MOD_ACTIVE', sid)
180  await refresh($)
181}
182
183// The fence payload in the settings-hook shape guard.py reads (SPEC 2.3 payload mapping).
184async function fencePayload($: Dollar, e: Record<string, unknown>): Promise<Record<string, unknown>> {
185  const toolInput: Record<string, unknown> = {}
186  for (const [key, value] of Object.entries(e)) {
187    if (key !== 'tool' && key !== 'tool_use_id' && key !== 'agentId') toolInput[key] = value
188  }
189  const payload: Record<string, unknown> = {
190    hook_event_name: 'PreToolUse',
191    tool_name: e.tool,
192    tool_input: toolInput,
193    session_id: await $.session.id(),
194    cwd: await $.session.cwd(),
195  }
196  if (e.agentId !== undefined) payload.agent_id = e.agentId
197  return payload
198}
199
200function failClosed(why: string): { deny: string } {
201  return { deny: 'mm-runtime: the PM write fence could not decide (' + why + '), so the write is refused (fail closed)' }
202}
203
204async function fenceVerdict($: Dollar, payload: Record<string, unknown>): Promise<{ deny: string } | undefined> {
205  let run: Run
206  try {
207    run = await runMm($, ['fence', '--json'], { stdin: JSON.stringify(payload), timeoutMs: TIMEOUT.fence })
208  } catch (err) {
209    return failClosed('mm.py fence did not run: ' + errorText(err))
210  }
211  if (run.exitCode !== 0) return failClosed('mm.py fence exited ' + run.exitCode + ': ' + firstLine(run.stderr))
212  let verdict: Record<string, unknown>
213  try {
214    verdict = JSON.parse(run.stdout)
215  } catch {
216    return failClosed('mm.py fence printed no JSON')
217  }
218  if (typeof verdict !== 'object' || verdict === null || verdict.schema !== 'mm.fence/1') {
219    return failClosed('mm.py fence answered another schema')
220  }
221  if (verdict.decision === 'allow') return undefined
222  const messages = Array.isArray(verdict.messages) ? verdict.messages.filter((m) => typeof m === 'string' && m !== '') : []
223  if (verdict.decision === 'deny' && messages.length > 0) return { deny: messages.join('\n') }
224  return failClosed('mm.py fence answered no allow')
225}
226
227type ChangesDoc = { raw: string; changed: string[] }
228// A snapshot read either succeeded (`doc`) or failed (`error`, why); a failure is never "no changes".
229type ChangesRead = { doc: ChangesDoc; error?: undefined } | { doc?: undefined; error: string }
230
231async function productChanges($: Dollar, taskDir: string, before?: ChangesDoc): Promise<ChangesRead> {
232  const args = ['product-changes', '--task-dir', taskDir, '--json']
233  if (before !== undefined) args.push('--baseline', '-')
234  let run: Run
235  try {
236    run = await runMm($, args, { stdin: before?.raw, timeoutMs: TIMEOUT.changes })
237  } catch (err) {
238    return { error: 'mm.py product-changes did not run: ' + errorText(err) }
239  }
240  if (run.exitCode !== 0) {
241    const why = firstLine(run.stderr)
242    return { error: 'mm.py product-changes exited ' + run.exitCode + (why === '' ? '' : ': ' + why) }
243  }
244  let parsed: any
245  try {
246    parsed = JSON.parse(run.stdout)
247  } catch {
248    return { error: 'mm.py product-changes printed no JSON' }
249  }
250  if (typeof parsed !== 'object' || parsed === null || parsed.schema !== 'mm.changes/1') {
251    return { error: 'mm.py product-changes answered another schema' }
252  }
253  if (before !== undefined && !Array.isArray(parsed.changed)) return { error: 'mm.py product-changes answered no changed list' }
254  const changed = Array.isArray(parsed.changed) ? parsed.changed.filter((f: unknown) => typeof f === 'string') : []
255  return { doc: { raw: run.stdout, changed } }
256}
257
258// The plugin never aborts a turn (operator decision, R-26: "Warn + stop note, no abort"). A change
259// attributable to the PM instead adds this note to the shell call's result, so the PM stops and asks.
260function stopNote(files: string[]): string {
261  return 'MM boundary: product files changed during your PM shell call: ' + files.join(', ') +
262    '. Stop: do not continue this task; tell the operator what changed and ask how to proceed. Do not revert anything.'
263}
264
265function monitorStopNote(why: string): string {
266  return 'MM boundary monitor failed: after your PM shell call the product-changes read failed (' + why +
267    '), so product file changes during it cannot be ruled out. Stop: do not continue this task; tell the operator the boundary monitor failed and ask how to proceed. Do not revert anything.'
268}
269
270function changedWarning(files: string[]): string {
271  return 'MM boundary: ' + files.length + ' product files changed during a PM shell call: ' + files.join(', ')
272}
273
274function monitorWarning(why: string): string {
275  return 'MM boundary monitor failed: the product-changes read after a PM shell call failed (' + why +
276    '), so product file changes during it are unknown'
277}
278
279function uncertainTail(win: ShellWindow): string {
280  if (win.reasons.length === 0) return ''
281  return '; UNCERTAIN: ' + win.reasons.join('; ') +
282    '. The change may come from a worker or a background shell, so it is not attributed to the PM call and no stop is asked'
283}
284
285// Step 4 of R-26 for one finding: toast, status prefix and pending prompt context.
286function report($: Dollar, warning: string, files: string[], failed: boolean): void {
287  $.ui.toast(warning, { timeoutMs: 15000 })
288  pendingWarnings.push(warning)
289  for (const f of files) if (!boundaryFiles.includes(f)) boundaryFiles.push(f)
290  if (failed) monitorFailed = true
291  pinStatus($)
292}
293
294function isDeny(r: any): boolean {
295  return r !== null && typeof r === 'object' && typeof r.deny === 'string'
296}
297
298// One re-check of every pending background window against its own baseline. Its findings are
299// always UNCERTAIN (reason: background shell), so they warn and never carry a stop note.
300async function checkBackgroundOnce($: Dollar): Promise<void> {
301  for (const bg of [...backgroundWindows]) {
302    const after = await productChanges($, bg.root, bg.before)
303    bg.checks += 1
304    if (after.error !== undefined) {
305      if (!bg.failureReported) {
306        bg.failureReported = true
307        report($, monitorWarning(after.error) + uncertainTail(bg.win), [], true)
308      }
309    } else {
310      const fresh = after.doc.changed.filter((f) => !bg.reported.includes(f))
311      if (fresh.length > 0) {
312        bg.reported.push(...fresh)
313        report($, changedWarning(fresh) + uncertainTail(bg.win), fresh, false)
314      }
315    }
316    if (bg.finished || bg.checks >= MAX_BACKGROUND_CHECKS) {
317      backgroundWindows.delete(bg)
318      openWindows.delete(bg.win)
319      if (!bg.finished) {
320        $.ui.toast('MM boundary: stopped watching a background PM shell call after ' + MAX_BACKGROUND_CHECKS +
321          ' checks (' + (bg.taskId ?? 'no task id') + '); product files it changes from now on are not reported', { timeoutMs: 15000 })
322      }
323    }
324  }
325}
326
327// Background windows close on the engine's completion signal: a classic.Stop whose
328// background_tasks (the session's in-flight background work [DTS StopHookInput.background_tasks,
329// BackgroundTaskSummary.id]) no longer lists the task id the shell's result named
330// [tools DTS Bash/PowerShell output backgroundTaskId] gets one final check. Without that signal a
331// window closes after MAX_BACKGROUND_CHECKS re-checks. Re-checks run one at a time, in order.
332function checkBackground($: Dollar): Promise<void> {
333  ageWorkerShells($)
334  if (backgroundWindows.size === 0) return backgroundChecks
335  backgroundChecks = backgroundChecks.then(() => checkBackgroundOnce($)).catch((err: unknown) => {
336    $.ui.log('mm-runtime: a background boundary re-check failed: ' + errorText(err), { to: 'debug' })
337  })
338  return backgroundChecks
339}
340
341// Each check of the background windows is one check of the worker background shells too.
342function ageWorkerShells($: Dollar): void {
343  for (const ws of [...workerShells]) {
344    ws.checks += 1
345    if (ws.checks >= MAX_BACKGROUND_CHECKS) {
346      workerShells.delete(ws)
347      $.ui.log('mm-runtime: stopped treating a worker background shell as running after ' + MAX_BACKGROUND_CHECKS +
348        ' checks (' + ws.label + ')', { to: 'debug' })
349    }
350  }
351}
352
353function backgroundTaskOf(r: any): string | undefined {
354  return typeof r?.result?.backgroundTaskId === 'string' ? (r.result.backgroundTaskId as string) : undefined
355}
356
357// A worker shell still running when its tool call returned (run_in_background, or moved to the
358// background later, which its result's backgroundTaskId shows) stays tracked (WPMOD-r3 F3).
359function trackWorkerShell(e: Record<string, unknown>, r: unknown, label: string): void {
360  const taskId = backgroundTaskOf(r)
361  if (isDeny(r) || (e.run_in_background !== true && taskId === undefined)) return
362  workerShells.add({ label: label + ' (' + (taskId ?? 'no task id') + ')', taskId, checks: 0 })
363}
364
365// WPMOD-r3 F1, F2: what a PM shell call is checked against, or undefined to run it unmonitored.
366// Only the latest completed status read proving session.bound === false skips the monitor; after a
367// failed read one fresh read is taken, and if that fails too the session is treated as bound.
368// Snapshots anchor at the repository root the fence uses (task.repo_root), never at the task
369// folder, which mm.py close moves; with no task known, at the session directory.
370async function monitorTarget($: Dollar): Promise<{ root: string; snap: Snapshot | undefined } | undefined> {
371  if (current.snapshot === undefined) await refresh($)
372  if (incompatible() || isUnbound(current.snapshot)) return undefined
373  const kept = isBound(lastValid) ? lastValid : undefined
374  const snap = current.snapshot ?? kept
375  const task = taskOf(snap) ?? taskOf(kept)
376  return { root: task !== undefined ? task.repo : await $.session.cwd(), snap }
377}
378
379// R-26: a PM-main-loop shell call bracketed by two product-changes reads.
380async function watchShell($: Dollar, e: Record<string, unknown>, next: (e: never) => Promise<unknown>) {
381  await checkBackground($)
382  const target = await monitorTarget($)
383  if (target === undefined) return next(e as never)
384  const { root, snap } = target
385  const runsMm = typeof e.command === 'string' && e.command.includes('mm.py')
386  const win: ShellWindow = { reasons: [], flying: inFlightIds(snap) }
387  const unknown = dispatchUnknown()
388  if (unknown !== undefined) addReason(win, unknown)
389  if (dispatchCallsInProgress.size > 0) addReason(win, 'a dispatch tool call was in progress when the window opened')
390  if (workersInProgress.size > 0) {
391    const labels = [...workersInProgress].map((w) => w.label).join('; ')
392    addReason(win, 'a delegated worker call was already in progress when the window opened (' + labels + ')')
393  }
394  if (workerShells.size > 0) {
395    const labels = [...workerShells].map((w) => w.label).join('; ')
396    addReason(win, 'a delegated worker shell may still be running in the background (' + labels + ')')
397  }
398  if (openWindows.size > 0) {
399    addReason(win, 'another PM shell call overlapped')
400    for (const other of openWindows) addReason(other, 'another PM shell call overlapped')
401  }
402  if (win.flying.length > 0) addReason(win, 'dispatches were in flight (' + win.flying.join(', ') + ')')
403  openWindows.add(win)
404  let r: any
405  let failure: { error: unknown } | undefined
406  let after: ChangesRead
407  let background: BackgroundWindow | undefined
408  try {
409    const before = await productChanges($, root)
410    if (before.error !== undefined) {
411      // An mm.py call is never refused for this (WPMOD-r3 F2): mm.py unbind must always run.
412      if (runsMm) {
413        report($, 'MM boundary monitor failed: the product-changes read before a PM shell call running mm.py failed (' +
414          before.error + '), so it ran unmonitored and product file changes during it are unknown' + uncertainTail(win), [], true)
415        return await next(e as never)
416      }
417      return {
418        deny: 'mm-runtime: cannot snapshot product files before this PM shell call (' + before.error +
419          '), so it is refused (fail closed). Fix git for ' + root +
420          ', or delegate the command to a worker, or leave PM mode: mm.py unbind --session ' + sid,
421      }
422    }
423    try {
424      r = await next(e as never)
425    } catch (err) {
426      failure = { error: err }
427    }
428    after = await productChanges($, root, before.doc)
429    // A command still running when its tool call returned (run_in_background, or moved to the
430    // background later, which its result's backgroundTaskId shows) keeps its window open.
431    const taskId = backgroundTaskOf(r)
432    if (failure === undefined && !isDeny(r) && (e.run_in_background === true || taskId !== undefined)) {
433      addReason(win, 'background shell: the command kept running after its tool call returned (' + (taskId ?? 'no task id') + ')')
434      background = {
435        win, root, before: before.doc, taskId, reported: [], checks: 0, finished: false, failureReported: false,
436      }
437      backgroundWindows.add(background)
438    }
439  } finally {
440    if (background === undefined) openWindows.delete(win)
441  }
442  if (background !== undefined) {
443    if (after.error !== undefined) {
444      background.failureReported = true
445      report($, monitorWarning(after.error) + uncertainTail(win), [], true)
446    } else if (after.doc.changed.length > 0) {
447      background.reported.push(...after.doc.changed)
448      report($, changedWarning(after.doc.changed) + uncertainTail(win), after.doc.changed, false)
449    }
450    return r
451  }
452  if (after.error === undefined && after.doc.changed.length === 0) {
453    if (failure !== undefined) throw failure.error
454    return r
455  }
456  const uncertain = win.reasons.length > 0
457  let note: string
458  if (after.error !== undefined) {
459    // A failed after read is a boundary finding too: nothing proves the call left product files alone.
460    report($, monitorWarning(after.error) + uncertainTail(win), [], true)
461    note = monitorStopNote(after.error)
462  } else {
463    const files = after.doc.changed
464    report($, changedWarning(files) + uncertainTail(win), files, false)
465    note = stopNote(files)
466  }
467  // A rejected call is propagated unchanged, after its warning. The turn is never aborted.
468  if (failure !== undefined) throw failure.error
469  // UNCERTAIN: warning only (toast, status prefix, prompt context), no stop note.
470  if (uncertain) return r
471  if (isDeny(r)) return r
472  return { ...r, context: [...(Array.isArray(r?.context) ? r.context : []), note] }
473}
474
475// R-25 step 0: the prompt path, resolved once against the session directory with string
476// operations only, so every mm.py call gets the same fully qualified path.
477function resolvePrompt(base: string, v: string): { path: string } | { deny: string } {
478  let windows: boolean
479  let root = ''
480  const unc = /^[\\/]{2}[^\\/]+[\\/][^\\/]+/.exec(base)
481  if (/^[A-Za-z]:[\\/]/.test(base)) {
482    windows = true
483    root = base.slice(0, 2)
484  } else if (unc !== null) {
485    windows = true
486    root = unc[0]
487  } else if (base.startsWith('/')) {
488    windows = false
489  } else {
490    return { deny: 'mm-runtime: the session directory ' + base + ' is neither a Windows drive or UNC path nor a POSIX path, so prompt_file cannot be resolved; nothing was run' }
491  }
492  if (windows && (/^[A-Za-z]:[\\/]/.test(v) || /^[\\/]{2}/.test(v))) return { path: v }
493  if (windows && /^[\\/]/.test(v)) return { path: root + v }
494  if (/^[A-Za-z]:/.test(v)) {
495    return {
496      deny: 'mm-runtime: prompt_file ' + v + ' is drive-relative or a drive path in a POSIX session; it cannot be resolved reliably (a drive-relative path depends on a per-drive current folder the plugin cannot see), so it is refused. Pass a fully qualified path or one relative to the session directory',
497    }
498  }
499  if (!windows && v.startsWith('/')) return { path: v }
500  return { path: base.replace(/[\\/]+$/, '') + '/' + v }
501}
502
503function runText(name: string, run: Run): string {
504  return 'mm.py ' + name + ' exit ' + run.exitCode + (run.stdout === '' ? '' : '\n' + run.stdout.trimEnd()) +
505    (run.stderr === '' ? '' : '\n' + run.stderr.trimEnd())
506}
507
508function parseApproval(run: Run): Record<string, unknown> | undefined {
509  try {
510    const parsed = JSON.parse(run.stdout)
511    if (parsed?.schema !== 'mm.approval/1' || typeof parsed.record !== 'object' || parsed.record === null) return undefined
512    return parsed.record
513  } catch {
514    return undefined
515  }
516}
517
518// R-25: the dispatch tool. Returns its answer and whether mm.py was reached.
519async function dispatchTool($: Dollar, e: Record<string, unknown>): Promise<{ answer: unknown; reached: boolean }> {
520  const fields = ['row', 'route', 'model', 'prompt_file', 'worker'] as const
521  for (const f of fields) {
522    if (typeof e[f] !== 'string' || e[f] === '') {
523      return { answer: { deny: 'mm-runtime: dispatch needs ' + fields.join(', ') + ' as non-empty strings; ' + f + ' is missing' }, reached: false }
524    }
525  }
526  if (e.launch !== undefined && typeof e.launch !== 'string') {
527    return { answer: { deny: 'mm-runtime: dispatch launch must be a string when given' }, reached: false }
528  }
529  const row = e.row as string
530  const route = e.route as string
531  const model = e.model as string
532  const worker = e.worker as string
533  const launchArgs = typeof e.launch === 'string' ? ['--launch', e.launch] : []
534  const resolved = resolvePrompt(await $.session.cwd(), e.prompt_file as string)
535  if ('deny' in resolved) return { answer: { deny: resolved.deny }, reached: false }
536  const promptFile = resolved.path
537
538  const session = await $.session.id()
539  let status: StatusRead
540  try {
541    status = readStatus(await runMm($, ['status', '--json', '--session', session], { timeoutMs: TIMEOUT.status }))
542  } catch (err) {
543    status = { reason: 'mm.py status could not run (' + errorText(err) + ')' }
544  }
545  if (status.incompatible === true) {
546    return { answer: { deny: 'mm-runtime: ' + INCOMPATIBLE + '; nothing was dispatched (' + status.reason + ')' }, reached: true }
547  }
548  const snap = status.snapshot
549  if (snap === undefined) return { answer: { deny: 'mm-runtime: mm status is unavailable (' + status.reason + '), so nothing was dispatched' }, reached: true }
550  const task = taskOf(snap)
551  if (!isBound(snap)) {
552    return { answer: { deny: 'mm-runtime: this session is not bound to an MM task; run mm.py bind --task-dir <task> --session ' + session + ' first' }, reached: true }
553  }
554  if (task === undefined) {
555    const why = errorsOf(snap).map((x) => x.message).join('; ')
556    return { answer: { deny: 'mm-runtime: the bound task cannot be read (' + (why || 'no task in the status document') + ')' }, reached: true }
557  }
558  const common = ['--task-dir', task.dir, '--row', row, '--route', route, '--model', model, '--prompt-file', promptFile]
559
560  if (modeOf(snap) === 'unattended') {
561    const run = await runMm($, ['dispatch', ...common, '--worker', worker, ...launchArgs], { cwd: task.dir, timeoutMs: TIMEOUT.dispatch })
562    return { answer: { result: runText('dispatch', run) }, reached: true }
563  }
564  if (interactive !== true) {
565    return {
566      answer: { deny: 'mm-runtime: attended dispatch needs the operator at the terminal; no one can answer here, so it is refused. Ask in chat and use mm.py dispatch --approved-by' },
567      reached: true,
568    }
569  }
570  const approveArgs = ['approve-dispatch', ...common, '--worker', worker, ...launchArgs, '--channel', 'cc-dialog', '--session', session]
571  const dry = await runMm($, [...approveArgs, '--dry-run', '--json'], { cwd: task.dir, timeoutMs: TIMEOUT.approve })
572  const draft = dry.exitCode === 0 ? parseApproval(dry) : undefined
573  if (draft === undefined || typeof draft.prompt_sha256 !== 'string') {
574    return { answer: { deny: 'mm-runtime: approve-dispatch --dry-run refused or answered no record: ' + runText('approve-dispatch', dry) }, reached: true }
575  }
576  const sha = draft.prompt_sha256
577  const launch = typeof draft.launch === 'string' && draft.launch !== '' ? draft.launch : '(none)'
578  const question = [
579    'mm dispatch for task ' + task.name,
580    'row: ' + row + ' ' + (rowItem(snap, row) ?? ''),
581    'worker: ' + worker,
582    'route: ' + route,
583    'model: ' + model,
584    'launch: ' + launch,
585    'prompt: ' + promptFile,
586    'sha256: ' + sha.slice(0, 12) + ' (' + String(draft.prompt_bytes) + ' bytes)',
587    'Approve this dispatch?',
588  ].join('\n')
589  let answer = 'Cancel'
590  try {
591    answer = await $.ui.ask(question, ['Cancel', 'Approve'])
592  } catch (err) {
593    return { answer: { deny: 'mm-runtime: operator cancelled the dispatch of row ' + row + ' (the dialog closed: ' + errorText(err) + '); nothing was approved' }, reached: true }
594  }
595  if (answer !== 'Approve') {
596    return { answer: { deny: 'mm-runtime: operator cancelled the dispatch of row ' + row + '; nothing was approved' }, reached: true }
597  }
598  const approved = await runMm($, [...approveArgs, '--expect-sha256', sha, '--json'], { cwd: task.dir, timeoutMs: TIMEOUT.approve })
599  const record = approved.exitCode === 0 ? parseApproval(approved) : undefined
600  if (record === undefined || typeof record.id !== 'string') {
601    return { answer: { deny: 'mm-runtime: approve-dispatch refused, nothing was dispatched: ' + runText('approve-dispatch', approved) }, reached: true }
602  }
603  const run = await runMm($, ['dispatch', ...common, '--approval-id', record.id, '--worker', worker, ...launchArgs], {
604    cwd: task.dir, timeoutMs: TIMEOUT.dispatch,
605  })
606  return { answer: { result: runText('dispatch', run) }, reached: true }
607}
608
609const DISPATCH_SCHEMA = {
610  type: 'object',
611  properties: {
612    row: { type: 'string', description: 'The ledger row id the dispatch serves.' },
613    route: { type: 'string', description: 'The mm route, as mm.py dispatch --route takes it.' },
614    model: { type: 'string', description: 'The model the worker runs on.' },
615    prompt_file: {
616      type: 'string',
617      description: 'The prompt file: fully qualified, root-relative on Windows (takes the session drive), or relative to the session directory; drive-relative values such as C:foo are refused.',
618    },
619    worker: { type: 'string', enum: WORKERS, description: 'Who runs the work.' },
620    launch: { type: 'string', description: 'Optional launch template, as mm.py dispatch --launch takes it; {prompt} becomes the prompt copy path.' },
621  },
622  required: ['row', 'route', 'model', 'prompt_file', 'worker'],
623}
624
625export const register: Register = (on) => {
626  on('session.start', async ($, e, next) => {
627    if (!configured) {
628      $.ui.status('MM runtime: not configured')
629      return next(e)
630    }
631    interactive = e.isInteractive === true
632    await startSession($)
633    try {
634      await $.tool.register({
635        name: 'dispatch',
636        description: 'Record an mm dispatch for the bound task. Attended tasks ask the operator in a dialog bound to this exact call (row, worker, route, model, launch, prompt hash) before mm.py dispatch runs.',
637        inputSchema: DISPATCH_SCHEMA,
638      })
639    } catch (err) {
640      $.ui.log('mm-runtime: the dispatch tool was not registered: ' + errorText(err), { to: 'debug' })
641    }
642    try {
643      await $.command.register({ name: 'mm-runtime', description: 'Show the mm task pane', argumentHint: '', immediate: true })
644    } catch (err) {
645      $.ui.log('mm-runtime: /mm-runtime was not registered: ' + errorText(err), { to: 'debug' })
646    }
647    return next(e)
648  })
649
650  on('classic.SessionStart', async ($, e, next) => {
651    if (!configured) return next(e)
652    await startSession($)
653    return next(e)
654  })
655
656  // MultiEdit is not a built-in tool of every build, so its name is typed loosely here.
657  on('tool.call', { tool: ['Edit', 'Write', 'MultiEdit' as never, 'NotebookEdit'] }, async ($, e, next) => {
658    if (!configured || incompatible()) return next(e)
659    const call = e as Record<string, unknown>
660    const worker = call.agentId !== undefined ? workerStarted(call) : undefined
661    seeAgent(call.agentId)
662    try {
663      // Only the latest completed status read may waive the fence, never a snapshot kept after a failed refresh.
664      if (isUnbound(current.snapshot)) return await next(e)
665      const payload = await fencePayload($, call)
666      const denied = await fenceVerdict($, payload)
667      if (denied !== undefined) return denied
668      return await next(e)
669    } finally {
670      if (worker !== undefined) workersInProgress.delete(worker)
671    }
672  }).catch(($, e, next) => ({
673    deny: 'mm-runtime: the PM write fence failed (' + next.error.kind + ': ' + next.error.message + '), so the write is refused (fail closed)',
674  }))
675
676  on('tool.call', { tool: ['Bash', 'PowerShell'] }, async ($, e, next) => {
677    if (!configured) return next(e)
678    const call = e as Record<string, unknown>
679    const command = typeof call.command === 'string' ? call.command : ''
680    let r: unknown
681    if (call.agentId !== undefined) {
682      seeAgent(call.agentId)
683      const worker = workerStarted(call)
684      try {
685        r = await next(e)
686      } finally {
687        workersInProgress.delete(worker)
688      }
689      trackWorkerShell(call, r, worker.label)
690    } else if (incompatible()) {
691      r = await next(e)
692    } else {
693      r = await watchShell($, call, next as never)
694    }
695    if (command.includes('mm.py')) await refresh($)
696    return r as never
697  })
698
699  on('tool.call', { tool: DISPATCH_TOOL }, async ($, e, next) => {
700    if (!configured) return next(e)
701    // WPMOD-r4 F2: a dispatch may start a worker, so every PM window open meanwhile is UNCERTAIN.
702    const call = {}
703    dispatchCallsInProgress.add(call)
704    for (const win of openWindows) addReason(win, 'a dispatch tool call ran during the window')
705    try {
706      const out = await dispatchTool($, e as Record<string, unknown>)
707      if (out.reached) await refresh($)
708      return out.answer as never
709    } finally {
710      dispatchCallsInProgress.delete(call)
711    }
712  }).catch(($, e, next) => ({
713    deny: 'mm-runtime: the dispatch tool failed (' + next.error.kind + ': ' + next.error.message + '); nothing was approved by this call',
714  }))
715
716  on('turn.complete', async ($, e, next) => {
717    if (!configured) return next(e)
718    await refresh($)
719    if (e.agentId === undefined) await checkBackground($)
720    return next(e)
721  })
722
723  // The completion signal for background PM shell calls (WPMOD-r2 F1, see checkBackground) and
724  // worker background shells (WPMOD-r3 F3).
725  on('classic.Stop', async ($, e, next) => {
726    if (!configured || (backgroundWindows.size === 0 && workerShells.size === 0)) return next(e)
727    const inFlight = (e as Record<string, unknown>).background_tasks
728    if (Array.isArray(inFlight)) {
729      const ids = inFlight.map((t: unknown) => (typeof t === 'object' && t !== null ? (t as Record<string, unknown>).id : undefined))
730      for (const ws of [...workerShells]) {
731        if (ws.taskId !== undefined && !ids.includes(ws.taskId)) workerShells.delete(ws)
732      }
733      let done = false
734      for (const bg of backgroundWindows) {
735        if (bg.taskId !== undefined && !ids.includes(bg.taskId)) {
736          bg.finished = true
737          done = true
738        }
739      }
740      if (done) await checkBackground($)
741    }
742    return next(e)
743  })
744
745  on('command.run', { command: 'mm-runtime' }, async ($, e, next) => {
746    if (!configured) return next(e)
747    if (incompatible()) return { text: INCOMPATIBLE + ' (' + current.reason + ')' }
748    const snap = current.snapshot
749    if (snap === undefined) {
750      return {
751        text: 'mm-runtime: mm status is unavailable (' + current.reason + '); run ' + PYTHON + ' ' + MM_PY +
752          ' status --json --session ' + sid + ' to see why',
753      }
754    }
755    if (isUnbound(snap)) return { text: 'mm-runtime: this session is not bound to an MM task' }
756    const lines = paneLines(snap, agentIdsSeen)
757    const asText = lines.map((l) => l.text).join('\n')
758    try {
759      const opened = await $.ui.open({ id: PANE_ID, title: 'mm', focus: true, closeOnEscape: true })
760      if (opened.isPlaced === true) return {}
761      return { text: asText + '\n(pane not placed: ' + opened.reason + ')' }
762    } catch (err) {
763      return { text: asText + '\n(pane not opened: ' + errorText(err) + ')' }
764    }
765  })
766
767  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
768    if (!configured || e.requestId !== PANE_ID) return next(e)
769    const { Box, Text } = $.ui.resolve(e)
770    const [header, ...body] = paneLines(current.snapshot, agentIdsSeen)
771    return Box({
772      flexDirection: 'column',
773      children: [
774        Text({ bold: true, wrap: 'truncate-end', children: [header === undefined ? 'mm' : header.text] }),
775        ...body.map((l) => Text({ wrap: 'truncate-end', dimColor: l.dim, children: [l.text] })),
776      ],
777    })
778  })
779
780  on('prompt.submit', async ($, e, next) => {
781    if (!configured) return next(e)
782    await checkBackground($)
783    if (pendingWarnings.length === 0) return next(e)
784    const warnings = pendingWarnings
785    pendingWarnings = []
786    boundaryFiles = []
787    monitorFailed = false
788    pinStatus($)
789    return next({ ...e, context: [...(e.context ?? []), ...warnings] })
790  })
791}
792
hooks/mm-config.ts 5 lines
1// Written by the installer (scripts/mm_runtime_install.py) in the installed copy only.
2// Empty here means "not configured": every handler passes through.
3export const PYTHON = ''
4export const MM_PY = ''
5
hooks/present.ts 273 lines
1// Pure presentation of an `mm.py status --json` document (schema mm.status/1): the
2// status line (R-22) and the pane lines (R-27). No `$`, no I/O. Every field is read
3// defensively, so a null object never reaches a template string.
4
5export const STATUS_SCHEMA = 'mm.status/1'
6
7export type PaneLine = { text: string; dim?: boolean }
8
9// A document that passed readStatus; its fields are still read through the guards below.
10export type Snapshot = Record<string, unknown> & { schema: 'mm.status/1' }
11
12// `incompatible` is set only on positive proof that mm.py predates this plugin (see readStatus).
13export type StatusRead =
14  | { snapshot: Snapshot; reason?: undefined; incompatible?: undefined }
15  | { snapshot?: undefined; reason: string; incompatible?: boolean }
16
17type Obj = Record<string, unknown>
18
19const isObj = (v: unknown): v is Obj => typeof v === 'object' && v !== null && !Array.isArray(v)
20
21function text(v: unknown, fallback: string): string {
22  if (typeof v === 'string' && v !== '') return v
23  if (typeof v === 'number' && Number.isFinite(v)) return String(v)
24  return fallback
25}
26
27const list = (v: unknown): unknown[] => (Array.isArray(v) ? v : [])
28
29const obj = (v: unknown): Obj | undefined => (isObj(v) ? v : undefined)
30
31function firstLine(s: string): string {
32  const line = s.split('\n').find((l) => l.trim() !== '')
33  return line === undefined ? '' : line.trim().slice(0, 200)
34}
35
36// Positive proof that mm.py predates this plugin's CLI: `status --json --session <sid>` exits 2
37// with an argparse usage error (r25 answers "the following arguments are required: --task-dir";
38// older or newer mismatches answer "unrecognized arguments" or "invalid choice"), or exits 2
39// printing a JSON document that names a schema other than mm.status/1. Anything else is not proof.
40const ARGPARSE_REFUSAL = /: error: (argument <command>: invalid choice|unrecognized arguments|the following arguments are required)/
41
42function tooOldReason(run: { exitCode: number; stdout: string; stderr: string }): string | undefined {
43  if (run.exitCode !== 2) return undefined
44  const refusal = run.stderr.split('\n').find((l) => ARGPARSE_REFUSAL.test(l))
45  if (/^usage: /m.test(run.stderr) && refusal !== undefined) return 'mm.py status refused the arguments: ' + refusal.trim().slice(0, 200)
46  try {
47    const parsed: unknown = JSON.parse(run.stdout)
48    if (isObj(parsed) && typeof parsed.schema === 'string' && parsed.schema !== STATUS_SCHEMA) {
49      return 'mm.py status answered schema ' + parsed.schema.slice(0, 80) + ' with exit 2, not ' + STATUS_SCHEMA
50    }
51  } catch {
52    return undefined
53  }
54  return undefined
55}
56
57// Reads one `mm.py status --json` run. A non-zero exit, non-JSON, another schema or a
58// malformed document gives a reason and no snapshot (the STATUS UNAVAILABLE case).
59export function readStatus(run: { exitCode: number; stdout: string; stderr: string }): StatusRead {
60  if (run.exitCode !== 0) {
61    const tooOld = tooOldReason(run)
62    if (tooOld !== undefined) return { reason: tooOld, incompatible: true }
63    const why = firstLine(run.stderr) || firstLine(run.stdout)
64    return { reason: 'mm.py status exited ' + run.exitCode + (why === '' ? '' : ': ' + why) }
65  }
66  let parsed: unknown
67  try {
68    parsed = JSON.parse(run.stdout)
69  } catch {
70    return { reason: 'mm.py status printed no JSON document' }
71  }
72  if (!isObj(parsed)) return { reason: 'mm.py status printed no JSON object' }
73  if (parsed.schema !== STATUS_SCHEMA) {
74    return { reason: 'mm.py status answered schema ' + text(parsed.schema, 'none') + ', not ' + STATUS_SCHEMA }
75  }
76  const session = obj(parsed.session)
77  if (session === undefined || typeof session.bound !== 'boolean' || !Array.isArray(parsed.errors)) {
78    return { reason: 'mm.py status answered a malformed ' + STATUS_SCHEMA + ' document' }
79  }
80  return { snapshot: parsed as Snapshot }
81}
82
83export function isBound(s: Snapshot | undefined): boolean {
84  return s !== undefined && obj(s.session)?.bound === true
85}
86
87export function isUnbound(s: Snapshot | undefined): boolean {
88  return s !== undefined && obj(s.session)?.bound === false
89}
90
91// The bound task's name, folder and repository root (the folder when the document names no
92// root), or undefined when the document carries no task.
93export function taskOf(s: Snapshot | undefined): { name: string; dir: string; repo: string } | undefined {
94  const task = s === undefined ? undefined : obj(s.task)
95  if (task === undefined || typeof task.dir !== 'string' || task.dir === '') return undefined
96  return { name: text(task.name, lastComponent(task.dir)), dir: task.dir, repo: text(task.repo_root, task.dir) }
97}
98
99export function modeOf(s: Snapshot | undefined): string | undefined {
100  return s !== undefined && typeof s.mode === 'string' ? s.mode : undefined
101}
102
103export function inFlightIds(s: Snapshot | undefined): string[] {
104  return s === undefined ? [] : list(s.dispatches_in_flight).map((d) => text(obj(d)?.id, 'unnamed'))
105}
106
107export function rowItem(s: Snapshot | undefined, id: string): string | undefined {
108  if (s === undefined) return undefined
109  const row = list(s.rows).map(obj).find((r) => r !== undefined && r.id === id)
110  return row === undefined ? undefined : text(row.item, '')
111}
112
113export function errorsOf(s: Snapshot | undefined): { code: string; message: string }[] {
114  if (s === undefined) return []
115  return list(s.errors).map((e) => ({ code: text(obj(e)?.code, 'UNKNOWN-ERROR'), message: text(obj(e)?.message, 'no message') }))
116}
117
118function lastComponent(dir: string): string {
119  const parts = dir.split(/[\\/]+/).filter((p) => p !== '')
120  return parts.length === 0 ? dir : (parts[parts.length - 1] as string)
121}
122
123// First matching code wins, in this order (R-22 rule 3).
124const ERROR_ORDER = [
125  'BINDING-UNREADABLE', 'TASK-DIR-MISSING', 'BINDING-LEGACY', 'FENCE-ROOT-TOO-BROAD', 'LEDGER-INVALID',
126  'GIT-FAILED', 'CHECK-RAISED', 'NO-TASK', 'SESSION-ID-UNUSABLE',
127]
128
129function errorLine(code: string, s: Snapshot): string {
130  const task = taskOf(s)
131  const name = task === undefined ? 'no task' : task.name
132  const folder = task === undefined ? 'no task' : lastComponent(task.dir)
133  switch (code) {
134    case 'BINDING-UNREADABLE':
135      return 'MM · PM · BINDING UNREADABLE: mm.py unbind, then bind'
136    case 'TASK-DIR-MISSING':
137      return 'MM · ' + folder + ' · PM · TASK FOLDER MISSING: mm.py unbind'
138    case 'BINDING-LEGACY':
139      return 'MM · ' + name + ' · PM · REBIND NEEDED: mm.py bind'
140    case 'FENCE-ROOT-TOO-BROAD':
141      return 'MM · ' + name + ' · PM · FENCE ROOT TOO BROAD: mm.py unbind'
142    case 'LEDGER-INVALID':
143      return 'MM · ' + name + ' · LEDGER INVALID: mm.py check'
144    case 'GIT-FAILED':
145      return 'MM · ' + name + ' · GIT FAILED: fix git'
146    case 'CHECK-RAISED':
147      return 'MM · ' + name + ' · CHECK RAISED: mm.py check'
148    case 'NO-TASK':
149      return 'MM · NO TASK: mm.py bind'
150    case 'SESSION-ID-UNUSABLE':
151      return 'MM · SESSION ID UNUSABLE: see /mm-runtime'
152    default:
153      return 'MM · ' + name + ' · ' + code + ': see /mm-runtime'
154  }
155}
156
157function baseLine(s: Snapshot | undefined): string | undefined {
158  if (s === undefined) return 'MM · STATUS UNAVAILABLE'
159  const errors = errorsOf(s)
160  if (isUnbound(s) && errors.length === 0) return undefined
161  if (errors.length > 0) {
162    const codes = errors.map((e) => e.code)
163    const code = ERROR_ORDER.find((c) => codes.includes(c)) ?? (codes[0] as string)
164    return errorLine(code, s) + (errors.length > 1 ? ' · +' + (errors.length - 1) + ' more' : '')
165  }
166  const task = taskOf(s)
167  const name = task === undefined ? 'no task' : task.name
168  const check = obj(s.check)
169  if (check?.status === 'failed') return 'MM · ' + name + ' · CHECK FAILED (exit ' + text(check.code, 'unknown') + ')'
170  const parts = ['MM', name, 'PM']
171  if (s.mode === 'unattended') parts.push('AUTO')
172  else if (s.mode === 'attended') parts.push('ATTENDED')
173  const open = obj(s.open_row)
174  if (open !== undefined) parts.push('row ' + text(open.id, 'unnamed') + ' ' + text(open.state, 'unknown'))
175  const inbox = obj(s.inbox)
176  if (inbox !== undefined) parts.push('inbox ' + text(inbox.entries, '0'))
177  parts.push('builders ' + list(s.dispatches_in_flight).length)
178  parts.push('waiting ' + list(s.waiting_on_operator).length)
179  const loop = obj(s.loop)
180  if (s.mode === 'unattended' && loop !== undefined) {
181    parts.push('no-op ' + text(loop.noop_count, '0') + '/' + text(loop.noop_cap, 'unknown'))
182  }
183  if (check?.status === 'fixable') parts.push('check fixable')
184  return parts.join(' · ')
185}
186
187// The status line (R-22); undefined removes it. A pending boundary warning of
188// `boundaryFiles` files adds the BOUNDARY prefix to any form, and a pending boundary
189// monitor failure the BOUNDARY MONITOR FAILED prefix before it.
190export function statusLine(s: Snapshot | undefined, boundaryFiles: number, monitorFailed = false): string | undefined {
191  const parts: string[] = []
192  if (monitorFailed) parts.push('MM · BOUNDARY MONITOR FAILED')
193  if (boundaryFiles > 0) parts.push('MM · BOUNDARY ' + boundaryFiles + ' files')
194  const line = baseLine(s)
195  if (line !== undefined) parts.push(line)
196  return parts.length === 0 ? undefined : parts.join(' · ')
197}
198
199// The pane (R-27): header, one line per error, the rows, waiting, inbox, dispatches in
200// flight, agent ids seen, loop, check lines (dim) and the uncommitted count.
201export function paneLines(s: Snapshot | undefined, agentIdsSeen: readonly string[]): PaneLine[] {
202  if (s === undefined) return [{ text: 'mm · no task · no role · no mode' }, { text: 'status: unavailable' }]
203  const lines: PaneLine[] = []
204  const task = taskOf(s)
205  const session = obj(s.session)
206  lines.push({
207    text: 'mm · ' + (task === undefined ? 'no task' : task.name) + ' · ' + text(session?.role, 'no role') + ' · ' +
208      text(s.mode, 'no mode'),
209  })
210  for (const e of errorsOf(s)) lines.push({ text: e.code + ': ' + e.message })
211  const open = obj(s.open_row)
212  const rows = list(s.rows).map(obj).filter((r): r is Obj => r !== undefined)
213  if (rows.length === 0) lines.push({ text: 'rows: none' })
214  for (const r of rows) {
215    const marker = open !== undefined && r.id === open.id ? '> ' : ''
216    lines.push({
217      text: marker + text(r.id, 'unnamed') + ' ' + text(r.state, 'unknown') + ' (' + text(r.status_label, 'no label') + ') ' +
218        text(r.item, 'no item'),
219    })
220  }
221  const waiting = list(s.waiting_on_operator).map(obj).filter((w): w is Obj => w !== undefined)
222  if (waiting.length === 0) lines.push({ text: 'waiting on operator: none' })
223  for (const w of waiting) {
224    lines.push({ text: 'waiting on operator: ' + text(w.kind, 'item') + ' for ' + text(w.row, 'no row') + ': ' + text(w.text, '') })
225  }
226  const inbox = obj(s.inbox)
227  lines.push({
228    text: inbox === undefined
229      ? 'inbox: unavailable'
230      : 'inbox: ' + text(inbox.entries, '0') + ' entries, ' + text(inbox.problems, '0') + ' problems, ' +
231        text(inbox.closed_task_files, '0') + ' closed-task files',
232  })
233  const flying = list(s.dispatches_in_flight).map(obj).filter((d): d is Obj => d !== undefined)
234  if (flying.length === 0) lines.push({ text: 'dispatches in flight: none' })
235  for (const d of flying) {
236    let line = 'dispatch in flight: ' + text(d.id, 'unnamed') + ' row ' + text(d.row, 'none') + ' ' + text(d.route, 'unknown') +
237      ':' + text(d.model, 'unknown')
238    if (typeof d.worker === 'string' && d.worker !== '') line += ' worker ' + d.worker
239    if (typeof d.approval_id === 'string' && d.approval_id !== '') line += ' approval ' + d.approval_id
240    lines.push({ text: line + ' · last check ' + text(d.last_verdict, 'never') })
241  }
242  lines.push({
243    text: 'agents seen this session (historical, not live): ' + (agentIdsSeen.length === 0 ? 'none' : agentIdsSeen.join(', ')),
244  })
245  const loop = obj(s.loop)
246  if (loop === undefined) {
247    lines.push({ text: 'loop: unavailable' })
248  } else {
249    let line = 'loop: ' + text(loop.mode, 'unknown') + ' · cadence ' + text(loop.cadence, 'not set') + ' · no-op ' +
250      text(loop.noop_count, '0') + '/' + text(loop.noop_cap, 'unknown')
251    const tick = obj(loop.last_tick)
252    if (tick !== undefined && typeof tick.at === 'string' && tick.at !== '') {
253      line += ' · last tick ' + tick.at + ' ' + text(tick.result, '')
254    }
255    if (typeof loop.stopped_reason === 'string' && loop.stopped_reason !== '') line += ' · stopped: ' + loop.stopped_reason
256    lines.push({ text: line.trimEnd() })
257  }
258  const check = obj(s.check)
259  const code = check?.code
260  lines.push({
261    text: 'check: ' + text(check?.status, 'unknown') + (typeof code === 'number' ? ' (exit ' + code + ')' : ''),
262    dim: true,
263  })
264  for (const l of list(check?.lines)) {
265    if (typeof l === 'string' && l !== '') lines.push({ text: 'check | ' + l, dim: true })
266  }
267  const uncommitted = s.uncommitted
268  lines.push({
269    text: Array.isArray(uncommitted) ? 'uncommitted: ' + uncommitted.length + ' files' : 'uncommitted: unavailable',
270  })
271  return lines
272}
273