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\";\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: srv, launcher, some host)\n//   ~/.agentmux/dev/<branch>/<hash>/logs/              (task dev, keyed on branch)\n//   ~/.agentmux/channels/local-*/versions/<v>/.../logs/(portable/per-build)\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    for (const p of glob(path.join(AGENTMUX, \"channels\", \"*\", \"versions\", \"*\", \"*\", \"logs\")))\n        yield { dir: p, source: \"channel:\" + p.split(path.sep).at(-5) };\n}\n\n// Tiny one-level-per-`*` glob (no deps). Only handles `*` path segments.\nfunction 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((a, b) => b.mtime - a.mtime);\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).\nfunction renderLine(raw, opt) {\n    const t = raw.trim();\n    if (!t) 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) 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\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    }\n    return line;\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\".\nfunction printLastLines(file, n, opt, whole = false) {\n    const rendered = [];\n    for (const l of readForDisplay(file, whole).split(\"\\n\")) {\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}\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} 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}\nfunction 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    console.log(`${\"TARGET\".padEnd(9)}${\"VERSION\".padEnd(9)}${\"SOURCE\".padEnd(22)}${\"AGE\".padEnd(8)}${\"SIZE\".padEnd(8)}PATH`);\n    for (const e of rows) {\n        console.log(\n            e.target.padEnd(9) + e.version.padEnd(9) + e.source.slice(0, 21).padEnd(22) +\n            age(e.mtime).padEnd(8) + human(e.size).padEnd(8) + e.file,\n        );\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 (agentmux-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 === \"--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 === \"--grep\") opt.grep = new RegExp(argv[++i], \"i\");\n        else pos.push(a);\n    }\n    return { opt, pos };\n}\n\nfunction resolveFile(target, opt) {\n    let cands = discover(target);\n    if (opt.instance) cands = cands.filter((e) => e.file.toLowerCase().includes(opt.instance.toLowerCase()) || e.source.includes(opt.instance) || e.version.includes(opt.instance));\n    if (!cands.length) {\n        console.error(`muxlog: no ${target} log found${opt.instance ? ` matching \'${opt.instance}\'` : \"\"}. Try \\`muxlog ls\\`.`);\n        process.exit(1);\n    }\n    return cands[0].file; // most recently active\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\nOptions (any position):\n  -i <substr>   pick the instance whose log path/branch/version matches <substr>\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)\n  --raw         emit the original NDJSON   --verbose  include structured fields`;\n\nfunction 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\") { listInstances(); return; }\n    if (cmd === \"mem\" || cmd === \"doctor\") { memDoctor(); return; }\n\n    if (cmd === \"errors\") {\n        opt.level = [\"error\", \"warn\"];\n        for (const tgt of [\"host\", \"srv\"]) {\n            const f = discover(tgt).filter((e) => !opt.instance || e.file.toLowerCase().includes(opt.instance.toLowerCase()))[0];\n            if (f) { console.log(`\\n=== ${tgt}: ${f.file} ===`); printLastLines(f.file, opt.n, opt, true); }\n        }\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        printLastLines(f, opt.n, opt, true);\n        return;\n    }\n\n    const targets = [\"host\", \"srv\", \"launcher\", \"fe\", \"all\"];\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    const file = resolveFile(target === \"fe\" ? \"host\" : target === \"all\" ? \"host\" : target, opt);\n\n    if (action === \"cat\" || action === \"grep\") { printLastLines(file, 0, opt, true); return; }\n    // default: tail -f (print last -n lines, then follow)\n    printLastLines(file, opt.n, opt);\n    follow(file, opt);\n}\n\nmain();\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.