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

Personal Claude Code skills, babysitter processes, agents, hooks, and commands.
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.
| Folder | Symlinked to | What 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) |
| Skill | Command | Description |
|---|---|---|
babysitter-multi-session | /babysitter-multi-session | Generate a run-sessions.sh script to chain multiple babysitter yolo sessions sequentially |
bh | /bh | Bug Hunter — scan, TDD-fix, conventions gate, code review, DoD gate, commit |
bh-forever | /bh-forever | Continuous bug hunting loop until convergence score ≥ 90 |
codex-cli | /codex-cli | Codex CLI integration |
codex-review | /codex-review | Independent, 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-pipeline | Takes 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-rtl | Apply 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 | /gemini | Gemini CLI integration |
langtalk | /langtalk | Hybrid 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-plan | Deep 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-finder | Plan 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-spec | Convert 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-prd | Convert 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 | /mm | Micromanager — 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-mailbox | Hand 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-cloud | To-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-it | Spawn 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-multilang | Parallel 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.
| Process | Description |
|---|---|
bug-hunter.js | Babysitter process driving the full BH pipeline |
deep-plan-verification.js | Phase 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.js | Babysitter 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.js | Babysitter 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. |
Standalone bash scripts and tools. See each folder for its own install instructions.
| Script | Description |
|---|---|
scripts/issue-loop.sh | Auto-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. |
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.
hooks/register.ts 792 lines1// 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}
792hooks/mm-config.ts 5 lines1// 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 = ''
5hooks/present.ts 273 lines1// 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