Skip to main content

MUXLOG_JS

Constant MUXLOG_JS 

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