const MUXLOG_JS: &str = "#!/usr/bin/env node\n// Copyright 2026, AgentMux Corp.\n// SPDX-License-Identifier: Apache-2.0\n//\n// muxlog \u{2014} discover, render, and follow AgentMux logs across every instance.\n//\n// Deployed by agentmux-srv next to the shell rcfiles (~/.agentmux/shell/muxlog.mjs);\n// the bash/zsh/pwsh/fish `muxlog` functions delegate here. Run standalone with\n// `node muxlog.mjs ...` (works from any subshell, unlike the shell function).\n//\n// Why a Node core instead of per-shell functions: log lines are structured NDJSON,\n// they live in three different root trees (shared, dev/<branch>, channels/local-*),\n// and the old version-pinned pointer routinely resolved a STALE instance\'s log.\n// One tested implementation does discovery + JSON rendering + filtering uniformly.\n\nimport fs from \"node:fs\";\nimport os from \"node:os\";\nimport path from \"node:path\";\nimport { execFileSync } from \"node:child_process\";\nimport { pathToFileURL } from \"node:url\";\n\nconst HOME = os.homedir();\nconst AGENTMUX = path.join(HOME, \".agentmux\");\n\n// \u{2500}\u{2500}\u{2500} Log discovery \u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\n// Logs can live in any of these roots (the shared dir alone is NOT enough \u{2014} dev\n// builds and per-build channels keep their host log in their own data dir):\n// ~/.agentmux/logs/ (shared: launcher, and\n// srv when AGENTMUX_LOG_DIR\n// isn\'t set \u{2014} see below)\n// ~/.agentmux/dev/<branch>/<hash>/logs/ (task dev, keyed on branch)\n// ~/.agentmux/channels/<channel>/versions/<v>/logs/ (portable/per-build; both\n// host AND srv as of\n// crates/srv/src/bootstrap/logging.rs\n// honoring AGENTMUX_LOG_DIR \u{2014}\n// see REPORT_MUXSPECT_MUXLOG_\n// CROSS_CHANNEL_INSPECTION_2026_08_22.md)\n//\n// The confirmed on-disk depth is `versions/<v>/logs` (ONE directory between\n// version and logs \u{2014} verified against a real running instance\'s own\n// AGENTMUX_LOG_DIR). An earlier version of this glob assumed TWO directories\n// (`versions/*/*/logs`), which never actually matches that shape \u{2014} silently\n// finding zero channel-build logs ever, on any platform, the entire time this\n// source existed. Try both depths (cheap \u{2014} glob() is a handful of readdirSync\n// calls) so a differently-shaped install elsewhere still resolves, deduping by\n// path so a hit on the 1-level pattern doesn\'t also list on the 2-level one.\nfunction* logRoots() {\n yield { dir: path.join(AGENTMUX, \"logs\"), source: \"shared\" };\n for (const p of glob(path.join(AGENTMUX, \"dev\", \"*\", \"*\", \"logs\")))\n yield { dir: p, source: \"dev:\" + p.split(path.sep).at(-3) };\n const seen = new Set();\n for (const p of glob(path.join(AGENTMUX, \"channels\", \"*\", \"versions\", \"*\", \"logs\"))) {\n if (seen.has(p)) continue; seen.add(p);\n yield { dir: p, source: \"channel:\" + p.split(path.sep).at(-4) };\n }\n for (const p of glob(path.join(AGENTMUX, \"channels\", \"*\", \"versions\", \"*\", \"*\", \"logs\"))) {\n if (seen.has(p)) continue; seen.add(p);\n yield { dir: p, source: \"channel:\" + p.split(path.sep).at(-5) };\n }\n}\n\n// Tiny one-level-per-`*` glob (no deps). Only handles `*` path segments.\n// Exported (pure, no I/O beyond readdirSync/existsSync \u{2014} no network, no\n// process.exit) for muxlog.test.mjs, which pins the depth-matching bug fixed\n// in logRoots() below (an earlier version of the channels/ glob pattern had\n// one wildcard segment too many and could never match a real install).\nexport function glob(pattern) {\n const parts = pattern.split(path.sep);\n let bases = [parts[0] === \"\" ? path.sep : parts[0]];\n for (let i = 1; i < parts.length; i++) {\n const seg = parts[i];\n const next = [];\n for (const base of bases) {\n if (seg.includes(\"*\")) {\n const re = new RegExp(\"^\" + seg.replace(/[.+?^${}()|[\\]\\\\]/g, \"\\\\$&\").replace(/\\*/g, \".*\") + \"$\");\n let entries = [];\n try { entries = fs.readdirSync(base, { withFileTypes: true }); } catch { /* skip */ }\n for (const e of entries) if (re.test(e.name)) next.push(path.join(base, e.name));\n } else {\n next.push(path.join(base, seg));\n }\n }\n bases = next;\n }\n return bases.filter((p) => { try { return fs.existsSync(p); } catch { return false; } });\n}\n\nconst TARGET_GLOB = {\n host: /^agentmux-host-v.*\\.log(\\..*)?$/,\n srv: /^agentmuxsrv-v.*\\.log(\\..*)?$/,\n launcher: /^agentmux-launcher\\.log$/,\n};\n\n// Returns [{target, file, mtime, size, version, source}] across all roots, newest first.\nfunction discover(target) {\n const out = [];\n const want = target === \"fe\" || target === \"all\" ? null : target;\n for (const { dir, source } of logRoots()) {\n let entries = [];\n try { entries = fs.readdirSync(dir); } catch { continue; }\n for (const name of entries) {\n for (const [t, re] of Object.entries(TARGET_GLOB)) {\n if (want && t !== want) continue;\n if (!re.test(name)) continue;\n const full = path.join(dir, name);\n let st; try { st = fs.statSync(full); } catch { continue; }\n const ver = (name.match(/v(\\d+\\.\\d+\\.\\d+)/) || [])[1] || \"?\";\n out.push({ target: t, file: full, mtime: st.mtimeMs, size: st.size, version: ver, source });\n }\n }\n }\n return out.sort(newestLogFirst);\n}\n\n// Newest mtime first; on a tie, the later rotation date in the name. Two daily\n// files written within the same mtime tick (a rollover, or a test writing both)\n// otherwise came out in readdir order \u{2014} older date first.\nexport function newestLogFirst(a, b) {\n const byMtime = b.mtime - a.mtime;\n if (byMtime) return byMtime;\n const da = logNameParts(path.basename(a.file))?.date ?? \"\";\n const db = logNameParts(path.basename(b.file))?.date ?? \"\";\n return db < da ? -1 : db > da ? 1 : 0;\n}\n\n// \u{2500}\u{2500}\u{2500} Rendering \u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\nconst LVL_COLOR = { ERROR: \"\\x1b[31m\", WARN: \"\\x1b[33m\", INFO: \"\\x1b[2m\", DEBUG: \"\\x1b[2m\" };\nconst RESET = \"\\x1b[0m\";\nconst DIM = \"\\x1b[2m\";\n\n// An NDJSON log line \u{2192} one compact human line (or null if filtered out).\n// Exported (pure \u{2014} no I/O) for muxlog.test.mjs, which pins Ext 6\'s\n// `-d/--dispatch` raw-line-substring behavior directly.\nexport function renderLine(raw, opt) {\n const t = raw.trim();\n if (!t) return null;\n // Ext 6: `--dispatch <id>` \u{2014} checked on the RAW line text, before JSON\n // parsing and regardless of it, because a dispatch/subagent id can show\n // up either in the rendered message OR as a bare structured field value\n // (`dispatch_id`, `session_id`, `agent_id`, ... \u{2014} the exact field name\n // varies per call site, see the tracing calls in subagent_watcher/).\n // `--grep` only ever matches the message text (phases\' own doc comment\n // above), which would silently miss a field-only occurrence; a plain\n // substring check on the whole raw line catches both uniformly without\n // needing to enumerate every field name that might carry an id.\n if (opt.dispatch && !t.includes(opt.dispatch)) return null;\n let j;\n try {\n j = JSON.parse(t);\n } catch {\n // Non-JSON line (a panic backtrace, a raw println). It has no structured\n // fields, so any structured filter excludes it; a --grep must still match\n // its text. Otherwise pass it through verbatim so nothing is lost.\n if (opt.level || opt.target || opt.excludeTarget || opt.since || opt.until || opt.agentMatch) return null;\n if (opt.grep && !opt.grep.test(t)) return null;\n return t;\n }\n const fields = j.fields || {};\n const msg = fields.message ?? j.message ?? \"\";\n const target = j.target || \"\";\n const level = (j.level || \"INFO\").toUpperCase();\n\n // Default: drop agent-conversation transcript noise (the srv log is mostly this).\n if (!opt.all && /blockcontroller::subprocess|subprocess stdout \u{2192} blockfile/.test(target + \" \" + msg)) return null;\n if (opt.level && !opt.level.includes(level.toLowerCase())) return null;\n if (opt.target && !target.includes(opt.target)) return null;\n if (opt.excludeTarget && target.includes(opt.excludeTarget)) return null;\n if (opt.grep && !opt.grep.test(String(msg))) return null;\n if (opt.since && j.timestamp && j.timestamp < opt.since) return null;\n if (opt.until && j.timestamp && j.timestamp > opt.until) return null;\n if (opt.agentMatch && !opt.agentMatch(j)) return null;\n\n if (opt.raw) return t;\n const ts = (j.timestamp || \"\").replace(/^.*T/, \"\").replace(/\\..*$/, \"\") || \"--:--:--\";\n const c = LVL_COLOR[level] ?? \"\";\n let line = `${DIM}${ts}${RESET} ${c}${level.padEnd(5)}${RESET} ${DIM}${shortTarget(target)}${RESET} ${msg}`;\n if (opt.verbose) {\n const extra = { ...fields }; delete extra.message;\n const keys = Object.keys(extra);\n if (keys.length) line += ` ${DIM}${JSON.stringify(extra)}${RESET}`;\n } else if (opt.inlineFields) {\n line += inlineFieldsSuffix(fields, opt.inlineFields);\n }\n return line;\n}\n\n// A recipe\'s chosen structured fields, shown inline without --verbose\'s full\n// JSON dump (`admission`: holder/released/epoch/\u{2026} are the facts that matter,\n// and they never appear in the message text). `block_id` is shortened to 8\n// characters and `why` to 120; everything else is shown as-is.\nexport function inlineFieldsSuffix(fields, keys) {\n const parts = [];\n for (const k of keys) {\n let v = fields[k];\n if (v == null || v === \"\") continue;\n v = String(v);\n if (k === \"block_id\") v = v.slice(0, 8);\n else if (k === \"why\" && v.length > 120) v = v.slice(0, 117) + \"...\";\n parts.push(`${k}=${v}`);\n }\n return parts.length ? ` ${DIM}${parts.join(\" \")}${RESET}` : \"\";\n}\n\nfunction shortTarget(t) {\n // agentmux_cef::commands::backend \u{2192} cef:backend ; agentmux_srv::server \u{2192} srv:server\n return t.replace(/^agentmux_/, \"\").replace(/::/g, \":\").replace(/:[^:]+:/g, \":\");\n}\n\n// \u{2500}\u{2500}\u{2500} Follow (tail -f over NDJSON) \u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\n// Read for display. For a plain tail of a huge file we only need the end, so\n// read the last 8 MB (the launcher log can reach hundreds of MB). When we have\n// to scan/filter the whole history (grep, recipes, explicit filters) pass\n// whole=true so matches aren\'t missed.\nfunction readForDisplay(file, whole) {\n let size = 0; try { size = fs.statSync(file).size; } catch { return \"\"; }\n const WINDOW = 8 * 1024 * 1024;\n if (whole || size <= WINDOW) { try { return fs.readFileSync(file, \"utf8\"); } catch { return \"\"; } }\n const fd = fs.openSync(file, \"r\");\n const buf = Buffer.alloc(WINDOW);\n fs.readSync(fd, buf, 0, WINDOW, size - WINDOW);\n fs.closeSync(fd);\n const s = buf.toString(\"utf8\");\n return s.slice(s.indexOf(\"\\n\") + 1); // drop the partial first line\n}\n\n// Filter FIRST, then keep the last n survivors \u{2014} so `muxlog bridge`/`grep`\n// returns the last n *matching* lines, not \"the last n lines, if any match\".\n// Returns the total match count (before the -n cap) \u{2014} Ext 6\'s `swarm\n// --dispatch` verdict needs to say \"0 matches\" precisely, not just print\n// nothing and leave the caller to guess whether that means zero matches or\n// a resolution/read failure upstream.\nexport function printLastLines(file, n, opt, whole = false) {\n return printLastLinesMulti([file], n, opt, whole);\n}\n\n// Same, over several files read oldest-first as one stream \u{2014} a window that\n// spans a daily rotation (see windowFiles below). `--agent`\'s matcher is\n// built here, from the very lines about to be printed, so every recipe that\n// prints through this gets it without its own wiring.\nexport function printLastLinesMulti(files, n, opt, whole = false) {\n const lines = [];\n for (const f of files) for (const l of readForDisplay(f, whole || files.length > 1).split(\"\\n\")) lines.push(l);\n ensureAgentMatcher(lines, opt);\n const rendered = [];\n for (const l of lines) {\n const r = renderLine(l, opt); if (r != null) rendered.push(r);\n }\n const out = n > 0 ? rendered.slice(-n) : rendered;\n for (const r of out) process.stdout.write(r + \"\\n\");\n return rendered.length;\n}\n\n// \u{2500}\u{2500}\u{2500} rotated files \u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\n// Sidecar and host logs roll daily by UTC date: `<stem>.log.<YYYY-MM-DD>`.\n// Resolution picks ONE file (the newest), so an incident just before UTC\n// midnight used to be invisible even with --since. With --since/--until, a\n// resolved file is widened to every sibling of the same stem whose date can\n// overlap the window (SPEC_MUXLOG_AGENT_ADMISSION_TIMELINE_2026_09_27.md \u{a7}3.1).\n// Without either, it stays exactly the one resolved file.\n\n// `agentmuxsrv-v0.57.8.log.2026-09-26` \u{2192} { stem: \"agentmuxsrv-v0.57.8\", date: \"2026-09-26\" };\n// an un-suffixed `\u{2026}.log` has date null (the file being written right now).\nexport function logNameParts(name) {\n const m = name.match(/^(.*)\\.log(?:\\.(\\d{4}-\\d{2}-\\d{2}))?$/);\n return m ? { stem: m[1], date: m[2] ?? null } : null;\n}\n\n// Pure: of these file names (one stem), the ones a [since, until] window can\n// touch, oldest first. A file dated D only holds lines from UTC day D, so it\n// can be skipped exactly when D is outside the window\'s dates; an undated\n// file is always kept.\nexport function selectWindowNames(names, since, until) {\n const sinceDay = since ? since.slice(0, 10) : null;\n const untilDay = until ? until.slice(0, 10) : null;\n return names\n .map((name) => ({ name, parts: logNameParts(name) }))\n .filter(({ parts }) => parts)\n .filter(({ parts }) => parts.date == null || ((!sinceDay || parts.date >= sinceDay) && (!untilDay || parts.date <= untilDay)))\n .sort((a, b) => (a.parts.date ?? \"9999\") < (b.parts.date ?? \"9999\") ? -1 : (a.parts.date ?? \"9999\") > (b.parts.date ?? \"9999\") ? 1 : 0)\n .map(({ name }) => name);\n}\n\n// The files to read for `file`: itself alone, or, with --since/--until, its\n// same-stem siblings in the window.\nexport function windowFiles(file, opt, strict = false) {\n if (!opt.since && !opt.until) return [file];\n const dir = path.dirname(file);\n const parts = logNameParts(path.basename(file));\n if (!parts) return [file];\n let names = [];\n try { names = fs.readdirSync(dir).filter((n) => logNameParts(n)?.stem === parts.stem); } catch { return [file]; }\n const picked = selectWindowNames(names, opt.since, opt.until).map((n) => path.join(dir, n));\n // Single-instance use keeps the resolved file when nothing is dated in the\n // window (unchanged behavior); a multi-instance merge wants nothing then.\n return picked.length ? picked : strict ? [] : [file];\n}\n\n// \u{2500}\u{2500}\u{2500} --agent \u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\n// The agent shows up as `fields.agent`, `fields.agent_id`, `fields.uid` or\n// `fields.definition_id`, in whatever case its call site used (\"AgentA\" in\n// some lines, \"agenta\" in others), and some of the most important lines \u{2014}\n// `persistent process spawned`, `turn_active flip (process exited)` \u{2014} carry\n// only its pane\'s `block_id`. So: given a name or a uid, collect every id\n// that co-occurs with it on a line (name \u{2194} uid), then the block ids of those\n// lines, and match a line on any of them. Two passes so a name found only via\n// a uid (or the reverse) is picked up too.\nconst ID_FIELDS = [\"agent\", \"agent_id\", \"uid\", \"definition_id\"];\nconst UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;\n\nexport function expandAgentIds(lines, agent) {\n const ids = new Set([String(agent).toLowerCase()]);\n const blocks = new Set();\n const parsed = [];\n for (const l of lines) {\n const t = typeof l === \"string\" ? l.trim() : \"\";\n if (!t || t[0] !== \"{\") continue;\n try { parsed.push(JSON.parse(t).fields || {}); } catch { /* not JSON */ }\n }\n for (let pass = 0; pass < 2; pass++) {\n for (const f of parsed) {\n const vals = ID_FIELDS.map((k) => f[k]).filter((v) => typeof v === \"string\" && v);\n if (!vals.some((v) => ids.has(v.toLowerCase()))) continue;\n for (const v of vals) ids.add(v.toLowerCase());\n if (typeof f.block_id === \"string\" && f.block_id) blocks.add(f.block_id.toLowerCase());\n }\n }\n return { ids, blocks };\n}\n\nexport function makeAgentMatcher({ ids, blocks }) {\n const names = [...ids].filter((x) => !UUID_RE.test(x));\n const uuids = [...ids].filter((x) => UUID_RE.test(x));\n const esc = (s) => s.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\");\n const nameRe = names.length ? new RegExp(`\\\\b(?:${names.map(esc).join(\"|\")})\\\\b`, \"i\") : null;\n return (j) => {\n const f = j.fields || {};\n for (const k of ID_FIELDS) {\n const v = f[k];\n if (typeof v === \"string\" && ids.has(v.toLowerCase())) return true;\n }\n if (typeof f.block_id === \"string\" && blocks.has(f.block_id.toLowerCase())) return true;\n const text = `${f.message ?? j.message ?? \"\"} ${f.why ?? \"\"}`;\n if (nameRe && nameRe.test(text)) return true;\n const lower = text.toLowerCase();\n return uuids.some((u) => lower.includes(u));\n };\n}\n\nfunction ensureAgentMatcher(lines, opt) {\n if (opt.agent && !opt.agentMatch) opt.agentMatch = makeAgentMatcher(expandAgentIds(lines, opt.agent));\n}\n\n// \u{2500}\u{2500}\u{2500} --instances \u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\n// Take-over involves a REQUESTER and a HOLDER in different channels, and their\n// lines only make sense interleaved (\u{a7}3.2). One group per instance: a log\n// directory plus a file stem (the shared root holds several versions\' logs\n// side by side, each its own instance).\n\n// `v0.57.6/7a8245ae` \u{2014} version, then the channel hash (last 8) or dev branch.\nexport function instanceTag(e) {\n const ident = e.source.replace(/^[a-z]+:/i, \"\");\n const short = e.source.startsWith(\"channel:\") ? ident.slice(-8) : e.source === \"shared\" ? \"shared\" : ident.slice(0, 20);\n return `v${e.version}/${short}`;\n}\n\n// Pure: group discovered entries into instances, filtered by `spec`\n// (\"all\", \"live\", or comma-separated substrings as for -i). Liveness is\n// supplied by the caller (it\'s an async probe).\nexport function groupInstances(cands, spec, liveByDir = {}) {\n const groups = new Map();\n for (const e of cands) {\n const parts = logNameParts(path.basename(e.file));\n const key = path.dirname(e.file) + \"|\" + (parts?.stem ?? path.basename(e.file));\n if (!groups.has(key)) groups.set(key, { key, dir: path.dirname(e.file), source: e.source, version: e.version, tag: instanceTag(e), files: [], newest: e });\n const g = groups.get(key);\n g.files.push(e.file);\n if (newestLogFirst(e, g.newest) < 0) g.newest = e;\n }\n let out = [...groups.values()];\n if (spec === \"live\") out = out.filter((g) => liveByDir[g.dir] !== \"dead\");\n else if (spec && spec !== \"all\") {\n const needles = spec.split(\",\").map((s) => s.trim()).filter(Boolean);\n out = out.filter((g) => needles.some((n) => filterByInstance([{ file: g.newest.file, source: g.source, version: g.version }], n).length));\n }\n return out.sort((a, b) => b.newest.mtime - a.newest.mtime);\n}\n\n// The files one instance contributes: its window\'s files with --since/--until,\n// else just its newest.\nfunction groupFiles(g, opt) {\n return opt.since || opt.until ? windowFiles(g.newest.file, opt, true) : [g.newest.file];\n}\n\n// Pure: parse + filter + tag one instance\'s raw lines into timeline entries.\nexport function timelineEntries(lines, tag, opt) {\n const out = [];\n for (const l of lines) {\n const r = renderLine(l, opt);\n if (r == null) continue;\n let j = null;\n try { j = JSON.parse(l.trim()); } catch { /* non-JSON passthrough */ }\n out.push({ ts: j?.timestamp ?? \"\", tag, rendered: r, raw: l.trim(), j });\n }\n return out;\n}\n\n// Stable chronological merge \u{2014} equal timestamps keep instance order.\nexport function mergeTimelines(lists) {\n const all = [];\n lists.forEach((list, li) => list.forEach((e, i) => all.push({ e, li, i })));\n all.sort((a, b) => (a.e.ts < b.e.ts ? -1 : a.e.ts > b.e.ts ? 1 : a.li - b.li || a.i - b.i));\n return all.map((x) => x.e);\n}\n\n// `09-26 23:52:19 v0.57.6/7a8245ae INFO \u{2026}` \u{2014} a merged timeline can span days,\n// so it shows the date too, then the instance tag, then the usual line.\nexport function renderTimelineEntry(e, opt) {\n if (opt.raw) return `${e.tag}\\t${e.raw}`;\n if (!e.j) return `${DIM}${e.tag}${RESET} ${e.rendered}`;\n const m = String(e.ts).match(/^\\d{4}-(\\d{2}-\\d{2})T(\\d{2}:\\d{2}:\\d{2})/);\n const when = m ? `${m[1]} ${m[2]}` : \"--:--:--\";\n const rest = e.rendered.slice(e.rendered.indexOf(RESET) + RESET.length).replace(/^ /, \"\");\n return `${DIM}${when}${RESET} ${DIM}${e.tag.padEnd(18)}${RESET} ${rest}`;\n}\n\nasync function resolveInstanceGroups(target, opt) {\n const cands = discover(target);\n let live = {};\n if (opt.instances === \"live\") {\n const dirs = [...new Set(cands.map((c) => path.dirname(c.file)))];\n const res = await Promise.all(dirs.map((d) => checkLiveness(d)));\n live = Object.fromEntries(dirs.map((d, i) => [d, res[i]]));\n }\n const groups = groupInstances(cands, opt.instances, live);\n if (!groups.length) {\n console.error(`muxlog: no ${target} logs found for --instances ${opt.instances}. Try \\`muxlog ls\\`.`);\n process.exit(1);\n }\n return groups;\n}\n\n// Read every group\'s window, build the --agent matcher from ALL of it (the\n// name \u{2194} uid pairing may only appear in one instance\'s lines), then merge.\nfunction mergedTimeline(groups, opt) {\n const perGroup = groups.filter((g) => groupFiles(g, opt).length).map((g) => {\n const files = groupFiles(g, opt);\n const lines = [];\n for (const f of files) for (const l of readForDisplay(f, true).split(\"\\n\")) lines.push(l);\n return { g, files, lines };\n });\n ensureAgentMatcher(perGroup.flatMap((p) => p.lines), opt);\n const lists = perGroup.map((p) => timelineEntries(p.lines, p.g.tag, opt));\n return { perGroup, merged: mergeTimelines(lists) };\n}\n\nfunction printMerged(groups, opt, title) {\n const { perGroup, merged } = mergedTimeline(groups, opt);\n console.log(`=== ${title} ===`);\n // Only the instances that contributed lines \u{2014} a machine keeps many old\n // dev/portable logs, and listing every one buries the two that matter.\n const contributed = new Set(merged.map((e) => e.tag));\n const shown = perGroup.filter((p) => contributed.has(p.g.tag));\n for (const p of shown) console.log(` ${p.g.tag.padEnd(18)} ${p.files.map((f) => path.basename(f)).join(\", \")} (${p.g.dir})`);\n const quiet = perGroup.length - shown.length;\n if (quiet) console.log(` (${quiet} other instance${quiet === 1 ? \"\" : \"s\"} read, no matching lines)`);\n console.log(\"\");\n const out = opt.n > 0 ? merged.slice(-opt.n) : merged;\n for (const e of out) console.log(renderTimelineEntry(e, opt));\n return { perGroup, merged };\n}\n\nfunction follow(file, opt) {\n let size; try { size = fs.statSync(file).size; } catch { size = 0; }\n setInterval(() => {\n let st; try { st = fs.statSync(file); } catch { return; }\n if (st.size < size) size = 0; // truncated/rotated\n if (st.size === size) return;\n const fd = fs.openSync(file, \"r\");\n const buf = Buffer.alloc(st.size - size);\n fs.readSync(fd, buf, 0, buf.length, size);\n fs.closeSync(fd);\n size = st.size;\n for (const l of buf.toString(\"utf8\").split(\"\\n\")) {\n const r = renderLine(l, opt); if (r != null) process.stdout.write(r + \"\\n\");\n }\n }, 400);\n}\n\n// \u{2500}\u{2500}\u{2500} liveness \u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\n// A log\'s mtime says \"something was written recently,\" not \"this instance is\n// running right now\" \u{2014} the two look identical for a process that just died.\n// agentmux-cef already writes a real, checkable liveness signal per instance:\n// `<port-file-dir>/ipc-port-<hash>` (crates/cef/src/lib.rs), containing\n// `port:token` for the host\'s IPC HTTP server. We don\'t replicate the exact\n// hash to compute the filename \u{2014} glob for any `ipc-port*` file instead,\n// cheaper and forward-compatible if the hash scheme ever changes.\n//\n// muxreview P1 on PR #2752: `port_file_dir` is NOT always the `data` sibling.\n// crates/cef/src/lib.rs writes it to `p.cef_cache_dir` for `task dev`\n// instances (`is_dev_build_exe` branch) and to `AGENTMUX_DATA_DIR` (==\n// `DataPaths.data_dir`, the `data` sibling) for portable/installed builds.\n// `logs`/`data`/`cef-cache` are always siblings under the same version dir\n// (crates/common/src/data_paths.rs) regardless of build type, so check\n// both siblings rather than trying to infer dev-vs-portable from the path.\n//\n// Exported (pure logic split from the fs/net I/O) for muxlog.test.mjs.\nexport function siblingCandidateDirs(logDir) {\n if (path.basename(logDir) !== \"logs\") return [];\n const parent = path.dirname(logDir);\n return [path.join(parent, \"data\"), path.join(parent, \"cef-cache\")];\n}\n\n// muxreview P2 on PR #2742: a raw TCP connect only proves SOMETHING is\n// listening, not that it\'s AgentMux \u{2014} if the OS reassigns a dead instance\'s\n// ephemeral port to an unrelated local service before this probe runs, a\n// bare connect would false-positive \"live\". The host\'s IPC server\n// (crates/cef/src/ipc.rs) exposes a genuine unauthenticated\n// `GET /health` on this exact port returning `{\"status\":\"ok\",\"version\":...}`\n// \u{2014} verify that shape specifically, not just a successful connection.\nasync function probePort(port, timeoutMs = 300) {\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), timeoutMs);\n try {\n const resp = await fetch(`http://127.0.0.1:${port}/health`, { signal: controller.signal });\n if (!resp.ok) return false;\n const body = await resp.json().catch(() => null);\n return body?.status === \"ok\";\n } catch {\n return false;\n } finally {\n clearTimeout(timer);\n }\n}\n\n// \"live\" | \"dead\" | \"?\" \u{2014} \"?\" means genuinely unknown (no sibling data/cef-cache dir,\n// no port file, or unreadable/malformed contents), never a liveness verdict\n// of its own. Async \u{2014} the only I/O-bound piece of muxlog; every caller\n// awaits a Promise.all of these so probes for different instances run\n// concurrently instead of serially piling up 300ms timeouts.\nexport async function checkLiveness(logDir) {\n const candidateDirs = siblingCandidateDirs(logDir);\n if (candidateDirs.length === 0) return \"?\";\n // muxreview P1 on PR #2742: a dev-mode data dir is keyed by BRANCH, not\n // version, and a crashed (non-graceful-exit) process\'s port file is\n // never cleaned up (crates/cef/src/lib.rs writes it once at startup;\n // nothing removes it on a crash, only on the graceful-shutdown path).\n // So a stale port file from a PRIOR crashed run and a live one from the\n // CURRENT run can coexist in the same dir \u{2014} taking only the first\n // readdirSync result (unspecified ordering) could probe the dead one\n // and report \"dead\" for a genuinely live instance. Probe every\n // candidate concurrently instead; \"live\" if ANY of them answers.\n // muxreview P2 on PR #2752: crates/cef/src/lib.rs writes the bare\n // filename \"ipc-port\" (no trailing hyphen) when AGENTMUX_IPC_HASH is\n // unset (the task dev:standalone no-launcher path) \u{2014} startsWith\n // \"ipc-port-\" alone misses that exact literal.\n const ports = [];\n for (const dir of candidateDirs) {\n let entries;\n try { entries = fs.readdirSync(dir); } catch { continue; }\n for (const name of entries) {\n if (name !== \"ipc-port\" && !name.startsWith(\"ipc-port-\")) continue;\n let contents;\n try { contents = fs.readFileSync(path.join(dir, name), \"utf8\"); } catch { continue; }\n const port = parseInt(contents.trim().split(\":\")[0], 10);\n if (Number.isFinite(port) && port > 0 && port <= 65535) ports.push(port);\n }\n }\n if (ports.length === 0) return \"?\";\n const results = await Promise.all(ports.map((p) => probePort(p)));\n return results.some(Boolean) ? \"live\" : \"dead\";\n}\n\n// \u{2500}\u{2500}\u{2500} ls \u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\nasync function listInstances() {\n const seen = new Set();\n const rows = [];\n for (const e of discover(\"host\").concat(discover(\"srv\"), discover(\"launcher\"))) {\n if (seen.has(e.file)) continue; seen.add(e.file);\n rows.push(e);\n }\n rows.sort((a, b) => b.mtime - a.mtime);\n if (!rows.length) { console.log(\"No AgentMux logs found under ~/.agentmux.\"); return; }\n const live = await Promise.all(rows.map((e) => checkLiveness(path.dirname(e.file))));\n console.log(`${\"TARGET\".padEnd(9)}${\"VERSION\".padEnd(9)}${\"SOURCE\".padEnd(22)}${\"LIVE\".padEnd(6)}${\"AGE\".padEnd(8)}${\"SIZE\".padEnd(8)}PATH`);\n rows.forEach((e, i) => {\n console.log(\n e.target.padEnd(9) + e.version.padEnd(9) + e.source.slice(0, 21).padEnd(22) +\n live[i].padEnd(6) + age(e.mtime).padEnd(8) + human(e.size).padEnd(8) + e.file,\n );\n });\n if (live.every((v) => v === \"?\")) {\n console.log(`\\nLIVE column is \'?\' for everything \u{2014} this host has no findable ipc-port-* files (older builds predating this feature, or none of these are agentmux-cef host instances).`);\n }\n}\nfunction age(ms) {\n const s = Math.floor((Date.now() - ms) / 1000);\n if (s < 90) return s + \"s\";\n if (s < 5400) return Math.floor(s / 60) + \"m\";\n if (s < 129600) return Math.floor(s / 3600) + \"h\";\n return Math.floor(s / 86400) + \"d\";\n}\nfunction human(b) { return b < 1024 ? b + \"B\" : b < 1048576 ? (b / 1024).toFixed(0) + \"K\" : (b / 1048576).toFixed(1) + \"M\"; }\n\n// \u{2500}\u{2500}\u{2500} mem / doctor \u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\n// The 2026-06-16 OOM crash was driven by SYSTEM COMMIT exhaustion across several\n// concurrent AgentMux instances (+ dev tooling), not physical RAM. This surfaces\n// commit-free, the derived pressure level, and the live AgentMux footprint so\n// the multi-instance cause is visible BEFORE the cliff.\n// See SPEC_MEMORY_PRESSURE_SUPERVISION_2026_06_16 \u{a7}5.G + Discussion #943.\n\n// Keep in lockstep with the host thresholds (crates/cef/src/memory_pressure.rs).\nconst WARN_FLOOR_MB = 1024;\nconst CRITICAL_FLOOR_MB = 512;\n\nfunction pressureLevel(freeMb) {\n if (freeMb < CRITICAL_FLOOR_MB) return \"critical\";\n if (freeMb < WARN_FLOOR_MB) return \"warn\";\n return \"normal\";\n}\n\n// Best-effort, never throws \u{2014} a missing tool / odd platform degrades gracefully.\nfunction run(file, args) {\n try {\n return execFileSync(file, args, { encoding: \"utf8\", stdio: [\"ignore\", \"pipe\", \"ignore\"], timeout: 8000 });\n } catch { return \"\"; }\n}\n\n// System COMMIT (page file + RAM) in MB \u{2014} the OOM-relevant ceiling, NOT physical\n// RAM. Returns { freeMb, totalMb } or null when the platform has no cheap figure.\nfunction systemCommit() {\n const plat = os.platform();\n if (plat === \"win32\") {\n // Win32_OperatingSystem reports Free/Total VirtualMemory in KB = commit.\n const out = run(\"powershell\", [\n \"-NoProfile\", \"-Command\",\n \"$o=Get-CimInstance Win32_OperatingSystem; $o.FreeVirtualMemory; $o.TotalVirtualMemorySize\",\n ]);\n const n = out.trim().split(/\\s+/).map(Number).filter((x) => Number.isFinite(x));\n if (n.length >= 2) return { freeMb: Math.round(n[0] / 1024), totalMb: Math.round(n[1] / 1024) };\n return null;\n }\n if (plat === \"linux\") {\n try {\n const info = fs.readFileSync(\"/proc/meminfo\", \"utf8\");\n const kb = (k) => { const m = info.match(new RegExp(\"^\" + k + \":\\\\s+(\\\\d+)\", \"m\")); return m ? +m[1] : null; };\n const limit = kb(\"CommitLimit\"), committed = kb(\"Committed_AS\");\n if (limit != null && committed != null) {\n return { freeMb: Math.round((limit - committed) / 1024), totalMb: Math.round(limit / 1024) };\n }\n } catch { /* fall through */ }\n return null;\n }\n return null; // macOS / other: no cheap commit figure\n}\n\n// Live AgentMux processes: [{ name, pid, mb }] sorted by memory desc.\nfunction agentmuxProcesses() {\n const procs = [];\n if (os.platform() === \"win32\") {\n // CSV cols: \"Image\",\"PID\",\"Session\",\"Session#\",\"Mem Usage\"\n for (const line of run(\"tasklist\", [\"/FO\", \"CSV\", \"/NH\"]).split(/\\r?\\n/)) {\n const c = line.split(\'\",\"\').map((s) => s.replace(/^\"|\"$/g, \"\"));\n if (c.length < 5 || !/agentmux/i.test(c[0])) continue;\n const kb = parseInt(c[4].replace(/[^\\d]/g, \"\"), 10) || 0; // \"12,345 K\"\n procs.push({ name: c[0], pid: c[1], mb: Math.round(kb / 1024) });\n }\n } else {\n for (const line of run(\"ps\", [\"-eo\", \"comm,pid,rss\"]).split(/\\r?\\n/).slice(1)) {\n const m = line.trim().match(/^(\\S+)\\s+(\\d+)\\s+(\\d+)$/);\n if (!m || !/agentmux/i.test(m[1])) continue;\n procs.push({ name: m[1], pid: m[2], mb: Math.round(+m[3] / 1024) });\n }\n }\n return procs.sort((a, b) => b.mb - a.mb);\n}\n\nfunction memDoctor() {\n console.log(\"AgentMux memory doctor\\n\");\n\n const commit = systemCommit();\n if (commit) {\n const usedPct = (((commit.totalMb - commit.freeMb) / commit.totalMb) * 100).toFixed(1);\n const lvl = pressureLevel(commit.freeMb);\n const badge = lvl === \"critical\" ? \"CRITICAL\" : lvl === \"warn\" ? \"WARN\" : \"ok\";\n console.log(` system commit : ${commit.freeMb} MB free / ${commit.totalMb} MB (${usedPct}% used) \u{2192} ${badge}`);\n if (lvl === \"critical\") console.log(\" an OOM is imminent \u{2014} close some windows or other apps NOW.\");\n else if (lvl === \"warn\") console.log(\" getting tight \u{2014} closing a window or another app will help.\");\n } else {\n console.log(\" system commit : (unavailable on this platform \u{2014} commit-limit exhaustion is a Windows/Linux concern)\");\n }\n\n const procs = agentmuxProcesses();\n // `agentmux-srv` is the backend sidecar \u{2014} ~one per instance (a crash-monitor\n // child may add one more), so it\'s a far more robust instance proxy than the\n // launcher exe, which gets renamed per portable build.\n const backends = procs.filter((p) => /srv/i.test(p.name)).length;\n const totalMb = procs.reduce((s, p) => s + p.mb, 0);\n console.log(`\\n agentmux procs: ${procs.length} (\u{2248}${backends} backend${backends === 1 ? \"\" : \"s\"}), ${totalMb} MB total working set`);\n for (const p of procs.slice(0, 24)) {\n console.log(` ${String(p.pid).padStart(6)} ${String(p.mb).padStart(6)} MB ${p.name}`);\n }\n if (procs.length > 24) console.log(` \u{2026} and ${procs.length - 24} more`);\n\n if (commit && pressureLevel(commit.freeMb) !== \"normal\") {\n console.log(`\\n AgentMux is using ${totalMb} MB of commit \u{2014} closing a window or another running AgentMux is the most direct way to free it.`);\n }\n}\n\n// \u{2500}\u{2500}\u{2500} CLI \u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\nfunction parse(argv) {\n const opt = { n: 200, all: false, raw: false, verbose: false };\n const pos = [];\n for (let i = 0; i < argv.length; i++) {\n const a = argv[i];\n if (a === \"-a\" || a === \"--all\") opt.all = true;\n else if (a === \"--raw\") opt.raw = true;\n else if (a === \"-v\" || a === \"--verbose\") opt.verbose = true;\n else if (a === \"-n\") opt.n = parseInt(argv[++i], 10) || 200;\n else if (a === \"-i\" || a === \"--instance\") opt.instance = argv[++i];\n else if (a === \"-d\" || a === \"--dispatch\") opt.dispatch = argv[++i];\n else if (a === \"--level\") { const v = argv[++i]; if (v) opt.level = v.toLowerCase().split(\",\"); }\n else if (a === \"--target\") opt.target = argv[++i];\n else if (a === \"--exclude-target\") opt.excludeTarget = argv[++i];\n else if (a === \"--since\") opt.since = argv[++i];\n else if (a === \"--until\") opt.until = argv[++i];\n else if (a === \"--agent\") opt.agent = argv[++i];\n else if (a === \"--instances\") opt.instances = argv[++i] || \"all\";\n else if (a === \"--grep\") opt.grep = new RegExp(argv[++i], \"i\");\n else pos.push(a);\n }\n return { opt, pos };\n}\n\n// Case-insensitive across file path, source label, and version \u{2014} the\n// original inline filter only lowercased `.file`, so an `-i` matching only\n// via `.source`/`.version` (e.g. `-i Dev:Main`) silently missed real\n// candidates. Exported for muxlog.test.mjs.\nexport function filterByInstance(cands, needle) {\n const n = needle.toLowerCase();\n return cands.filter(\n (e) => e.file.toLowerCase().includes(n) || e.source.toLowerCase().includes(n) || e.version.toLowerCase().includes(n),\n );\n}\n\n// $AGENTMUX_CHANNEL\'s dev-mode format is `dev-<branch>[-<clone_id>]`\n// (hyphen-joined \u{2014} agentmux-common\'s resolve_channel_and_dir) but never\n// appears as a literal substring of a dev candidate\'s `.source`\n// (`\"dev:\" + branch`, colon) or `.file` path (`dev/<branch>/...`,\n// slash-separated) \u{2014} same words, different separators, so\n// filterByInstance()\'s plain substring check never matches (reagent P1 on\n// PR #2741: the own-channel default in pickCandidate silently never fired\n// for `task dev`).\n//\n// A first attempt fixed that by stripping ALL separators and doing a plain\n// substring check on the flattened result \u{2014} reagent P1 round 2 caught the\n// real bug in that: flattening loses word boundaries, so needle\n// \"dev-phase-3\" (normalized \"devphase3\") is a substring-PREFIX of an\n// unrelated sibling\'s \"dev-phase-3-repro\" (normalized \"devphase3repro\"),\n// false-positive matching a genuinely different instance whose branch name\n// happens to start with the same characters. No amount of delimiter-padding\n// fixes this on its own, since needle being a true PREFIX of haystack\'s\n// token sequence still satisfies a boundary-aligned check trivially \u{2014} the\n// fix has to require the needle match a COMPLETE identifying segment (or\n// exact concatenation of segments), never a partial/prefix one.\n//\n// So: normalize each candidate field into discrete tokens split on real\n// structural boundaries (`/`/`\\` for the file path, `:` for the source\n// label\'s own prefix), and require EXACT equality against one of those\n// tokens \u{2014} or, for the file path\'s `dev/<branch>/<clone_id>/logs/...`\n// shape specifically, exact equality against two CONSECUTIVE tokens joined\n// (branch + clone_id) \u{2014} never a substring/prefix match against a flattened\n// blob. `-i` (filterByInstance) is untouched by any of this \u{2014} a user\n// typing `-i fix-shell` still gets plain, literal substring matching, same\n// as always.\nfunction normalizeToken(s) {\n return s.toLowerCase().replace(/[^a-z0-9]+/g, \"-\").replace(/^-+|-+$/g, \"\");\n}\n\nexport function matchesOwnChannel(cand, ownChannel) {\n const needle = normalizeToken(ownChannel);\n if (!needle) return false;\n\n // Source label: \"dev:<branch>\" / \"channel:<name>\" / \"shared\" \u{2014} strip a\n // known \"<word>:\" prefix if present, exact-match what\'s left (with and\n // without a \"dev-\" prepended, since the channel string carries that\n // prefix but the source label\'s own identifying part doesn\'t).\n const sourceIdent = normalizeToken(cand.source.replace(/^[a-z]+:/i, \"\"));\n if (sourceIdent && (needle === sourceIdent || needle === `dev-${sourceIdent}`)) return true;\n\n // File path: split on real path separators only (never on \'-\' \u{2014} a\n // branch/channel name\'s internal hyphens are part of ONE segment, not\n // segment boundaries). Check each segment alone, and each consecutive\n // pair joined (the dev-mode branch+clone_id shape), always as exact\n // equality, never substring.\n const segments = cand.file.split(/[\\\\/]/).map(normalizeToken).filter(Boolean);\n for (let i = 0; i < segments.length; i++) {\n const seg = segments[i];\n if (needle === seg || needle === `dev-${seg}`) return true;\n if (i + 1 < segments.length) {\n const pair = `${seg}-${segments[i + 1]}`;\n if (needle === pair || needle === `dev-${pair}`) return true;\n }\n }\n return false;\n}\n\n// Resolving \"the\" srv/host log used to mean \"freshest across every instance\n// on the machine\" \u{2014} with several instances at the same version routinely\n// running at once (dev branches, portables, channels), that\'s frequently the\n// WRONG instance, and nothing said so (see\n// docs/reports/REPORT_MUXSPECT_MUXLOG_CROSS_CHANNEL_INSPECTION_2026_08_22.md\n// \u{a7}2.1: `swarm` resolved to a stale sibling version\'s log on the very first\n// live repro of this). An explicit `-i` always wins, same as before. Absent\n// that, prefer OUR OWN running instance \u{2014} $AGENTMUX_CHANNEL is already in\n// every agent pane\'s environment (same source resolveBlockId below already\n// trusts for $AGENTMUX_BLOCKID), so a caller running from inside an agent\n// pane gets ITS OWN instance\'s log by default instead of a same-version\n// sibling\'s. This is a soft preference, not a hard requirement: falls\n// through to the old \"freshest overall\" behavior when nothing matches\n// (`launcher` has no per-channel log at all; an older srv build predating\n// Ext 1\'s AGENTMUX_LOG_DIR fix still only writes to the shared root) rather\n// than failing outright \u{2014} degrading gracefully beats refusing to run.\n//\n// Pure (no I/O, no process.exit) \u{2014} takes already-discovered candidates so\n// muxlog.test.mjs can exercise the actual selection logic without touching\n// the real filesystem/HOME. Returns `null` when nothing matches at all;\n// `resolveFile` (below) owns turning that into the CLI\'s error+exit.\nexport function pickCandidate(cands, opt, ownChannel) {\n if (opt.instance) {\n const filtered = filterByInstance(cands, opt.instance);\n return filtered[0]?.file ?? null;\n }\n if (ownChannel) {\n const own = cands.filter((c) => matchesOwnChannel(c, ownChannel));\n if (own.length) return own[0].file;\n }\n return cands[0]?.file ?? null; // most recently active, no preference matched\n}\n\nfunction resolveFile(target, opt) {\n const cands = discover(target);\n const file = pickCandidate(cands, opt, process.env.AGENTMUX_CHANNEL);\n if (!file) {\n console.error(\n `muxlog: no ${target} log found${opt.instance ? ` matching \'${opt.instance}\'` : \"\"}. Try \\`muxlog ls\\`.`,\n );\n process.exit(1);\n }\n return file;\n}\n\n// \u{2500}\u{2500}\u{2500} admission \u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\n// \"Why won\'t this agent run here\": one cross-instance timeline of the agent\'s\n// admission, take-over and lifecycle lines, then a per-instance verdict and a\n// diagnosis from a small rule table\n// (SPEC_MUXLOG_AGENT_ADMISSION_TIMELINE_2026_09_27.md \u{a7}3.4).\nexport const ADMISSION_VOCAB =\n /agent_admission\\.|registry: (shared )?entry changed hands|self-quit|persistent process (spawned|exited)|turn_active flip \\(process exited\\)|muxbus: auto-registered|muxbus: .*(lease|subscription|RemoveAgent)|provisioned per-agent credential/i;\nexport const ADMISSION_FIELDS = [\"holder\", \"holder_channel\", \"released\", \"epoch\", \"block_id\", \"why\", \"to\"];\n\nconst msgOf = (j) => String(j?.fields?.message ?? j?.message ?? \"\");\n\n// \"this computer, channel local-main-b28b7a-7a8245ae, v0.57.6\" \u{2192} \"local-main-b28b7a-7a8245ae\"\nexport function holderChannel(holder) {\n const m = String(holder ?? \"\").match(/channel\\s+([^,\\s]+)/i);\n return m ? m[1] : null;\n}\n\n// Pure: one instance\'s entries (already filtered to the agent) \u{2192} its state.\nexport function summarizeInstance(entries) {\n const s = { state: null, stateTs: null, epoch: null, holder: null, lastSpawn: null, lastStop: null, released0At: null, takeoverRequestedAt: null };\n for (const e of entries) {\n const m = msgOf(e.j);\n const f = e.j?.fields || {};\n if (/^agent_admission\\.granted/.test(m)) { s.state = \"granted\"; s.stateTs = e.ts; s.epoch = f.epoch ?? null; }\n else if (/^agent_admission\\.fenced/.test(m)) { s.state = \"fenced\"; s.stateTs = e.ts; if (f.holder) s.holder = f.holder; s.lastStop = e.ts; }\n else if (/^agent_admission\\.denied/.test(m)) { s.state = \"denied\"; s.stateTs = e.ts; if (f.holder) s.holder = f.holder; }\n if (/takeover: requesting release/.test(m)) s.takeoverRequestedAt = e.ts;\n if (/takeover: release handled/.test(m) && Number(f.released) === 0) s.released0At = e.ts;\n if (/persistent process spawned/.test(m)) s.lastSpawn = e.ts;\n if (/self-quit|persistent process exited|turn_active flip \\(process exited\\)/.test(m)) s.lastStop = e.ts;\n }\n // Does this instance have a live pane for the agent at the end of the window?\n // true / false, or null when the window holds no lifecycle lines at all.\n s.live = s.lastSpawn ? !s.lastStop || s.lastSpawn > s.lastStop : s.lastStop ? false : null;\n return s;\n}\n\n// Pure: [{ tag, source, file, entries }] \u{2192} { summaries, diagnoses }.\n// Each diagnosis rule cites the PRs that fixed the failure mode it names.\nexport function admissionVerdict(instances) {\n const summaries = instances.map((i) => ({ ...i, s: summarizeInstance(i.entries) }));\n const diagnoses = [];\n for (const r of summaries) {\n if (r.s.state !== \"fenced\" && r.s.state !== \"denied\") continue;\n const chan = holderChannel(r.s.holder);\n if (!chan) continue;\n const h = summaries.find((x) => x !== r && matchesOwnChannel({ source: x.source, file: x.file }, chan));\n if (!h) {\n diagnoses.push(\n `${r.tag}: the relay names \"${r.s.holder}\" as the holder, and no local log belongs to that instance. ` +\n `If it\'s on another computer, run \\`muxlog admission\\` there.`,\n );\n continue;\n }\n if (h.s.live === false) {\n const since = h.s.lastStop ? ` since ${h.s.lastStop}` : \"\";\n const released = h.s.released0At ? `, and a take-over there answered released: 0 (${h.s.released0At})` : \"\";\n diagnoses.push(\n `stale relay lease: the relay names ${h.tag} as holder, but ${h.tag} has had no live pane for the agent${since}${released}. ` +\n `A closed agent\'s cloud subscription kept renewing its lease (#3897), and a take-over couldn\'t release a lease ` +\n `held without a live pane (#3899, #3908). #3897, #3899, #3903 and #3908 are all in v0.58.0. ` +\n `Recover: quit or restart ${h.tag}; the lease expires \u{2264} 15 s after renewals stop, then Take over again ` +\n `(if ${r.tag} predates #3903 it may keep its cached refusal; restart it too).`,\n );\n } else if (h.s.live === true) {\n diagnoses.push(\n `${h.tag} genuinely runs the agent (a live pane since ${h.s.lastSpawn}). ${r.tag} is refused correctly; ` +\n `use Take over, or close the agent in ${h.tag}.`,\n );\n } else {\n diagnoses.push(\n `the relay names ${h.tag} as holder, but its logs in this window have no lifecycle lines for the agent. ` +\n `Widen --since to see when it last started or stopped it.`,\n );\n }\n }\n return { summaries, diagnoses };\n}\n\nfunction describeState(s) {\n if (!s.state) return \"no admission lines\";\n const at = s.stateTs ? ` at ${s.stateTs}` : \"\";\n if (s.state === \"granted\") return `granted${s.epoch != null ? `@epoch ${s.epoch}` : \"\"}${at}`;\n return `${s.state}${at}${s.holder ? ` (relay names: ${s.holder})` : \"\"}`;\n}\n\nasync function admissionRecipe(pos, opt) {\n opt.agent = opt.agent || pos[1] || process.env.AGENTMUX_AGENT_ID;\n if (!opt.agent) {\n console.error(\"muxlog admission: which agent? Pass a name or uid (`muxlog admission AgentA`), or run it from an agent\'s own shell.\");\n process.exit(1);\n }\n opt.instances = opt.instances || \"all\";\n if (!opt.since) opt.since = new Date(Date.now() - 24 * 3600 * 1000).toISOString().slice(0, 16);\n opt.grep = opt.grep || ADMISSION_VOCAB;\n opt.inlineFields = ADMISSION_FIELDS;\n opt.all = true; // the agent\'s own lifecycle lines can sit under the subprocess targets renderLine drops by default\n const groups = await resolveInstanceGroups(\"srv\", opt);\n const { perGroup, merged } = printMerged(groups, opt, `admission: ${opt.agent} since ${opt.since}`);\n if (!merged.length) {\n console.log(`\\nverdict: no admission/lifecycle lines for \'${opt.agent}\' in any instance since ${opt.since}. Try a wider --since, or check the name with \\`muxlog srv --agent ${opt.agent} cat\\`.`);\n return;\n }\n const { summaries, diagnoses } = admissionVerdict(\n perGroup.map((p) => ({ tag: p.g.tag, source: p.g.source, file: p.g.newest.file, entries: merged.filter((e) => e.tag === p.g.tag) })),\n );\n console.log(\"\\nverdict:\");\n for (const x of summaries) {\n if (!x.entries.length) continue;\n const live = x.s.live === true ? \"live pane\" : x.s.live === false ? \"no live pane\" : \"no lifecycle lines\";\n console.log(` ${x.tag.padEnd(18)} ${describeState(x.s)}; ${live}`);\n }\n for (const d of diagnoses) console.log(`\\ndiagnosis: ${d}`);\n}\n\n// \u{2500}\u{2500}\u{2500} opens \u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\n// \"How long do agent opens take\": the frontend\'s one `[agent-open]` line per\n// open (frontend/app/view/agent/open-trace.ts), across every instance, then\n// p50/p95 per source (SPEC_AGENT_OPEN_LATENCY_2026_09_27.md \u{a7}4.6). Times are\n// ms since the click (or the pane\'s mount); the clock stops at reveal + quiet,\n// so a login prompt after that is never counted.\nexport const OPEN_PHASE_KEYS = [\"cli\", \"config\", \"committed\", \"history_start\", \"history_read\", \"parsed\", \"painted\", \"revealed\", \"quiet\"];\n\n// Pure: a log message \u{2192} the open\'s fields, or null if it isn\'t one.\nexport function parseAgentOpenLine(message) {\n const at = String(message ?? \"\").indexOf(\"[agent-open]\");\n if (at < 0) return null;\n const out = {};\n const re = /(\\w+)=(\"(?:[^\"\\\\]|\\\\.)*\"|\\S+)/g;\n for (const m of String(message).slice(at).matchAll(re)) {\n let v = m[2];\n if (v.startsWith(\'\"\')) { try { v = JSON.parse(v); } catch { /* keep raw */ } }\n else if (/^-?\\d+(\\.\\d+)?$/.test(v)) v = Number(v);\n out[m[1]] = v;\n }\n return out.agent !== undefined ? out : null;\n}\n\n// Nearest-rank percentile of a numeric list; null for an empty one.\nexport function percentile(values, p) {\n const xs = values.filter((v) => typeof v === \"number\").sort((a, b) => a - b);\n if (!xs.length) return null;\n return xs[Math.min(xs.length - 1, Math.max(0, Math.ceil((p / 100) * xs.length) - 1))];\n}\n\n// Pure: parsed opens \u{2192} one summary per source.\nexport function summarizeOpens(opens) {\n const bySource = new Map();\n for (const o of opens) {\n const k = o.source ?? \"?\";\n if (!bySource.has(k)) bySource.set(k, []);\n bySource.get(k).push(o);\n }\n return [...bySource.entries()].map(([source, list]) => {\n const outcomes = {};\n for (const o of list) outcomes[o.outcome ?? \"?\"] = (outcomes[o.outcome ?? \"?\"] ?? 0) + 1;\n const quiet = list.filter((o) => o.outcome === \"quiet\");\n return {\n source,\n n: list.length,\n outcomes,\n installs: list.filter((o) => o.cli_source === \"installed\").length,\n painted: { p50: percentile(list.map((o) => o.painted), 50), p95: percentile(list.map((o) => o.painted), 95) },\n quiet: { p50: percentile(quiet.map((o) => o.quiet), 50), p95: percentile(quiet.map((o) => o.quiet), 95) },\n };\n });\n}\n\nconst fmtMs = (v) => (typeof v === \"number\" ? `${v}` : \"-\");\n\nasync function opensRecipe(pos, opt) {\n const agent = (opt.agent || pos[1] || \"\").toLowerCase();\n opt.instances = opt.instances || \"all\";\n if (!opt.since) opt.since = new Date(Date.now() - 24 * 3600 * 1000).toISOString().slice(0, 16);\n opt.grep = /\\[agent-open\\]/;\n opt.all = true;\n delete opt.agent; // matched on the line\'s own agent=\"\u{2026}\" below\n const groups = await resolveInstanceGroups(\"host\", opt);\n const { merged } = mergedTimeline(groups, opt);\n const opens = [];\n for (const e of merged) {\n const o = parseAgentOpenLine(msgOf(e.j) || e.raw);\n if (!o) continue;\n if (agent && String(o.agent).toLowerCase() !== agent) continue;\n opens.push({ ...o, ts: e.ts, tag: e.tag });\n }\n const title = `opens${agent ? `: ${agent}` : \"\"} since ${opt.since} (ms since click/mount; stops at reveal + quiet)`;\n console.log(`=== ${title} ===`);\n if (!opens.length) {\n console.log(`\\nno [agent-open] lines${agent ? ` for \'${agent}\'` : \"\"} since ${opt.since}. They need a build with SPEC_AGENT_OPEN_LATENCY \u{a7}4.6; try a wider --since.`);\n return;\n }\n const shown = opt.n > 0 ? opens.slice(-opt.n) : opens;\n console.log(`${\"when\".padEnd(14)} ${\"instance\".padEnd(18)} ${\"agent\".padEnd(16)} ${\"source\".padEnd(9)} ${\"outcome\".padEnd(9)} ${\"cli\".padStart(6)} ${\"cli_source\".padEnd(13)} ${\"painted\".padStart(7)} ${\"revealed\".padStart(8)} ${\"quiet\".padStart(6)} ${\"total\".padStart(6)} auth`);\n for (const o of shown) {\n const m = String(o.ts).match(/^\\d{4}-(\\d{2}-\\d{2})T(\\d{2}:\\d{2}:\\d{2})/);\n const when = m ? `${m[1]} ${m[2]}` : \"--\";\n console.log(\n `${when.padEnd(14)} ${String(o.tag).padEnd(18)} ${String(o.agent).slice(0, 16).padEnd(16)} ${String(o.source ?? \"-\").padEnd(9)} ${String(o.outcome ?? \"-\").padEnd(9)} ` +\n `${fmtMs(o.cli).padStart(6)} ${String(o.cli_source ?? \"-\").padEnd(13)} ${fmtMs(o.painted).padStart(7)} ${fmtMs(o.revealed).padStart(8)} ` +\n `${fmtMs(o.quiet).padStart(6)} ${fmtMs(o.total).padStart(6)} ${o.auth ?? \"-\"}`,\n );\n }\n console.log(\"\\nsummary:\");\n for (const s of summarizeOpens(opens)) {\n const outcomes = Object.entries(s.outcomes).map(([k, v]) => `${k} ${v}`).join(\", \");\n console.log(\n ` ${s.source.padEnd(9)} ${s.n} open${s.n === 1 ? \"\" : \"s\"} (${outcomes}); first row p50 ${fmtMs(s.painted.p50)} p95 ${fmtMs(s.painted.p95)}; ` +\n `quiet p50 ${fmtMs(s.quiet.p50)} p95 ${fmtMs(s.quiet.p95)}${s.installs ? `; ${s.installs} installed the CLI` : \"\"}`,\n );\n }\n}\n\n// \u{2500}\u{2500}\u{2500} phases \u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\n// Unified turn-phase timeline for ONE agent pane, merged chronologically across\n// the frontend\'s `[wave-turn]` transition log (host) and the backend\'s\n// `[health] turn_active flip` log (srv) \u{2014} the two separate files an\n// investigator previously had to cross-reference by hand to answer \"why did\n// this pane show Working\". See\n// docs/specs/SPEC_AGENT_TURN_PHASE_TIMELINE_LOGGING_2026_08_18.md.\n//\n// Block-id matching differs by source: `[wave-turn]` embeds only a 7-char\n// PANE PREFIX in its plain message text (`pane=abc1234`, from\n// `blockId.slice(0, 7)` in agent-pane-state-store.ts \u{2014} there is no structured\n// field for it, since the frontend\'s console-log pipe just forwards a\n// space-joined string). `[health] turn_active flip` instead carries the FULL\n// block id as a structured tracing field (`fields.block_id`). Both are\n// checked via a custom `matcher`, since `renderLine`\'s generic `--grep`/\n// `--target` only ever look at the message text, never at arbitrary\n// structured fields like `block_id`.\n//\n// The recipe\'s own `matcher` (which pane/block this line is even about) is\n// NON-NEGOTIABLE \u{2014} always applied, never overridden \u{2014} but every OTHER\n// generic muxlog option the top-level help documents as applying \"any\n// position\" (`--grep`, `--level`, `--target`, `--exclude-target`, `-a`)\n// still needs to compose on top of it, the same way `swarm`/`auth`/`bridge`\n// compose theirs via `renderLine`/`printLastLines` (reagent P2 on PR #2653 \u{2014}\n// `phases` originally bypassed all of these silently). A user\'s `--grep`\n// ADDS an extra AND-filter here rather than replacing the recipe\'s own\n// matcher (unlike `auth`\'s `opt.grep || default`) \u{2014} replacing it would\n// defeat the entire point of `phases`, which is \"only lines about this one\n// block.\"\nfunction collectPhaseLines(file, opt, matcher) {\n const out = [];\n for (const l of readForDisplay(file, true).split(\"\\n\")) {\n const t = l.trim();\n if (!t) continue;\n let j;\n try { j = JSON.parse(t); } catch { continue; }\n const fields = j.fields || {};\n const msg = String(fields.message ?? j.message ?? \"\");\n const target = j.target || \"\";\n const level = (j.level || \"INFO\").toUpperCase();\n if (!opt.all && /blockcontroller::subprocess|subprocess stdout \u{2192} blockfile/.test(target + \" \" + msg)) continue;\n if (opt.level && !opt.level.includes(level.toLowerCase())) continue;\n if (opt.target && !target.includes(opt.target)) continue;\n if (opt.excludeTarget && target.includes(opt.excludeTarget)) continue;\n if (opt.grep && !opt.grep.test(msg)) continue;\n if (!matcher(msg, fields)) continue;\n if (opt.since && j.timestamp && j.timestamp < opt.since) continue;\n out.push({ ts: j.timestamp || \"\", msg, fields, raw: t });\n }\n return out;\n}\n\nconst PHASE_SOURCE_COLOR = { fe: \"\\x1b[36m\", srv: \"\\x1b[35m\" }; // cyan / magenta\n\n// [health] turn_active flip\'s own useful payload lives in structured fields\n// (crates/srv/src/backend/blockcontroller/health.rs: `active`, `was_active`,\n// `exit_code`), NOT in the message text \u{2014} which is the same static string\n// \"[health] turn_active flip\" every time. Surfacing only `entry.msg` (as an\n// earlier version of this recipe did) made every non-`--raw` srv line in the\n// timeline render identically regardless of whether the turn became active\n// or inactive \u{2014} the exact fact the merged timeline exists to expose (reagent\n// P1 on PR #2653). Always shown for srv lines (not gated behind --verbose,\n// unlike the generic renderLine path) since it\'s the entire reason srv lines\n// are in this recipe\'s timeline at all.\nfunction extraFieldsSuffix(fields) {\n const shown = { ...fields };\n delete shown.message;\n delete shown.block_id; // redundant \u{2014} already implied by the block filter\n const keys = Object.keys(shown);\n return keys.length ? ` ${DIM}${keys.map((k) => `${k}=${shown[k]}`).join(\" \")}${RESET}` : \"\";\n}\n\nfunction renderPhaseLine(entry, opt) {\n if (opt.raw) return entry.raw;\n const ts = (entry.ts || \"\").replace(/^.*T/, \"\").replace(/\\..*$/, \"\") || \"--:--:--\";\n const c = PHASE_SOURCE_COLOR[entry.source] ?? \"\";\n const suffix = entry.source === \"srv\" ? extraFieldsSuffix(entry.fields) : \"\";\n return `${DIM}${ts}${RESET} ${c}${entry.source.padEnd(3)}${RESET} ${entry.msg}${suffix}`;\n}\n\nfunction resolveBlockId(pos) {\n const id = pos[1] || process.env.AGENTMUX_BLOCKID;\n if (!id) {\n console.error(\n \"muxlog: `phases` needs a block id \u{2014} pass one explicitly (`muxlog phases <block-id>`) \" +\n \"or run this from inside an agent\'s own shell, where $AGENTMUX_BLOCKID is already set.\",\n );\n process.exit(1);\n }\n return id;\n}\n\n// Whether `file` actually contains at least one line matching BOTH `tag`\n// (e.g. \"[wave-turn]\") and `needle` (a pane prefix or full block id).\n// Content presence is the ground truth for \"is this the right log for this\n// pane\" \u{2014} filename metadata (source/version) is only ever a search-order\n// hint below, never sufficient on its own (reagent P1 x2 on PR #2653; see\n// resolvePhaseFiles\'s own doc comment for the two concrete failure modes\n// that motivated this).\nfunction fileContainsPane(file, tag, needle) {\n let content = \"\";\n try { content = fs.readFileSync(file, \"utf8\"); } catch { return false; }\n return content.includes(tag) && content.includes(needle);\n}\n\n// Resolve the ONE host+srv log pair that actually belongs to `blockId`\'s\n// running instance. Two correlation bugs, both caught live by reagent\n// review while this recipe was under review, motivate doing this by\n// CONTENT rather than by filename metadata:\n//\n// 1. Picking `hostCands[0]` (globally most-recently-active host log,\n// respecting only `-i`) without checking it actually contains the\n// requested block id: with several instances running, the caller\'s\n// OWN pane (the common case \u{2014} default $AGENTMUX_BLOCKID) can easily\n// NOT be in whichever instance happens to be most recently active\n// overall, silently resolving to the wrong instance\'s host log (and,\n// via that wrong host, the wrong srv pairing too) and reporting \"no\n// lines found\" instead of the real timeline \u{2014} defeating the \"zero\n// setup self-audit\" the recipe exists to provide.\n// 2. Correlating srv\u{2192}host by `(source, version)` filename metadata alone:\n// `source` for a dev build is `\"dev:\" + <branch>` only \u{2014} it drops the\n// `<hash>` build-directory segment, so two retained dev builds of the\n// SAME branch at the SAME version are indistinguishable by this key,\n// and the mtime-sorted first match can be an unrelated build\'s srv\n// log. (`source` alone, with no version check at all, has an even\n// coarser version of the same problem: the shared root holds several\n// version-tagged pairs at once.)\n//\n// Fix: content presence is the ground truth for both. `source`/`version`\n// are used ONLY to order candidates cheaply (try the most-likely pairing\n// first) before falling back to a full scan \u{2014} never as the sole decision.\nfunction resolvePhaseFiles(pos, opt) {\n const blockId = resolveBlockId(pos);\n const prefix = `pane=${blockId.slice(0, 7)}`;\n\n let hostCands = discover(\"host\");\n // Case-insensitive filterByInstance (see its own doc comment) \u{2014} the\n // content-verification scan just below is the real correctness backstop\n // for `phases`, so this is only ever an ordering hint, same as before.\n if (opt.instance) hostCands = filterByInstance(hostCands, opt.instance);\n if (!hostCands.length) {\n console.error(`muxlog: no host log found${opt.instance ? ` matching \'${opt.instance}\'` : \"\"}. Try \\`muxlog ls\\`.`);\n process.exit(1);\n }\n // Scan newest-first for the first host log that actually contains this\n // pane. Falls back to the most-recent candidate (old behavior) only if\n // NONE contain it \u{2014} the timeline query below still runs and reports a\n // clear \"no lines found\" naming the (real, if wrong) file it checked,\n // rather than crashing outright.\n const hostEntry = hostCands.find((e) => fileContainsPane(e.file, \"[wave-turn]\", prefix)) ?? hostCands[0];\n\n const srvCands = discover(\"srv\");\n const bySourceVersion = srvCands.filter((e) => e.source === hostEntry.source && e.version === hostEntry.version);\n const bySource = srvCands.filter((e) => e.source === hostEntry.source);\n const srvEntry =\n bySourceVersion.find((e) => fileContainsPane(e.file, \"[health]\", blockId)) ??\n bySource.find((e) => fileContainsPane(e.file, \"[health]\", blockId)) ??\n srvCands.find((e) => fileContainsPane(e.file, \"[health]\", blockId)) ??\n // No srv log anywhere contains this block id yet \u{2014} not necessarily\n // an error (e.g. the pane opened but no turn has run since srv last\n // rotated). Fall back to the best-guess pairing so the timeline\n // still shows whatever fe-side lines exist.\n bySourceVersion[0] ?? bySource[0] ?? srvCands[0];\n if (!srvEntry) {\n console.error(\"muxlog: no srv log found. Try `muxlog ls`.\");\n process.exit(1);\n }\n\n return { blockId, prefix, hostFile: hostEntry.file, srvFile: srvEntry.file };\n}\n\nfunction phasesTimeline(pos, opt) {\n const { blockId, prefix, hostFile, srvFile } = resolvePhaseFiles(pos, opt);\n\n const feLines = collectPhaseLines(hostFile, opt, (msg) => msg.includes(\"[wave-turn]\") && msg.includes(prefix))\n .map((e) => ({ ...e, source: \"fe\" }));\n const srvLines = collectPhaseLines(srvFile, opt, (msg, fields) => msg.includes(\"[health]\") && fields.block_id === blockId)\n .map((e) => ({ ...e, source: \"srv\" }));\n\n const merged = [...feLines, ...srvLines].sort((a, b) => (a.ts < b.ts ? -1 : a.ts > b.ts ? 1 : 0));\n if (!merged.length) {\n console.error(\n `muxlog phases: no [wave-turn]/[health] lines found for block ${blockId} in\\n` +\n ` host: ${hostFile}\\n srv: ${srvFile}`,\n );\n process.exit(1);\n }\n\n console.log(`=== phases: ${blockId} ===`);\n console.log(` host: ${hostFile}`);\n console.log(` srv: ${srvFile}\\n`);\n const tail = opt.n > 0 ? merged.slice(-opt.n) : merged;\n for (const e of tail) console.log(renderPhaseLine(e, opt));\n}\n\nconst HELP = `muxlog \u{2014} AgentMux log viewer\n\n muxlog [host|srv|launcher|fe|all] [tail|cat|grep <re>] default: host tail (follow)\n muxlog ls list every instance\'s logs (newest first)\n muxlog mem system commit-free + pressure + live AgentMux procs\n muxlog errors ERROR/WARN across host+srv (active instance)\n muxlog bridge startup-handshake trace (debug reconnect loops)\n muxlog swarm subagent/swarm lifecycle trace (spawn/name/status, debug duplicate groups)\n muxlog swarm -d <dispatch_id> same, filtered to one dispatch + a match-count verdict \u{2014} \"did this ever get processed here\"\n muxlog auth provider auth/identity trace (login/OAuth wiring/unlink/account removal, credstate snapshots)\n muxlog phases [<block-id>] merged turn-phase timeline (host [wave-turn] + srv [health]) for one pane \u{2014} defaults to $AGENTMUX_BLOCKID\n muxlog admission [<agent>] why won\'t this agent run here: its admission/take-over/lifecycle lines across ALL instances,\n merged, then a per-instance verdict + diagnosis \u{2014} defaults to $AGENTMUX_AGENT_ID, --since 24h ago\n muxlog opens [<agent>] how long agent opens took: one row per [agent-open] line across ALL instances\n (ms since click to first row / reveal / quiet), then p50/p95 per source \u{2014} --since 24h ago\n\nOptions (any position):\n -i <substr> pick the instance whose log path/branch/version matches <substr>\n -d <id> only lines mentioning <id> anywhere in the raw line (message OR any structured field) \u{2014} combine with \'swarm\' for a per-dispatch verdict\n -n <N> history lines before following (default 200)\n -a include agent-transcript noise (excluded by default)\n --grep <re> filter on the message field only (not the whole JSON line)\n --level a,b only these levels (error,warn,info,debug)\n --target <s> only log lines whose target contains <s>\n --since <ts> only lines at/after ISO <ts> (e.g. 2026-06-15T23:30) \u{2014} also reads the older daily log files the window reaches\n --until <ts> only lines at/before ISO <ts> (same file widening)\n --agent <a> only lines about agent <a> (name or uid, any case; matches its structured fields and its panes\' block ids)\n --instances all|live|<s1,s2> merge these instances into one timeline, each line tagged v<version>/<channel>\n --raw emit the original NDJSON --verbose include structured fields`;\n\nasync function main() {\n const { opt, pos } = parse(process.argv.slice(2));\n const cmd = pos[0] || \"host\";\n\n if (cmd === \"help\" || cmd === \"-h\" || cmd === \"--help\") { console.log(HELP); return; }\n if (cmd === \"ls\") { await listInstances(); return; }\n if (cmd === \"mem\" || cmd === \"doctor\") { memDoctor(); return; }\n\n if (cmd === \"errors\") {\n opt.level = [\"error\", \"warn\"];\n // Was its own ad-hoc `.file`-only filter with no own-channel default\n // at all \u{2014} routed through the same pickCandidate() every other\n // recipe uses so `errors` gets the same \"prefer my own instance\"\n // behavior instead of \"freshest anywhere\" (see resolveFile\'s doc\n // comment above).\n for (const tgt of [\"host\", \"srv\"]) {\n const file = pickCandidate(discover(tgt), opt, process.env.AGENTMUX_CHANNEL);\n if (file) { console.log(`\\n=== ${tgt}: ${file} ===`); printLastLinesMulti(windowFiles(file, opt), opt.n, opt, true); }\n }\n return;\n }\n if (cmd === \"swarm\") {\n // Subagent/swarm lifecycle trace: spawn, display_name resolution\n // (subagent.GenerateName), status transitions (including\n // reconcile_stale_subagents\' active->abandoned pass), and the\n // parent_block_id a subagent is bound to on backfill \u{2014} the fields a\n // NAME-based grouping/dedup bug in the Swarm view needs, without\n // wading through the (usually much larger) agent-transcript log.\n // All emitted srv-side from the subagent_watcher/ module\'s tracing target\n // (`agentmux_srv::backend::subagent_watcher`, rendered as\n // `srv:subagent_watcher` by shortTarget) \u{2014} there is no host-side\n // subagent logging to combine in (checked: agentmux-cef has none).\n opt.target = opt.target || \"subagent_watcher\";\n const f = resolveFile(\"srv\", opt);\n console.log(`=== swarm trace: ${f} ===`);\n const matched = printLastLinesMulti(windowFiles(f, opt), opt.n, opt, true);\n // Ext 6 of docs/reports/REPORT_MUXSPECT_MUXLOG_CROSS_CHANNEL_INSPECTION_2026_08_22.md:\n // `-d/--dispatch <id>` productizes the manual correlation this\n // report\'s whole investigation had to do by hand \u{2014} \"did\n // subagent_watcher ever log anything about this specific dispatch,\n // on the instance I\'m actually resolved to.\" A silent 0 matches\n // looks identical to \"the recipe found nothing to print\" for any\n // other filter; --dispatch gets an explicit verdict line instead,\n // since \"never appeared in Swarm\" is exactly the symptom a human\n // reaches for this flag to diagnose, and an empty result IS the\n // answer, not an accident.\n if (opt.dispatch) {\n if (matched === 0) {\n console.log(\n `\\nverdict: 0 lines mention \'${opt.dispatch}\' in ${f}\\n` +\n ` \u{2014} either it was never processed by subagent_watcher on THIS instance` +\n ` (double-check you\'re resolved to the right one: \\`muxlog ls\\`, or pass -i explicitly),` +\n ` or the dispatch id itself doesn\'t match what actually got logged (try a shorter/partial` +\n ` substring \u{2014} e.g. just the first 8 chars of a UUID).`,\n );\n } else {\n console.log(`\\nverdict: ${matched} line${matched === 1 ? \"\" : \"s\"} mention \'${opt.dispatch}\' in ${f}`);\n }\n }\n return;\n }\n if (cmd === \"auth\") {\n // Provider auth / identity lifecycle trace. Unlike `swarm` (one module\n // \u{2192} one tracing target), auth events span several srv modules \u{2014}\n // server/identity_handlers.rs (auth.start/spawn/poll/cancel/submit*,\n // \"auth success (direct-account)\" persistence, OAuth config-dir\n // wiring), server/cli_handlers.rs (CheckCliAuth, \"claude auth:\"\n // credential seeding), identity/auth_session.rs (cancel_session,\n // session timeout), identity/resolver/inject.rs (\"oauth probe\"), and the\n // logout side in server/app_api/identity.rs + agent_handlers/\n // identity.rs (\"identity.unlink:\", \"identity.delete:\"), plus the\n // layer-3 spawn gate in identity/resolver/inject.rs\n // (\"identity.spawn.blocked:\", \"identity.spawn.ambient:\"), and the\n // login/logout-round credential-state diagnostics in\n // server/cli_handlers.rs (\"auth.credstate:\", a redacted\n // token-fingerprint snapshot of the checked dir) + the post-removal\n // verify in identity/cleanup.rs (\"identity.delete: ... STILL PRESENT\n // after remove_dir_all\") \u{2014} so filter on the MESSAGE vocabulary, not a\n // target (opt.grep matches the message field only, exactly what we\n // want). A user --grep overrides the recipe\'s regex;\n // --target/--level/--since/-n still combine.\n opt.grep = opt.grep || /\\bauth\\.\\w+|auth success|auth session|cancel_session|claude auth|CheckCliAuth|OAuth config dir|oauth probe|identity_upsert|identity\\.(unlink|delete|self\\.|account|spawn)|account\\.oauth|keychain delete/i;\n const f = resolveFile(\"srv\", opt);\n console.log(`=== auth trace: ${f} ===`);\n printLastLinesMulti(windowFiles(f, opt), opt.n, opt, true);\n return;\n }\n if (cmd === \"bridge\") {\n // The startup handshake \u{2014} correlate cred injection with bridge-init outcome.\n opt.grep = /Loading URL|Injected IPC|backend.?ready|window\\.api|Bootstrap|setupCefApi|reconnect|on_load_end/i;\n opt.all = true;\n const f = resolveFile(\"host\", opt);\n console.log(`=== bridge trace: ${f} ===`);\n printLastLinesMulti(windowFiles(f, opt), opt.n, opt, true);\n return;\n }\n if (cmd === \"phases\") {\n // One-shot merged timeline, not a live follow \u{2014} see phasesTimeline\'s\n // own doc comment for why host+srv need correlated instance\n // resolution and custom (not renderLine\'s generic) filtering.\n phasesTimeline(pos, opt);\n return;\n }\n if (cmd === \"admission\") {\n await admissionRecipe(pos, opt);\n return;\n }\n if (cmd === \"opens\") {\n await opensRecipe(pos, opt);\n return;\n }\n\n const targets = [\"host\", \"srv\", \"launcher\", \"fe\", \"all\"];\n const validActions = [\"tail\", \"cat\", \"grep\"];\n // A first token that is neither a known recipe (help/ls/mem/errors/swarm/\n // auth/bridge/phases \u{2014} all handled above and returned), a log target,\n // nor a bare action is an unknown command. Without this guard it silently falls\n // through to `follow(host)` below and tails the host log forever with no\n // filter \u{2014} which is exactly how a typo, or `muxlog auth` run against a\n // build predating the auth recipe, appears to \"hang\". Fail fast instead.\n if (!targets.includes(cmd) && !validActions.includes(cmd)) {\n console.error(`muxlog: unknown command \'${cmd}\'. Run \\`muxlog help\\` for usage.`);\n process.exit(1);\n }\n const target = targets.includes(cmd) ? cmd : \"host\";\n const action = (targets.includes(cmd) ? pos[1] : pos[0]) || \"tail\";\n if (action === \"grep\") { const re = pos[pos.indexOf(\"grep\") + 1]; if (re) opt.grep = new RegExp(re, \"i\"); }\n if (target === \"fe\") opt.grep = opt.grep || /\\[fe\\]/;\n\n if (opt.instances) {\n // Several instances merged into one timeline: a one-shot print, not a\n // follow (there\'s no single file to tail). `tail` keeps its -n cap.\n const groups = await resolveInstanceGroups(target === \"fe\" || target === \"all\" ? \"host\" : target, opt);\n if (action === \"cat\" || action === \"grep\") opt.n = 0;\n printMerged(groups, opt, `${target} across ${groups.length} instance${groups.length === 1 ? \"\" : \"s\"}`);\n return;\n }\n\n const file = resolveFile(target === \"fe\" ? \"host\" : target === \"all\" ? \"host\" : target, opt);\n\n if (action === \"cat\" || action === \"grep\") { printLastLinesMulti(windowFiles(file, opt), 0, opt, true); return; }\n // default: tail -f (print last -n lines, then follow)\n printLastLinesMulti(windowFiles(file, opt), opt.n, opt);\n follow(file, opt);\n}\n\n// Only run when executed directly (`node muxlog.mjs ...`) \u{2014} importing this\n// module (muxlog.test.mjs imports `glob`) must not trigger a live tail loop /\n// `process.exit`. Same pattern as muxspect.mjs. `main` is async now (the\n// `ls` recipe awaits liveness probes) \u{2014} `.catch` surfaces a rejected\n// promise as a visible error instead of an unhandled-rejection warning.\nif (import.meta.url === pathToFileURL(process.argv[1] ?? \"\").href) {\n main().catch((e) => {\n console.error(`muxlog: ${e?.message ?? String(e)}`);\n process.exit(1);\n });\n}\n";Expand description
Shared muxlog core (Node). Deployed once at <shell>/muxlog.mjs; every
shell’s muxlog function delegates to it. One tested implementation does log
discovery + NDJSON rendering + filtering for all shells.