@martintrojer/murmur 0.2.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +129 -81
- package/CHANGELOG.md +120 -0
- package/README.md +105 -70
- package/dist/cli.js +840 -271
- package/dist/cli.js.map +1 -1
- package/dist/extension/murmur-pi.js +7 -3
- package/dist/extension/murmur-pi.js.map +1 -1
- package/dist/extension/store.js.map +1 -1
- package/dist/index.d.ts +54 -2
- package/dist/index.js +27 -9
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
|
@@ -27,6 +27,10 @@ function runTmux(args) {
|
|
|
27
27
|
return null;
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
|
+
function chosenWindowName(name, autoRename) {
|
|
31
|
+
if (autoRename === "1") return null;
|
|
32
|
+
return name || null;
|
|
33
|
+
}
|
|
30
34
|
function exactSession(session) {
|
|
31
35
|
return `=${session}`;
|
|
32
36
|
}
|
|
@@ -46,16 +50,16 @@ var tmux = {
|
|
|
46
50
|
"-t",
|
|
47
51
|
pane,
|
|
48
52
|
"-p",
|
|
49
|
-
"#{session_id} #{window_id} #{session_name} #{window_name}"
|
|
53
|
+
"#{session_id} #{window_id} #{session_name} #{window_name} #{?automatic-rename,1,0}"
|
|
50
54
|
]);
|
|
51
|
-
const [session, window, sessionName, windowName] = fields?.split(" ") ?? [];
|
|
55
|
+
const [session, window, sessionName, windowName, autoRename] = fields?.split(" ") ?? [];
|
|
52
56
|
if (!session || !window) return null;
|
|
53
57
|
return {
|
|
54
58
|
session: asSessionId(session),
|
|
55
59
|
window: asWindowId(window),
|
|
56
60
|
pane,
|
|
57
61
|
session_name: sessionName || null,
|
|
58
|
-
window_name: windowName
|
|
62
|
+
window_name: chosenWindowName(windowName, autoRename)
|
|
59
63
|
};
|
|
60
64
|
},
|
|
61
65
|
// Which of this host's PANES still exist. The only liveness question tmux is
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/extension/murmur-pi.ts","../../src/mux.ts","../../src/ids.ts","../../src/extension/decide.ts"],"sourcesContent":["import { execFileSync } from \"node:child_process\";\nimport { tmux } from \"../mux.js\";\nimport type { Store } from \"../store.js\";\nimport type { Activity, AgentMeta, Location } from \"../types.js\";\nimport { driverFromEnv, settledState } from \"./decide.js\";\nimport type { StoreModule } from \"./store-api.js\";\n\n// Declared here rather than imported: murmur must not depend on pi to build,\n// and this is the whole surface the extension touches. getSessionName is\n// optional because an older pi does not have it, and a missing method must\n// degrade to \"no name\" rather than break the extension.\n//\n// The five events murmur needs, and no `reason` on any of them. pi puts a reason\n// on session_shutdown (\"quit\" | \"reload\" | \"new\" | \"resume\" | \"fork\"), but the\n// correct response is the same for all five: release the agent, clear the badge,\n// drop the store handle. What differs is only whether anything follows, and\n// session_start answers that by firing.\ntype ExtensionAPI = {\n on(\n event: \"agent_start\" | \"agent_end\" | \"agent_settled\" | \"session_shutdown\" | \"session_start\",\n handler: () => void | Promise<void>,\n ): void;\n getSessionName?(): string | undefined;\n};\n\n// Where to import the store from.\n//\n// The bare specifier only resolves when murmur is a dependency of the importer,\n// which it never is: the extension is loaded from ~/.pi/agent/extensions, and a\n// globally linked or installed murmur is not resolvable from there. Unpinned,\n// the import throws, getStore swallows it, and every write silently no-ops\n// while the tmux badge still paints -- so nothing looks broken while the store\n// stays empty and the node exports nothing.\n//\n// Two ways it gets pinned, because there are two install shapes:\n//\n// $MURMUR_STORE_MODULE set by the shim `murmur link pi` writes, which is a\n// re-export of THIS file from the murmur install. The\n// shim cannot rewrite this constant (it does not copy\n// the source), so it passes the path instead.\n// link pi --copy inlines this file and rewrites the string literal.\nconst storeModule = process.env.MURMUR_STORE_MODULE || \"@martintrojer/murmur/extension-store\";\nconst muManaged = process.env.MU_MANAGED_AGENT === \"1\";\nconst driver = driverFromEnv(process.env);\n\nfunction focused(pane: string): boolean {\n try {\n return (\n execFileSync(\n \"tmux\",\n [\n \"display-message\",\n \"-t\",\n pane,\n \"-p\",\n \"#{&&:#{pane_active},#{&&:#{window_active},#{session_attached}}}\",\n ],\n { encoding: \"utf8\", timeout: 3000, stdio: [\"ignore\", \"pipe\", \"ignore\"] },\n ).trim() === \"1\"\n );\n } catch {\n return false;\n }\n}\n\n// pi.getSessionName() is a live read and a session can be unnamed, so this must\n// never be the reason a report is lost.\nfunction safeSessionName(pi: ExtensionAPI): string | null {\n try {\n return pi.getSessionName?.() || null;\n } catch {\n return null;\n }\n}\n\nexport default function murmurPi(pi: ExtensionAPI): void {\n const startLocation = tmux.currentWindow();\n if (!startLocation) return;\n\n /**\n * Where this agent is NOW, not where it started.\n *\n * A pane can be moved between windows -- `move-pane`, `break-pane`, or a\n * keybinding that wraps them -- and tmux keeps the pane id while the window\n * id changes. Resolving the window once at startup meant an agent that was\n * moved painted its badge on the window it used to live in and recorded a\n * stale location on every later write.\n *\n * The pane is the address and does not change, so the agent row stays put;\n * only the location is re-read. Falls back to the startup location if tmux\n * cannot answer, which keeps a transient failure from rewriting an agent's\n * address to nothing.\n */\n let lastWindow = startLocation.window;\n const here = (): Location => {\n const location = tmux.currentWindow() ?? startLocation;\n // A move leaves the badge behind on the window the agent used to be in,\n // where nothing else will ever clear it: the badge belongs to the window,\n // and the only process that knows this agent left is this one.\n if (location.window !== lastWindow) {\n try {\n tmux.setWindowBadge(lastWindow, null);\n } catch {\n // Best effort; the new window's badge matters more than the old one's.\n }\n lastWindow = location.window;\n }\n return location;\n };\n\n const meta = (): AgentMeta => ({\n // mu names its agents; pi names its sessions. Both beat a window name when\n // present, and neither can be recovered from tmux.\n agent_name: process.env.MU_AGENT_NAME ?? null,\n pi_session: safeSessionName(pi),\n workstream: process.env.MU_WORKSTREAM ?? null,\n role: process.env.MU_ROLE ?? null,\n cli: \"pi\",\n driver,\n });\n\n /**\n * One variable, three named states, so the combinations that must not exist\n * cannot be written down.\n *\n * This was a `Store | null | undefined` plus a separate `absent` boolean --\n * six combinations for three meanings -- and conflating two of them silenced\n * the extension for the life of the process: `null` meant both \"murmur is not\n * installed, stop trying\" and \"a write failed, let go of the handle\", so one\n * transient failure latched reporting off while the tmux badge still painted.\n *\n * Only `absent` is permanent, and only a failed import, a missing identity or\n * a REFUSED claim produces it. A dropped handle returns to `untried`, so the\n * next event reopens.\n */\n type StoreState = { kind: \"untried\" } | { kind: \"open\"; store: Store } | { kind: \"absent\" };\n let state: StoreState = { kind: \"untried\" };\n /**\n * This process is nested, permanently and unrecoverably.\n *\n * Separate from `absent`, which `session_start` re-arms: a missing murmur and\n * a missing identity are both fixable from outside a running pi, but a second\n * live process in one pane never becomes the owner. Re-arming that would let a\n * nested pi start reporting as the parent agent after the first /reload.\n */\n let refused = false;\n /** This process's agent row, for the life of the process. */\n let agentId: string | null = null;\n let queue: Promise<void> = Promise.resolve();\n\n const enqueue = (work: () => Promise<void>): Promise<void> => {\n queue = queue.then(work, work);\n return queue;\n };\n\n const dropStore = (): void => {\n if (state.kind === \"open\") {\n try {\n state.store.close();\n } catch {\n // Best effort: extension failures must never reach pi.\n }\n }\n state = { kind: \"untried\" };\n };\n\n /**\n * Open the store and claim the pane, in that order, once.\n *\n * `refused` is the nested-agent case, and it is permanent for this process: a\n * pi launched inside an agent's pane inherits $TMUX_PANE and would otherwise\n * report AS the parent agent. Six pids once wrote to one pane that way and the\n * parent read as idle while it was working. The claim's liveness probe answers\n * this with the database rather than with an environment marker a process\n * launched in an unusual way could drop -- and a refused caller registers\n * nothing, paints nothing, and says nothing.\n */\n const getStore = async (): Promise<Store | null> => {\n // Permanent: murmur is not installed, this node has no identity, or this\n // process is nested. None becomes false later in the same process, so\n // retrying would pay a failed dynamic import per turn forever.\n // `session_start` re-arms it, because the first two ARE fixable from\n // outside a running pi.\n if (state.kind === \"absent\") return null;\n if (refused) return null;\n if (state.kind === \"open\") return state.store;\n try {\n const { loadIdentity, openStore } = (await import(storeModule)) as StoreModule;\n // Read, never minted: an extension load must not bring a node into\n // existence.\n if (!loadIdentity()) {\n state = { kind: \"absent\" };\n return null;\n }\n const store = openStore();\n const claim = store.claimAgent({\n location: here(),\n owner_pid: process.pid,\n meta: meta(),\n });\n if (claim.outcome === \"refused\") {\n store.close();\n refused = true;\n state = { kind: \"absent\" };\n return null;\n }\n // `retained` is what makes /reload a no-op: pi re-runs this factory in the\n // same process, and the store recognises our own pid.\n agentId = claim.agent_id;\n state = { kind: \"open\", store };\n return store;\n } catch {\n state = { kind: \"absent\" };\n return null;\n }\n };\n\n /**\n * Report activity, and answer whether this process is still the owner.\n *\n * `setActivity` returning false is not an error and is not retried: it means\n * this process is no longer the owner of record, and the correct response is\n * silence.\n *\n * The boolean is what the badge is gated on. It has to be, because the badge\n * is the only part of a report a human sees directly: painting it before the\n * write is how a silently non-reporting extension looks healthy for the life\n * of a process. A window whose agent row belongs to someone else must not\n * carry this process's glyph.\n */\n const report = async (activity: Activity, location: Location): Promise<boolean> => {\n try {\n const store = await getStore();\n if (!store || !agentId) return false;\n return store.setActivity({ agent_id: agentId, owner_pid: process.pid, activity, location });\n } catch {\n dropStore();\n return false;\n }\n };\n\n /**\n * Claim the pane NOW, not on the first event.\n *\n * A nested process must paint no badge, and the badge is painted by the same\n * handler that reports -- so ownership has to be settled before any handler\n * can run.\n *\n * ONE DEVIATION FROM THE CONTRACT, stated because it is visible: §9.1 says a\n * refused process registers no handlers. It cannot, quite. The store arrives\n * through a dynamic `import()` of a path pinned at runtime, so the claim is\n * asynchronous, and pi's extension factory is not -- handlers must be attached\n * before the first `await` resolves or the extension misses events it does own.\n *\n * The observable behaviour is identical, which is what the contract is\n * actually about: the claim goes on the queue that already serialises every\n * handler, so each handler runs after it, and a refused process writes\n * nothing, paints nothing and holds no store handle. `refused` is checked in\n * both places that could act -- the badge and the store -- rather than being\n * relied on to be checked once.\n */\n void enqueue(async () => {\n await getStore();\n });\n\n /** Paint only if we own the pane. A nested agent is deliberately invisible. */\n const badge = (location: Location, state: \"running\" | null): void => {\n if (refused) return;\n tmux.setWindowBadge(location.window, state);\n };\n\n pi.on(\"agent_start\", () => {\n void enqueue(async () => {\n const location = here();\n // Ownership first, glyph second. A process whose pane was taken over\n // while its handle was dropped learns that from the claim inside\n // `report`, and a badge painted before it would announce an agent that\n // no longer lives in this window.\n if (await report(\"running\", location)) badge(location, \"running\");\n });\n });\n\n pi.on(\"agent_end\", () => {\n void enqueue(async () => {\n const location = here();\n // Clearing is safe whatever the answer -- it retracts this process's own\n // glyph and can only ever say less -- but it is still ordered after the\n // write so that both halves read the same ownership answer.\n await report(\"stopped\", location);\n badge(location, null);\n });\n });\n\n // The event that produces `done`. agent_end alone cannot express it: agent_end\n // fires when a run's loop ends, which is not the same as \"nothing more will\n // happen\" -- pi re-enters the loop for a retry, a compaction, or a queued\n // message, and each re-entry emits its own start/end pair. Only\n // `agent_settled` means finished and waiting. See the table in decide.ts.\n pi.on(\"agent_settled\", () => {\n void enqueue(async () => {\n const location = here();\n const settled = settledState(focused(location.pane), muManaged);\n if (settled === null) return;\n try {\n const store = await getStore();\n if (!store || refused) return;\n // Attention is pane-addressed, and this call structurally cannot name an\n // agent, a pid or an activity. Completion is `done`; `blocked` is never\n // authored by an owner.\n store.requestAttention({\n kind: settled,\n location,\n message: \"\",\n source: \"pi\",\n });\n tmux.setWindowBadge(location.window, settled);\n } catch {\n dropStore();\n }\n });\n });\n\n // `session_shutdown` does not mean \"the process is exiting\". pi fires it for\n // `/reload`, and for session switch, resume and fork, then rebinds and keeps\n // going -- its own docs say to clean up here and reestablish in\n // `session_start`. Treating it as terminal killed reporting permanently on\n // the first `/reload`.\n //\n // Releasing the agent deletes the row but deliberately NOT its attention: a\n // `done` raised at settle must survive the process quitting, or completion\n // becomes invisible the moment the agent exits.\n pi.on(\"session_shutdown\", async () => {\n await enqueue(async () => {\n const location = here();\n badge(location, null);\n try {\n if (state.kind === \"open\" && agentId) {\n state.store.releaseAgent({ agent_id: agentId, owner_pid: process.pid });\n }\n } catch {\n // The handle goes either way.\n }\n agentId = null;\n dropStore();\n });\n });\n\n // Reestablish, per pi's documented contract. A reload leaves this instance\n // live but with its store dropped and its cached location possibly wrong --\n // the pane can have moved while the session was being switched.\n pi.on(\"session_start\", () => {\n void enqueue(async () => {\n if (state.kind === \"absent\") state = { kind: \"untried\" };\n const location = here();\n lastWindow = location.window;\n // Re-claim NOW, not on the next agent event.\n //\n // `session_shutdown` released the agent row, so between it and this\n // handler the pane has no owner and `claimAgent` would refuse nobody. If\n // the re-claim waited for an agent event -- which may be minutes away, or\n // never, since /reload happens while the agent is idle -- a pi started in\n // this pane in the meantime claims it legitimately, and this process is\n // then refused permanently: silent for the rest of its life while its\n // badge still paints. pi fires session_start immediately after the\n // shutdown for exactly this reestablishment, which bounds the unowned\n // window to the gap between two synchronous handler calls.\n await getStore();\n });\n });\n}\n","import { execFileSync } from \"node:child_process\";\nimport {\n asPaneId,\n asSessionId,\n asWindowId,\n type PaneId,\n type SessionId,\n type WindowId,\n} from \"./ids.js\";\nimport type { Location } from \"./types.js\";\nimport type { RenderState } from \"./view.js\";\n\nexport interface Mux {\n currentWindow(): Location | null;\n livePanes(): Set<PaneId> | null;\n // Sets `@agent_state` on a WINDOW, even though the attention it expresses\n // belongs to a pane. The asymmetry is tmux's: the status bar and the `tms`\n // picker read a window option, and there is no per-pane equivalent they\n // would read instead. Its consequence is that a pane moving between windows\n // must clear the badge it left behind, since nothing else knows it moved.\n setWindowBadge(window: WindowId, state: RenderState | null): void;\n // Reports whether the attach actually happened. runTmux swallows failures to\n // return null, and a jump that silently failed looked exactly like \"enter did\n // nothing\" -- the symptom the remote probe was added to prevent, reproduced\n // on the local path.\n attach(session: SessionId, window: WindowId): boolean;\n windowForPane(pane: PaneId): WindowId | null;\n panesInWindow(window: WindowId): PaneId[];\n capture(pane: PaneId, lines?: number): string | null;\n // --- remote-jump session seam -------------------------------------------\n // A remote attach lives in its own local session rather than a window, so it\n // can be full-screen (no local status bar) and prefix-free (no nested ^b).\n // See jumpToAgent for why that is worth five extra methods.\n clientName(): string | null;\n currentTarget(): string | null;\n sessionNamed(name: string): boolean;\n newSession(name: string, command: string): boolean;\n setSessionOption(session: string, option: string, value: string): void;\n switchClient(client: string | null, session: string): boolean;\n}\n\nfunction runTmux(args: string[]): string | null {\n try {\n return execFileSync(\"tmux\", args, {\n encoding: \"utf8\",\n timeout: 3000,\n stdio: [\"ignore\", \"pipe\", \"ignore\"],\n }).trim();\n } catch {\n return null;\n }\n}\n\n/**\n * A session name as an exact target, in the two spellings tmux needs.\n *\n * Bare names match by PREFIX, so a wrapper for host `bub` silently retargets a\n * session called `bubba` once one exists -- verified, and it sets options on\n * the wrong session rather than failing. A leading `=` demands an exact match.\n * (`name=` is not the syntax; it reads as part of the name and matches nothing.)\n *\n * The trailing colon is the part that is easy to get wrong. `switch-client -t`\n * takes a target-SESSION, where `=name` is right, but `set-option -t` and\n * `show-options -t` take a target-PANE, where `=name` fails outright with `no\n * such session` and the exact form is `=name:` -- the empty window/pane part\n * resolving to the session's current pane.\n *\n * Neither rescues a name starting with `@`, `$` or `%`: those introduce tmux's\n * window, session and pane id syntax. remoteSessionName keeps them out.\n *\n * Both take a session NAME -- not a SessionId, which is why neither is branded.\n * `exactPaneTarget` is named for what it RETURNS, a tmux target-pane, because\n * what it takes and what it produces are different things and the old name\n * `exactPane` read as though it took a pane.\n */\nexport function exactSession(session: string): string {\n return `=${session}`;\n}\n\nexport function exactPaneTarget(session: string): string {\n return `=${session}:`;\n}\n\nexport function tmuxBadgeState(state: RenderState): string {\n // @agent_state is consumed by existing tmux configuration, whose public\n // vocabulary calls active work \"working\". Keep the internal activity named\n // \"running\" without forcing a coordinated config rollout.\n return state === \"running\" ? \"working\" : state;\n}\n\nexport const tmux: Mux = {\n currentWindow() {\n // $TMUX_PANE is the only trustworthy signal that we are inside a pane, and\n // it is set by tmux for every process in one.\n //\n // Asking tmux instead does not work: `display-message` answers from any\n // process on a machine with a running server, and reports whichever pane\n // that server considers active. A pi started outside tmux -- a bare ssh\n // login, a plain terminal, cron -- would then record itself as living in\n // some unrelated agent's pane and overwrite that agent's state. Falling\n // back to `display-message` here was exactly that bug.\n const raw = process.env.TMUX_PANE;\n if (!raw) return null;\n const pane = asPaneId(raw);\n\n // One call for ids and names together. The names travel with every row a\n // snapshot carries, because a reader cannot resolve a remote session or\n // window id against its own tmux.\n const fields = runTmux([\n \"display-message\",\n \"-t\",\n pane,\n \"-p\",\n \"#{session_id}\\t#{window_id}\\t#{session_name}\\t#{window_name}\",\n ]);\n const [session, window, sessionName, windowName] = fields?.split(\"\\t\") ?? [];\n if (!session || !window) return null;\n return {\n session: asSessionId(session),\n window: asWindowId(window),\n pane,\n session_name: sessionName || null,\n window_name: windowName || null,\n };\n },\n\n // Which of this host's PANES still exist. The only liveness question tmux is\n // ever asked, and the one that matches how an agent is addressed: a pane keeps\n // its id when it moves between windows, so a recorded window id can be gone\n // while the agent is very much alive.\n //\n // null means tmux could not answer; an empty set means it did and there are\n // none. Conflating the two would delete every agent on the host the moment\n // tmux was briefly unreachable.\n livePanes() {\n const out = runTmux([\"list-panes\", \"-a\", \"-F\", \"#{pane_id}\"]);\n if (out === null) return null;\n return new Set(out.split(\"\\n\").filter(Boolean).map(asPaneId));\n },\n\n setWindowBadge(window, state) {\n if (state === null) {\n runTmux([\"set-window-option\", \"-qu\", \"-t\", window, \"@agent_state\"]);\n } else {\n runTmux([\"set-window-option\", \"-q\", \"-t\", window, \"@agent_state\", tmuxBadgeState(state)]);\n runTmux([\"set-window-option\", \"-q\", \"-t\", window, \"@pane_agent\", \"1\"]);\n }\n runTmux([\"refresh-client\", \"-S\"]);\n },\n\n attach(session, window) {\n // Two steps, because switch-client alone is a no-op when the target window\n // is in the session you are already attached to — which is the common case\n // for a local agent, and why \"enter\" appeared to do nothing.\n // switch-client moves the client between sessions; select-window moves\n // that session to the right window.\n //\n // Only select-window decides the result. switch-client legitimately fails\n // when there is no client to switch (running outside tmux), and treating\n // that as a failed jump would report an error for a working attach.\n runTmux([\"switch-client\", \"-t\", session]);\n return runTmux([\"select-window\", \"-t\", window]) !== null;\n },\n\n // Sibling panes, for deciding whether an unowned pane may clear the window's\n // badge. A window holding an agent and a shell must not lose the badge when\n // you focus the shell.\n panesInWindow(window) {\n const out = runTmux([\"list-panes\", \"-t\", window, \"-F\", \"#{pane_id}\"]);\n return out?.split(\"\\n\").filter(Boolean).map(asPaneId) ?? [];\n },\n\n // Which client to send home when the remote attach exits. `switch-client`\n // with no -c moves whichever client tmux considers current, and `murmur pick`\n // usually runs in a popup -- a client of its own, which dies with the popup.\n // Naming the real client is what lets the return outlive the picker.\n clientName() {\n return runTmux([\"display-message\", \"-p\", \"#{client_name}\"]) || null;\n },\n\n // Where the jump started, as a switch-client target. Window-level, not just\n // the session: coming back to the right session but the wrong window is\n // still the wrong place. The window id is stable where its index is not,\n // since renumber-windows renumbers on every close.\n currentTarget() {\n return runTmux([\"display-message\", \"-p\", \"#{session_name}:#{window_id}\"]) || null;\n },\n\n // Whether a wrapper session for this host already exists. Deliberately not\n // returning an id: a session is addressed by name, so a `#{session_id}` would\n // only have to be turned back into one.\n sessionNamed(name) {\n const out = runTmux([\"list-sessions\", \"-F\", \"#{session_name}\"]);\n if (out === null) return false;\n return out.split(\"\\n\").includes(name);\n },\n\n newSession(name, command) {\n // Detached, because the caller sets the per-session options before showing\n // it. Creating it attached would paint one frame with the local status bar\n // up and the local prefix live, which is the flicker this design exists to\n // remove.\n return runTmux([\"new-session\", \"-d\", \"-s\", name, command]) !== null;\n },\n\n setSessionOption(session, option, value) {\n runTmux([\"set-option\", \"-t\", exactPaneTarget(session), option, value]);\n },\n\n switchClient(client, session) {\n const target = exactSession(session);\n const args = client\n ? [\"switch-client\", \"-c\", client, \"-t\", target]\n : [\"switch-client\", \"-t\", target];\n return runTmux(args) !== null;\n },\n\n // The window a pane belongs to, for a pane murmur holds no row for. Clearing\n // a badge is a tmux operation and does not require murmur to own the pane.\n windowForPane(pane) {\n const out = runTmux([\"display-message\", \"-t\", pane, \"-p\", \"#{window_id}\"]);\n return out ? asWindowId(out) : null;\n },\n\n capture(pane, lines) {\n const args = [\"capture-pane\", \"-p\", \"-t\", pane];\n if (lines !== undefined) args.push(\"-S\", `-${lines}`);\n return runTmux(args);\n },\n};\n\nexport function pidAlive(pid: number): boolean {\n try {\n process.kill(pid, 0);\n return true;\n } catch (error) {\n return (error as NodeJS.ErrnoException).code !== \"ESRCH\";\n }\n}\n","/**\n * tmux's three id kinds, kept apart by the type system.\n *\n * tmux itself is unambiguous about this and prints a sigil on every id --\n * `session=$25 window=@75 pane=%89` -- but they are all strings, so murmur\n * could and did pass one where another was meant. Twice, in shipped code: a\n * sweep keyed on window liveness deleted ten live agents, and a window cached\n * at extension startup badged the window a moved pane had left.\n *\n * An agent is addressed by its PANE, which keeps its id across `move-pane`,\n * `break-pane`, and a window closed and reopened. A session and a window are\n * only where that pane currently lives, and both may differ between two reports\n * from one agent. So the rule the brands enforce is:\n *\n * only a pane may decide whether an agent exists.\n *\n * Branding is a compile-time fiction: at runtime these are the same strings\n * tmux printed, which is what keeps the snapshot document and every stored row\n * byte-identical.\n */\n\ndeclare const brand: unique symbol;\n\n/** A tmux session id, `$N`. Mutable location. */\nexport type SessionId = string & { readonly [brand]: \"session\" };\n\n/** A tmux window id, `@N`. Mutable location -- never an agent's identity. */\nexport type WindowId = string & { readonly [brand]: \"window\" };\n\n/** A tmux pane id, `%N`. The agent's identity, stable for its whole life. */\nexport type PaneId = string & { readonly [brand]: \"pane\" };\n\n/*\n * The boundary. Every raw string that becomes an id passes through one of these\n * three, so the unsafe step is in one file and countable rather than scattered\n * as `as` at each call site.\n *\n * Deliberately not validating the sigil. These are called on tmux stdout, on\n * JSON off the wire, on sqlite rows and on argv, and a node that recorded an id\n * murmur does not recognise -- a future tmux, a different harness -- must still\n * round-trip it. Rejecting here would turn a naming change into a behaviour\n * change.\n */\n\nexport function asSessionId(raw: string): SessionId {\n return raw as SessionId;\n}\n\nexport function asWindowId(raw: string): WindowId {\n return raw as WindowId;\n}\n\nexport function asPaneId(raw: string): PaneId {\n return raw as PaneId;\n}\n","import type { Driver } from \"../types.js\";\n\n/**\n * THE THREE-EVENT DECISION TABLE. Read this before touching either function.\n *\n * pi fires three events murmur turns into state, and it fires them in a fixed\n * order that was verified at runtime against the shipped pi (0.84.3), not read\n * off the .d.ts:\n *\n * agent_start a run begins\n * agent_end that run's loop ended\n * agent_settled no retry, compaction or queued continuation will follow\n *\n * agent_end is NOT per turn -- `turn_start`/`turn_end` are. A three-tool-call\n * prompt fires one agent_start, three turn_end, one agent_end, one settled.\n * But agent_end CAN fire more than once per settle, because pi re-enters the\n * loop for a retry, a compaction, or a message queued by an agent_end handler,\n * and each re-entry emits its own agent_start first. Observed:\n *\n * start, end, start, end, settled (a queued continuation)\n *\n * So agent_start/agent_end always pair, and settled arrives exactly once, last,\n * ~60ms after the final agent_end.\n *\n * The two axes are independent. `agent_start` and `agent_end` write ACTIVITY\n * (running / stopped) and nothing else; `agent_settled` may raise ATTENTION and\n * never touches activity. Nothing resolves one against the other, so the table\n * is short:\n *\n * pane driver agent_start agent_end agent_settled\n * -------- ------------- ----------- --------- -----------------\n * focused human running stopped (nothing)\n * unfocused human running stopped attention: done\n * focused orchestrated running stopped (nothing)\n * unfocused orchestrated running stopped (nothing)\n *\n * Why each \"nothing\":\n *\n * FOCUSED. There is nothing to request -- the user is already looking at the\n * pane. Re-asserting attention at a human who is watching is the\n * badge-that-outlives-its-cause bug.\n *\n * ORCHESTRATED. A crew agent settling is not a human's problem: mu placed the\n * work and mu consumes the result. Raising attention here would put every\n * finishing worker into the status bar and un-hide those rows in the picker.\n *\n * Completion is `done`. `blocked` is never authored by an owner -- it comes only\n * from an external notifier -- and that split is what makes attention and\n * activity genuinely independent rather than two spellings of one enum.\n */\n\n/**\n * Whether `agent_settled` raises attention, and of which kind. Null means say\n * nothing.\n *\n * `\"done\" | null` is the whole range: an owner reports that it finished, and only\n * a notifier can report that someone is wanted.\n */\nexport function settledState(focused: boolean, muManaged: boolean): \"done\" | null {\n if (muManaged) return null;\n return focused ? null : \"done\";\n}\n\nexport function driverFromEnv(env: NodeJS.ProcessEnv): Driver {\n return env.MU_MANAGED_AGENT === \"1\" || env.MU_AGENT_NAME ? \"orchestrated\" : \"human\";\n}\n"],"mappings":"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","names":["execFileSync","focused","muManaged","execFileSync","state"]}
|
|
1
|
+
{"version":3,"sources":["../../src/extension/murmur-pi.ts","../../src/mux.ts","../../src/ids.ts","../../src/extension/decide.ts"],"sourcesContent":["import { execFileSync } from \"node:child_process\";\nimport { tmux } from \"../mux.js\";\nimport type { Store } from \"../store.js\";\nimport type { Activity, AgentMeta, Location } from \"../types.js\";\nimport { driverFromEnv, settledState } from \"./decide.js\";\nimport type { StoreModule } from \"./store-api.js\";\n\n// Declared here rather than imported: murmur must not depend on pi to build,\n// and this is the whole surface the extension touches. getSessionName is\n// optional because an older pi does not have it, and a missing method must\n// degrade to \"no name\" rather than break the extension.\n//\n// The five events murmur needs, and no `reason` on any of them. pi puts a reason\n// on session_shutdown (\"quit\" | \"reload\" | \"new\" | \"resume\" | \"fork\"), but the\n// correct response is the same for all five: release the agent, clear the badge,\n// drop the store handle. What differs is only whether anything follows, and\n// session_start answers that by firing.\ntype ExtensionAPI = {\n on(\n event: \"agent_start\" | \"agent_end\" | \"agent_settled\" | \"session_shutdown\" | \"session_start\",\n handler: () => void | Promise<void>,\n ): void;\n getSessionName?(): string | undefined;\n};\n\n// Where to import the store from.\n//\n// The bare specifier only resolves when murmur is a dependency of the importer,\n// which it never is: the extension is loaded from ~/.pi/agent/extensions, and a\n// globally linked or installed murmur is not resolvable from there. Unpinned,\n// the import throws, getStore swallows it, and every write silently no-ops\n// while the tmux badge still paints -- so nothing looks broken while the store\n// stays empty and the node exports nothing.\n//\n// Two ways it gets pinned, because there are two install shapes:\n//\n// $MURMUR_STORE_MODULE set by the shim `murmur link pi` writes, which is a\n// re-export of THIS file from the murmur install. The\n// shim cannot rewrite this constant (it does not copy\n// the source), so it passes the path instead.\n// link pi --copy inlines this file and rewrites the string literal.\nconst storeModule = process.env.MURMUR_STORE_MODULE || \"@martintrojer/murmur/extension-store\";\nconst muManaged = process.env.MU_MANAGED_AGENT === \"1\";\nconst driver = driverFromEnv(process.env);\n\nfunction focused(pane: string): boolean {\n try {\n return (\n execFileSync(\n \"tmux\",\n [\n \"display-message\",\n \"-t\",\n pane,\n \"-p\",\n \"#{&&:#{pane_active},#{&&:#{window_active},#{session_attached}}}\",\n ],\n { encoding: \"utf8\", timeout: 3000, stdio: [\"ignore\", \"pipe\", \"ignore\"] },\n ).trim() === \"1\"\n );\n } catch {\n return false;\n }\n}\n\n// pi.getSessionName() is a live read and a session can be unnamed, so this must\n// never be the reason a report is lost.\nfunction safeSessionName(pi: ExtensionAPI): string | null {\n try {\n return pi.getSessionName?.() || null;\n } catch {\n return null;\n }\n}\n\nexport default function murmurPi(pi: ExtensionAPI): void {\n const startLocation = tmux.currentWindow();\n if (!startLocation) return;\n\n /**\n * Where this agent is NOW, not where it started.\n *\n * A pane can be moved between windows -- `move-pane`, `break-pane`, or a\n * keybinding that wraps them -- and tmux keeps the pane id while the window\n * id changes. Resolving the window once at startup meant an agent that was\n * moved painted its badge on the window it used to live in and recorded a\n * stale location on every later write.\n *\n * The pane is the address and does not change, so the agent row stays put;\n * only the location is re-read. Falls back to the startup location if tmux\n * cannot answer, which keeps a transient failure from rewriting an agent's\n * address to nothing.\n */\n let lastWindow = startLocation.window;\n const here = (): Location => {\n const location = tmux.currentWindow() ?? startLocation;\n // A move leaves the badge behind on the window the agent used to be in,\n // where nothing else will ever clear it: the badge belongs to the window,\n // and the only process that knows this agent left is this one.\n if (location.window !== lastWindow) {\n try {\n tmux.setWindowBadge(lastWindow, null);\n } catch {\n // Best effort; the new window's badge matters more than the old one's.\n }\n lastWindow = location.window;\n }\n return location;\n };\n\n const meta = (): AgentMeta => ({\n // mu names its agents; pi names its sessions. Both beat a window name when\n // present, and neither can be recovered from tmux.\n agent_name: process.env.MU_AGENT_NAME ?? null,\n pi_session: safeSessionName(pi),\n workstream: process.env.MU_WORKSTREAM ?? null,\n role: process.env.MU_ROLE ?? null,\n cli: \"pi\",\n driver,\n });\n\n /**\n * One variable, three named states, so the combinations that must not exist\n * cannot be written down.\n *\n * This was a `Store | null | undefined` plus a separate `absent` boolean --\n * six combinations for three meanings -- and conflating two of them silenced\n * the extension for the life of the process: `null` meant both \"murmur is not\n * installed, stop trying\" and \"a write failed, let go of the handle\", so one\n * transient failure latched reporting off while the tmux badge still painted.\n *\n * Only `absent` is permanent, and only a failed import, a missing identity or\n * a REFUSED claim produces it. A dropped handle returns to `untried`, so the\n * next event reopens.\n */\n type StoreState = { kind: \"untried\" } | { kind: \"open\"; store: Store } | { kind: \"absent\" };\n let state: StoreState = { kind: \"untried\" };\n /**\n * This process is nested, permanently and unrecoverably.\n *\n * Separate from `absent`, which `session_start` re-arms: a missing murmur and\n * a missing identity are both fixable from outside a running pi, but a second\n * live process in one pane never becomes the owner. Re-arming that would let a\n * nested pi start reporting as the parent agent after the first /reload.\n */\n let refused = false;\n /** This process's agent row, for the life of the process. */\n let agentId: string | null = null;\n let queue: Promise<void> = Promise.resolve();\n\n const enqueue = (work: () => Promise<void>): Promise<void> => {\n queue = queue.then(work, work);\n return queue;\n };\n\n const dropStore = (): void => {\n if (state.kind === \"open\") {\n try {\n state.store.close();\n } catch {\n // Best effort: extension failures must never reach pi.\n }\n }\n state = { kind: \"untried\" };\n };\n\n /**\n * Open the store and claim the pane, in that order, once.\n *\n * `refused` is the nested-agent case, and it is permanent for this process: a\n * pi launched inside an agent's pane inherits $TMUX_PANE and would otherwise\n * report AS the parent agent. Six pids once wrote to one pane that way and the\n * parent read as idle while it was working. The claim's liveness probe answers\n * this with the database rather than with an environment marker a process\n * launched in an unusual way could drop -- and a refused caller registers\n * nothing, paints nothing, and says nothing.\n */\n const getStore = async (): Promise<Store | null> => {\n // Permanent: murmur is not installed, this node has no identity, or this\n // process is nested. None becomes false later in the same process, so\n // retrying would pay a failed dynamic import per turn forever.\n // `session_start` re-arms it, because the first two ARE fixable from\n // outside a running pi.\n if (state.kind === \"absent\") return null;\n if (refused) return null;\n if (state.kind === \"open\") return state.store;\n try {\n const { loadIdentity, openStore } = (await import(storeModule)) as StoreModule;\n // Read, never minted: an extension load must not bring a node into\n // existence.\n if (!loadIdentity()) {\n state = { kind: \"absent\" };\n return null;\n }\n const store = openStore();\n const claim = store.claimAgent({\n location: here(),\n owner_pid: process.pid,\n meta: meta(),\n });\n if (claim.outcome === \"refused\") {\n store.close();\n refused = true;\n state = { kind: \"absent\" };\n return null;\n }\n // `retained` is what makes /reload a no-op: pi re-runs this factory in the\n // same process, and the store recognises our own pid.\n agentId = claim.agent_id;\n state = { kind: \"open\", store };\n return store;\n } catch {\n state = { kind: \"absent\" };\n return null;\n }\n };\n\n /**\n * Report activity, and answer whether this process is still the owner.\n *\n * `setActivity` returning false is not an error and is not retried: it means\n * this process is no longer the owner of record, and the correct response is\n * silence.\n *\n * The boolean is what the badge is gated on. It has to be, because the badge\n * is the only part of a report a human sees directly: painting it before the\n * write is how a silently non-reporting extension looks healthy for the life\n * of a process. A window whose agent row belongs to someone else must not\n * carry this process's glyph.\n */\n const report = async (activity: Activity, location: Location): Promise<boolean> => {\n try {\n const store = await getStore();\n if (!store || !agentId) return false;\n return store.setActivity({ agent_id: agentId, owner_pid: process.pid, activity, location });\n } catch {\n dropStore();\n return false;\n }\n };\n\n /**\n * Claim the pane NOW, not on the first event.\n *\n * A nested process must paint no badge, and the badge is painted by the same\n * handler that reports -- so ownership has to be settled before any handler\n * can run.\n *\n * ONE DEVIATION FROM THE CONTRACT, stated because it is visible: §9.1 says a\n * refused process registers no handlers. It cannot, quite. The store arrives\n * through a dynamic `import()` of a path pinned at runtime, so the claim is\n * asynchronous, and pi's extension factory is not -- handlers must be attached\n * before the first `await` resolves or the extension misses events it does own.\n *\n * The observable behaviour is identical, which is what the contract is\n * actually about: the claim goes on the queue that already serialises every\n * handler, so each handler runs after it, and a refused process writes\n * nothing, paints nothing and holds no store handle. `refused` is checked in\n * both places that could act -- the badge and the store -- rather than being\n * relied on to be checked once.\n */\n void enqueue(async () => {\n await getStore();\n });\n\n /** Paint only if we own the pane. A nested agent is deliberately invisible. */\n const badge = (location: Location, state: \"running\" | null): void => {\n if (refused) return;\n tmux.setWindowBadge(location.window, state);\n };\n\n pi.on(\"agent_start\", () => {\n void enqueue(async () => {\n const location = here();\n // Ownership first, glyph second. A process whose pane was taken over\n // while its handle was dropped learns that from the claim inside\n // `report`, and a badge painted before it would announce an agent that\n // no longer lives in this window.\n if (await report(\"running\", location)) badge(location, \"running\");\n });\n });\n\n pi.on(\"agent_end\", () => {\n void enqueue(async () => {\n const location = here();\n // Clearing is safe whatever the answer -- it retracts this process's own\n // glyph and can only ever say less -- but it is still ordered after the\n // write so that both halves read the same ownership answer.\n await report(\"stopped\", location);\n badge(location, null);\n });\n });\n\n // The event that produces `done`. agent_end alone cannot express it: agent_end\n // fires when a run's loop ends, which is not the same as \"nothing more will\n // happen\" -- pi re-enters the loop for a retry, a compaction, or a queued\n // message, and each re-entry emits its own start/end pair. Only\n // `agent_settled` means finished and waiting. See the table in decide.ts.\n pi.on(\"agent_settled\", () => {\n void enqueue(async () => {\n const location = here();\n const settled = settledState(focused(location.pane), muManaged);\n if (settled === null) return;\n try {\n const store = await getStore();\n if (!store || refused) return;\n // Attention is pane-addressed, and this call structurally cannot name an\n // agent, a pid or an activity. Completion is `done`; `blocked` is never\n // authored by an owner.\n store.requestAttention({\n kind: settled,\n location,\n message: \"\",\n source: \"pi\",\n });\n tmux.setWindowBadge(location.window, settled);\n } catch {\n dropStore();\n }\n });\n });\n\n // `session_shutdown` does not mean \"the process is exiting\". pi fires it for\n // `/reload`, and for session switch, resume and fork, then rebinds and keeps\n // going -- its own docs say to clean up here and reestablish in\n // `session_start`. Treating it as terminal killed reporting permanently on\n // the first `/reload`.\n //\n // Releasing the agent deletes the row but deliberately NOT its attention: a\n // `done` raised at settle must survive the process quitting, or completion\n // becomes invisible the moment the agent exits.\n pi.on(\"session_shutdown\", async () => {\n await enqueue(async () => {\n const location = here();\n badge(location, null);\n try {\n if (state.kind === \"open\" && agentId) {\n state.store.releaseAgent({ agent_id: agentId, owner_pid: process.pid });\n }\n } catch {\n // The handle goes either way.\n }\n agentId = null;\n dropStore();\n });\n });\n\n // Reestablish, per pi's documented contract. A reload leaves this instance\n // live but with its store dropped and its cached location possibly wrong --\n // the pane can have moved while the session was being switched.\n pi.on(\"session_start\", () => {\n void enqueue(async () => {\n if (state.kind === \"absent\") state = { kind: \"untried\" };\n const location = here();\n lastWindow = location.window;\n // Re-claim NOW, not on the next agent event.\n //\n // `session_shutdown` released the agent row, so between it and this\n // handler the pane has no owner and `claimAgent` would refuse nobody. If\n // the re-claim waited for an agent event -- which may be minutes away, or\n // never, since /reload happens while the agent is idle -- a pi started in\n // this pane in the meantime claims it legitimately, and this process is\n // then refused permanently: silent for the rest of its life while its\n // badge still paints. pi fires session_start immediately after the\n // shutdown for exactly this reestablishment, which bounds the unowned\n // window to the gap between two synchronous handler calls.\n await getStore();\n });\n });\n}\n","import { execFileSync } from \"node:child_process\";\nimport {\n asPaneId,\n asSessionId,\n asWindowId,\n type PaneId,\n type SessionId,\n type WindowId,\n} from \"./ids.js\";\nimport type { Location } from \"./types.js\";\nimport type { RenderState } from \"./view.js\";\n\nexport interface Mux {\n currentWindow(): Location | null;\n livePanes(): Set<PaneId> | null;\n // Sets `@agent_state` on a WINDOW, even though the attention it expresses\n // belongs to a pane. The asymmetry is tmux's: the status bar and the `tms`\n // picker read a window option, and there is no per-pane equivalent they\n // would read instead. Its consequence is that a pane moving between windows\n // must clear the badge it left behind, since nothing else knows it moved.\n setWindowBadge(window: WindowId, state: RenderState | null): void;\n // Reports whether the attach actually happened. runTmux swallows failures to\n // return null, and a jump that silently failed looked exactly like \"enter did\n // nothing\" -- the symptom the remote probe was added to prevent, reproduced\n // on the local path.\n attach(session: SessionId, window: WindowId): boolean;\n windowForPane(pane: PaneId): WindowId | null;\n panesInWindow(window: WindowId): PaneId[];\n capture(pane: PaneId, lines?: number): string | null;\n // --- remote-jump session seam -------------------------------------------\n // A remote attach lives in its own local session rather than a window, so it\n // can be full-screen (no local status bar) and prefix-free (no nested ^b).\n // See jumpToAgent for why that is worth five extra methods.\n clientName(): string | null;\n currentTarget(): string | null;\n sessionNamed(name: string): boolean;\n newSession(name: string, command: string): boolean;\n setSessionOption(session: string, option: string, value: string): void;\n switchClient(client: string | null, session: string): boolean;\n}\n\nfunction runTmux(args: string[]): string | null {\n try {\n return execFileSync(\"tmux\", args, {\n encoding: \"utf8\",\n timeout: 3000,\n stdio: [\"ignore\", \"pipe\", \"ignore\"],\n }).trim();\n } catch {\n return null;\n }\n}\n\n/**\n * A session name as an exact target, in the two spellings tmux needs.\n *\n * Bare names match by PREFIX, so a wrapper for host `bub` silently retargets a\n * session called `bubba` once one exists -- verified, and it sets options on\n * the wrong session rather than failing. A leading `=` demands an exact match.\n * (`name=` is not the syntax; it reads as part of the name and matches nothing.)\n *\n * The trailing colon is the part that is easy to get wrong. `switch-client -t`\n * takes a target-SESSION, where `=name` is right, but `set-option -t` and\n * `show-options -t` take a target-PANE, where `=name` fails outright with `no\n * such session` and the exact form is `=name:` -- the empty window/pane part\n * resolving to the session's current pane.\n *\n * Neither rescues a name starting with `@`, `$` or `%`: those introduce tmux's\n * window, session and pane id syntax. remoteSessionName keeps them out.\n *\n * Both take a session NAME -- not a SessionId, which is why neither is branded.\n * `exactPaneTarget` is named for what it RETURNS, a tmux target-pane, because\n * what it takes and what it produces are different things and the old name\n * `exactPane` read as though it took a pane.\n */\n/**\n * The window name worth RECORDING, given tmux's own answer and whether tmux is\n * renaming that window itself.\n *\n * Null while `automatic-rename` is on -- which is tmux's DEFAULT -- because the\n * name is then just the foreground process. The picker's `agent` column showed\n * `Python`, `node` and `zsh` for real agents: pi's own interpreter, labelled\n * \"agent\".\n *\n * A name nobody chose is not a name, and recording it as one is worse than\n * recording nothing: `agentLabel` prefers the window over the session, so a\n * process name shadowed `hacking/murmur` -- the string the reader actually\n * searches on. Dropped at the point of RECORDING rather than at render, so\n * every surface, and every peer reading this node's snapshot, agrees on what\n * counts as a name.\n *\n * Split out of `currentWindow` to be testable: that method shells out to a real\n * tmux server, so the decision had no reachable seam and the format string was\n * the only thing a test could have asserted on.\n */\nexport function chosenWindowName(\n name: string | undefined,\n autoRename: string | undefined,\n): string | null {\n if (autoRename === \"1\") return null;\n return name || null;\n}\n\nexport function exactSession(session: string): string {\n return `=${session}`;\n}\n\nexport function exactPaneTarget(session: string): string {\n return `=${session}:`;\n}\n\nexport function tmuxBadgeState(state: RenderState): string {\n // @agent_state is consumed by existing tmux configuration, whose public\n // vocabulary calls active work \"working\". Keep the internal activity named\n // \"running\" without forcing a coordinated config rollout.\n return state === \"running\" ? \"working\" : state;\n}\n\nexport const tmux: Mux = {\n currentWindow() {\n // $TMUX_PANE is the only trustworthy signal that we are inside a pane, and\n // it is set by tmux for every process in one.\n //\n // Asking tmux instead does not work: `display-message` answers from any\n // process on a machine with a running server, and reports whichever pane\n // that server considers active. A pi started outside tmux -- a bare ssh\n // login, a plain terminal, cron -- would then record itself as living in\n // some unrelated agent's pane and overwrite that agent's state. Falling\n // back to `display-message` here was exactly that bug.\n const raw = process.env.TMUX_PANE;\n if (!raw) return null;\n const pane = asPaneId(raw);\n\n // One call for ids and names together. The names travel with every row a\n // snapshot carries, because a reader cannot resolve a remote session or\n // window id against its own tmux.\n const fields = runTmux([\n \"display-message\",\n \"-t\",\n pane,\n \"-p\",\n \"#{session_id}\\t#{window_id}\\t#{session_name}\\t#{window_name}\\t#{?automatic-rename,1,0}\",\n ]);\n const [session, window, sessionName, windowName, autoRename] = fields?.split(\"\\t\") ?? [];\n if (!session || !window) return null;\n return {\n session: asSessionId(session),\n window: asWindowId(window),\n pane,\n session_name: sessionName || null,\n window_name: chosenWindowName(windowName, autoRename),\n };\n },\n\n // Which of this host's PANES still exist. The only liveness question tmux is\n // ever asked, and the one that matches how an agent is addressed: a pane keeps\n // its id when it moves between windows, so a recorded window id can be gone\n // while the agent is very much alive.\n //\n // null means tmux could not answer; an empty set means it did and there are\n // none. Conflating the two would delete every agent on the host the moment\n // tmux was briefly unreachable.\n livePanes() {\n const out = runTmux([\"list-panes\", \"-a\", \"-F\", \"#{pane_id}\"]);\n if (out === null) return null;\n return new Set(out.split(\"\\n\").filter(Boolean).map(asPaneId));\n },\n\n setWindowBadge(window, state) {\n if (state === null) {\n runTmux([\"set-window-option\", \"-qu\", \"-t\", window, \"@agent_state\"]);\n } else {\n runTmux([\"set-window-option\", \"-q\", \"-t\", window, \"@agent_state\", tmuxBadgeState(state)]);\n runTmux([\"set-window-option\", \"-q\", \"-t\", window, \"@pane_agent\", \"1\"]);\n }\n runTmux([\"refresh-client\", \"-S\"]);\n },\n\n attach(session, window) {\n // Two steps, because switch-client alone is a no-op when the target window\n // is in the session you are already attached to — which is the common case\n // for a local agent, and why \"enter\" appeared to do nothing.\n // switch-client moves the client between sessions; select-window moves\n // that session to the right window.\n //\n // Only select-window decides the result. switch-client legitimately fails\n // when there is no client to switch (running outside tmux), and treating\n // that as a failed jump would report an error for a working attach.\n runTmux([\"switch-client\", \"-t\", session]);\n return runTmux([\"select-window\", \"-t\", window]) !== null;\n },\n\n // Sibling panes, for deciding whether an unowned pane may clear the window's\n // badge. A window holding an agent and a shell must not lose the badge when\n // you focus the shell.\n panesInWindow(window) {\n const out = runTmux([\"list-panes\", \"-t\", window, \"-F\", \"#{pane_id}\"]);\n return out?.split(\"\\n\").filter(Boolean).map(asPaneId) ?? [];\n },\n\n // Which client to send home when the remote attach exits. `switch-client`\n // with no -c moves whichever client tmux considers current, and `murmur pick`\n // usually runs in a popup -- a client of its own, which dies with the popup.\n // Naming the real client is what lets the return outlive the picker.\n clientName() {\n return runTmux([\"display-message\", \"-p\", \"#{client_name}\"]) || null;\n },\n\n // Where the jump started, as a switch-client target. Window-level, not just\n // the session: coming back to the right session but the wrong window is\n // still the wrong place. The window id is stable where its index is not,\n // since renumber-windows renumbers on every close.\n currentTarget() {\n return runTmux([\"display-message\", \"-p\", \"#{session_name}:#{window_id}\"]) || null;\n },\n\n // Whether a wrapper session for this host already exists. Deliberately not\n // returning an id: a session is addressed by name, so a `#{session_id}` would\n // only have to be turned back into one.\n sessionNamed(name) {\n const out = runTmux([\"list-sessions\", \"-F\", \"#{session_name}\"]);\n if (out === null) return false;\n return out.split(\"\\n\").includes(name);\n },\n\n newSession(name, command) {\n // Detached, because the caller sets the per-session options before showing\n // it. Creating it attached would paint one frame with the local status bar\n // up and the local prefix live, which is the flicker this design exists to\n // remove.\n return runTmux([\"new-session\", \"-d\", \"-s\", name, command]) !== null;\n },\n\n setSessionOption(session, option, value) {\n runTmux([\"set-option\", \"-t\", exactPaneTarget(session), option, value]);\n },\n\n switchClient(client, session) {\n const target = exactSession(session);\n const args = client\n ? [\"switch-client\", \"-c\", client, \"-t\", target]\n : [\"switch-client\", \"-t\", target];\n return runTmux(args) !== null;\n },\n\n // The window a pane belongs to, for a pane murmur holds no row for. Clearing\n // a badge is a tmux operation and does not require murmur to own the pane.\n windowForPane(pane) {\n const out = runTmux([\"display-message\", \"-t\", pane, \"-p\", \"#{window_id}\"]);\n return out ? asWindowId(out) : null;\n },\n\n capture(pane, lines) {\n const args = [\"capture-pane\", \"-p\", \"-t\", pane];\n if (lines !== undefined) args.push(\"-S\", `-${lines}`);\n return runTmux(args);\n },\n};\n\nexport function pidAlive(pid: number): boolean {\n try {\n process.kill(pid, 0);\n return true;\n } catch (error) {\n return (error as NodeJS.ErrnoException).code !== \"ESRCH\";\n }\n}\n","/**\n * tmux's three id kinds, kept apart by the type system.\n *\n * tmux itself is unambiguous about this and prints a sigil on every id --\n * `session=$25 window=@75 pane=%89` -- but they are all strings, so murmur\n * could and did pass one where another was meant. Twice, in shipped code: a\n * sweep keyed on window liveness deleted ten live agents, and a window cached\n * at extension startup badged the window a moved pane had left.\n *\n * An agent is addressed by its PANE, which keeps its id across `move-pane`,\n * `break-pane`, and a window closed and reopened. A session and a window are\n * only where that pane currently lives, and both may differ between two reports\n * from one agent. So the rule the brands enforce is:\n *\n * only a pane may decide whether an agent exists.\n *\n * Branding is a compile-time fiction: at runtime these are the same strings\n * tmux printed, which is what keeps the snapshot document and every stored row\n * byte-identical.\n */\n\ndeclare const brand: unique symbol;\n\n/** A tmux session id, `$N`. Mutable location. */\nexport type SessionId = string & { readonly [brand]: \"session\" };\n\n/** A tmux window id, `@N`. Mutable location -- never an agent's identity. */\nexport type WindowId = string & { readonly [brand]: \"window\" };\n\n/** A tmux pane id, `%N`. The agent's identity, stable for its whole life. */\nexport type PaneId = string & { readonly [brand]: \"pane\" };\n\n/*\n * The boundary. Every raw string that becomes an id passes through one of these\n * three, so the unsafe step is in one file and countable rather than scattered\n * as `as` at each call site.\n *\n * Deliberately not validating the sigil. These are called on tmux stdout, on\n * JSON off the wire, on sqlite rows and on argv, and a node that recorded an id\n * murmur does not recognise -- a future tmux, a different harness -- must still\n * round-trip it. Rejecting here would turn a naming change into a behaviour\n * change.\n */\n\nexport function asSessionId(raw: string): SessionId {\n return raw as SessionId;\n}\n\nexport function asWindowId(raw: string): WindowId {\n return raw as WindowId;\n}\n\nexport function asPaneId(raw: string): PaneId {\n return raw as PaneId;\n}\n","import type { Driver } from \"../types.js\";\n\n/**\n * THE THREE-EVENT DECISION TABLE. Read this before touching either function.\n *\n * pi fires three events murmur turns into state, and it fires them in a fixed\n * order that was verified at runtime against the shipped pi (0.84.3), not read\n * off the .d.ts:\n *\n * agent_start a run begins\n * agent_end that run's loop ended\n * agent_settled no retry, compaction or queued continuation will follow\n *\n * agent_end is NOT per turn -- `turn_start`/`turn_end` are. A three-tool-call\n * prompt fires one agent_start, three turn_end, one agent_end, one settled.\n * But agent_end CAN fire more than once per settle, because pi re-enters the\n * loop for a retry, a compaction, or a message queued by an agent_end handler,\n * and each re-entry emits its own agent_start first. Observed:\n *\n * start, end, start, end, settled (a queued continuation)\n *\n * So agent_start/agent_end always pair, and settled arrives exactly once, last,\n * ~60ms after the final agent_end.\n *\n * The two axes are independent. `agent_start` and `agent_end` write ACTIVITY\n * (running / stopped) and nothing else; `agent_settled` may raise ATTENTION and\n * never touches activity. Nothing resolves one against the other, so the table\n * is short:\n *\n * pane driver agent_start agent_end agent_settled\n * -------- ------------- ----------- --------- -----------------\n * focused human running stopped (nothing)\n * unfocused human running stopped attention: done\n * focused orchestrated running stopped (nothing)\n * unfocused orchestrated running stopped (nothing)\n *\n * Why each \"nothing\":\n *\n * FOCUSED. There is nothing to request -- the user is already looking at the\n * pane. Re-asserting attention at a human who is watching is the\n * badge-that-outlives-its-cause bug.\n *\n * ORCHESTRATED. A crew agent settling is not a human's problem: mu placed the\n * work and mu consumes the result. Raising attention here would put every\n * finishing worker into the status bar and un-hide those rows in the picker.\n *\n * Completion is `done`. `blocked` is never authored by an owner -- it comes only\n * from an external notifier -- and that split is what makes attention and\n * activity genuinely independent rather than two spellings of one enum.\n */\n\n/**\n * Whether `agent_settled` raises attention, and of which kind. Null means say\n * nothing.\n *\n * `\"done\" | null` is the whole range: an owner reports that it finished, and only\n * a notifier can report that someone is wanted.\n */\nexport function settledState(focused: boolean, muManaged: boolean): \"done\" | null {\n if (muManaged) return null;\n return focused ? null : \"done\";\n}\n\nexport function driverFromEnv(env: NodeJS.ProcessEnv): Driver {\n return env.MU_MANAGED_AGENT === \"1\" || env.MU_AGENT_NAME ? \"orchestrated\" : \"human\";\n}\n"],"mappings":"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","names":["execFileSync","focused","muManaged","execFileSync","state"]}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/identity.ts","../../src/paths.ts","../../src/store.ts","../../src/ids.ts","../../src/mux.ts","../../src/version.ts","../../src/view.ts"],"sourcesContent":["import { randomUUID } from \"node:crypto\";\nimport { existsSync, mkdirSync, readFileSync, writeFileSync } from \"node:fs\";\nimport { hostname } from \"node:os\";\nimport { join } from \"node:path\";\nimport { stateDir } from \"./paths.js\";\n\nexport type NodeIdentity = {\n host_id: string;\n display_name: string;\n};\n\nfunction identityPath(): string {\n return join(stateDir(), \"identity.json\");\n}\n\n/**\n * Memoized per process, keyed on the resolved path.\n *\n * `identity.json` cannot change under a running command, and the audit measured\n * eight redundant reads per invocation. Keyed on the path rather than a bare\n * boolean so a test that repoints `MURMUR_STATE_DIR` mid-process is not served\n * another directory's identity.\n */\nlet cache: { path: string; identity: NodeIdentity | null } | null = null;\n\n/**\n * This node's identity, or null when it has none.\n *\n * A READ, and only a read: nothing mints here. Every command that needs a\n * host_id fails with \"murmur is not initialised on this node; run: murmur init\"\n * rather than bringing a node into existence as a side effect of a status-bar\n * tick.\n */\nexport function loadIdentity(): NodeIdentity | null {\n const path = identityPath();\n if (cache?.path === path) return cache.identity;\n const identity = existsSync(path)\n ? (JSON.parse(readFileSync(path, \"utf8\")) as NodeIdentity)\n : null;\n cache = { path, identity };\n return identity;\n}\n\nfunction write(identity: NodeIdentity): NodeIdentity {\n mkdirSync(stateDir(), { recursive: true });\n writeFileSync(identityPath(), `${JSON.stringify(identity, null, 2)}\\n`);\n cache = { path: identityPath(), identity };\n return identity;\n}\n\n/** Create this node's identity. Only `murmur init` calls it. */\nexport function createIdentity(displayName = hostname()): NodeIdentity {\n if (loadIdentity()) throw new Error(`identity already exists: ${identityPath()}`);\n return write({ host_id: randomUUID(), display_name: displayName });\n}\n\n/**\n * Rename an existing node, keeping its `host_id`.\n *\n * `murmur init --name` on an already-initialised node used to ignore the flag\n * silently, which is the one thing a rename must not do.\n */\nexport function setDisplayName(displayName: string): NodeIdentity {\n const existing = loadIdentity();\n return write(\n existing\n ? { host_id: existing.host_id, display_name: displayName }\n : { host_id: randomUUID(), display_name: displayName },\n );\n}\n","import { homedir } from \"node:os\";\nimport { join } from \"node:path\";\n\nexport function stateDir(): string {\n return (\n process.env.MURMUR_STATE_DIR ??\n join(process.env.XDG_STATE_HOME ?? join(homedir(), \".local\", \"state\"), \"murmur\")\n );\n}\n\nexport function configDir(): string {\n return (\n process.env.MURMUR_CONFIG_DIR ??\n join(process.env.XDG_CONFIG_HOME ?? join(homedir(), \".config\"), \"murmur\")\n );\n}\n\n/** The current-state database. The only database murmur holds. */\nexport function dbPath(): string {\n return join(stateDir(), \"state.db\");\n}\n","import { randomUUID } from \"node:crypto\";\nimport { mkdirSync, rmSync } from \"node:fs\";\nimport { dirname } from \"node:path\";\nimport Database from \"better-sqlite3\";\nimport type { NodeIdentity } from \"./identity.js\";\nimport type { PaneId } from \"./ids.js\";\nimport { asPaneId, asSessionId, asWindowId } from \"./ids.js\";\nimport { pidAlive } from \"./mux.js\";\nimport { dbPath } from \"./paths.js\";\nimport type {\n ActivityUpdate,\n AgentClaim,\n AgentRelease,\n AttentionKind,\n AttentionRequest,\n ClaimResult,\n LocalWorld,\n PeerFetch,\n PeerRecord,\n ReconcileSummary,\n Snapshot,\n SnapshotAgent,\n SnapshotAttention,\n SnapshotPane,\n} from \"./types.js\";\nimport { MURMUR_VERSION } from \"./version.js\";\nimport { RENDER_PRIORITY } from \"./view.js\";\n\n/**\n * The storage version. Any change to any table bumps it.\n *\n * ONE version strategy: a mismatch salvages the peer names and targets a human\n * typed, deletes the file, and recreates the schema. No ALTER TABLE anywhere, so\n * there is no additive path to forget to use.\n */\nconst SCHEMA_USER_VERSION = 3;\n\nconst SCHEMA = `\n CREATE TABLE agents (\n agent_id TEXT NOT NULL PRIMARY KEY,\n pane TEXT NOT NULL UNIQUE,\n owner_pid INTEGER NOT NULL CHECK (owner_pid > 0),\n activity TEXT NOT NULL CHECK (activity IN ('running', 'stopped')),\n session TEXT NOT NULL,\n window TEXT NOT NULL,\n session_name TEXT,\n window_name TEXT,\n agent_name TEXT,\n pi_session TEXT,\n workstream TEXT,\n role TEXT,\n cli TEXT NOT NULL,\n driver TEXT NOT NULL CHECK (driver IN ('human', 'orchestrated')),\n claimed_at INTEGER NOT NULL,\n updated_at INTEGER NOT NULL\n ) STRICT;\n\n CREATE TABLE attention (\n pane TEXT NOT NULL,\n kind TEXT NOT NULL CHECK (kind IN ('done', 'blocked', 'crashed')),\n message TEXT NOT NULL,\n source TEXT NOT NULL,\n session TEXT NOT NULL,\n window TEXT NOT NULL,\n session_name TEXT,\n window_name TEXT,\n requested_at INTEGER NOT NULL,\n PRIMARY KEY (pane, kind)\n ) STRICT;\n\n CREATE TABLE peers (\n name TEXT NOT NULL PRIMARY KEY,\n target TEXT NOT NULL,\n host_id TEXT,\n display_name TEXT,\n snapshot TEXT,\n snapshot_at INTEGER,\n fetched_at INTEGER,\n last_attempt_at INTEGER,\n last_error TEXT,\n murmur_version TEXT,\n snapshot_version INTEGER\n ) STRICT;\n`;\n\n/**\n * The store, and the only place in murmur that holds a database handle or\n * writes SQL.\n *\n * This interface is CLOSED. There is no `append`, no `ingest`, no log read, no\n * partial-row update, and no local read other than `localPanes` — each of those\n * shapes let a writer say something it had no standing to say, and each cost a\n * shipped bug. Attention methods take no agent identity at all, which is what\n * makes \"a notifier cannot corrupt an agent row\" structural.\n */\nexport interface Store {\n // --- agent lifecycle: owner-only, pid-gated -----------------------------\n claimAgent(claim: AgentClaim): ClaimResult;\n setActivity(update: ActivityUpdate): boolean;\n releaseAgent(release: AgentRelease): boolean;\n\n // --- attention: pane-addressed, no agent authority ----------------------\n requestAttention(request: AttentionRequest): void;\n acknowledgePane(pane: PaneId): number;\n\n // --- local truth --------------------------------------------------------\n /** The one local read. Joins agents and attention by pane. No reconciliation. */\n localPanes(): SnapshotPane[];\n reconcileLocal(world: LocalWorld): ReconcileSummary;\n buildLocalSnapshot(identity: NodeIdentity, world: LocalWorld): Snapshot;\n\n // --- peer cache ---------------------------------------------------------\n peers(): PeerRecord[];\n addPeer(name: string, target: string): void;\n removePeer(name: string): boolean;\n replacePeerSnapshot(name: string, fetch: PeerFetch): void;\n\n close(): void;\n}\n\ntype AgentDbRow = {\n agent_id: string;\n pane: string;\n owner_pid: number;\n activity: string;\n session: string;\n window: string;\n session_name: string | null;\n window_name: string | null;\n agent_name: string | null;\n pi_session: string | null;\n workstream: string | null;\n role: string | null;\n cli: string;\n driver: string;\n claimed_at: number;\n updated_at: number;\n};\n\ntype AttentionDbRow = {\n pane: string;\n kind: string;\n message: string;\n source: string;\n session: string;\n window: string;\n session_name: string | null;\n window_name: string | null;\n requested_at: number;\n};\n\ntype PeerDbRow = {\n name: string;\n target: string;\n host_id: string | null;\n display_name: string | null;\n snapshot: string | null;\n snapshot_at: number | null;\n fetched_at: number | null;\n last_attempt_at: number | null;\n last_error: string | null;\n murmur_version: string | null;\n snapshot_version: number | null;\n};\n\n/** Peer names and targets: the two fields a human typed, and all we salvage. */\nfunction salvagePeers(path: string): { name: string; target: string }[] {\n try {\n const existing = new Database(path, { fileMustExist: true });\n try {\n const version = (existing.pragma(\"user_version\", { simple: true }) as number) ?? 0;\n if (version === SCHEMA_USER_VERSION) return [];\n return existing.prepare(\"SELECT name, target FROM peers\").all() as {\n name: string;\n target: string;\n }[];\n } catch {\n // Too old to have the table, or unreadable. Nothing to save.\n return [];\n } finally {\n existing.close();\n }\n } catch {\n // No database yet, or one too broken to open.\n return [];\n }\n}\n\nfunction needsReset(path: string): boolean {\n try {\n const existing = new Database(path, { fileMustExist: true });\n try {\n return (\n ((existing.pragma(\"user_version\", { simple: true }) as number) ?? 0) !== SCHEMA_USER_VERSION\n );\n } finally {\n existing.close();\n }\n } catch {\n return false;\n }\n}\n\nfunction toAttention(row: AttentionDbRow): SnapshotAttention {\n return {\n kind: row.kind as AttentionKind,\n message: row.message,\n source: row.source,\n requested_at: row.requested_at,\n };\n}\n\nfunction toAgent(row: AgentDbRow): SnapshotAgent {\n return {\n agent_id: row.agent_id,\n activity: row.activity as SnapshotAgent[\"activity\"],\n agent_name: row.agent_name,\n pi_session: row.pi_session,\n workstream: row.workstream,\n role: row.role,\n cli: row.cli,\n driver: row.driver as SnapshotAgent[\"driver\"],\n claimed_at: row.claimed_at,\n updated_at: row.updated_at,\n };\n}\n\nconst PRIORITY = new Map<string, number>(RENDER_PRIORITY.map((kind, index) => [kind, index]));\n\nfunction attentionOrder(left: SnapshotAttention, right: SnapshotAttention): number {\n return (PRIORITY.get(left.kind) ?? 99) - (PRIORITY.get(right.kind) ?? 99);\n}\n\n/**\n * Open the store. Takes no arguments and mints no identity.\n *\n * `openStore` deliberately does NOT read or create `identity.json`: identity is\n * created only by `murmur init`, so a read path — a status-bar tick, a focus\n * hook — cannot bring a node into existence as a side effect.\n */\nexport function openStore(): Store {\n const path = dbPath();\n mkdirSync(dirname(path), { recursive: true });\n\n const salvaged = salvagePeers(path);\n if (needsReset(path)) {\n for (const suffix of [\"\", \"-wal\", \"-shm\"]) rmSync(`${path}${suffix}`, { force: true });\n }\n\n const database = new Database(path);\n database.pragma(\"journal_mode = WAL\");\n database.pragma(\"busy_timeout = 5000\");\n const version = (database.pragma(\"user_version\", { simple: true }) as number) ?? 0;\n if (version !== SCHEMA_USER_VERSION) {\n database.exec(SCHEMA);\n database.pragma(`user_version = ${SCHEMA_USER_VERSION}`);\n // Re-inserted with every OBSERVED column null: a salvaged peer has no\n // snapshot and has never been fetched, and saying otherwise would render a\n // never-reached host as fresh.\n const restore = database.prepare(\"INSERT OR IGNORE INTO peers (name, target) VALUES (?, ?)\");\n for (const peer of salvaged) restore.run(peer.name, peer.target);\n }\n\n const selectAgentByPane = database.prepare(\"SELECT * FROM agents WHERE pane = ?\");\n const insertAgent = database.prepare(`\n INSERT INTO agents (agent_id, pane, owner_pid, activity, session, window,\n session_name, window_name, agent_name, pi_session,\n workstream, role, cli, driver, claimed_at, updated_at)\n VALUES (@agent_id, @pane, @owner_pid, @activity, @session, @window,\n @session_name, @window_name, @agent_name, @pi_session,\n @workstream, @role, @cli, @driver, @claimed_at, @updated_at)\n `);\n const retainAgent = database.prepare(`\n UPDATE agents\n SET session = @session, window = @window, session_name = @session_name,\n window_name = @window_name, agent_name = @agent_name,\n pi_session = @pi_session, workstream = @workstream, role = @role,\n cli = @cli, driver = @driver, updated_at = @updated_at\n WHERE agent_id = @agent_id\n `);\n const deleteAgentByPane = database.prepare(\"DELETE FROM agents WHERE pane = ?\");\n const deleteAttentionForPane = database.prepare(\"DELETE FROM attention WHERE pane = ?\");\n const updateActivity = database.prepare(`\n UPDATE agents\n SET activity = @activity, session = @session, window = @window,\n session_name = @session_name, window_name = @window_name,\n updated_at = @updated_at\n WHERE agent_id = @agent_id AND owner_pid = @owner_pid\n `);\n const deleteAgentOwned = database.prepare(\n \"DELETE FROM agents WHERE agent_id = ? AND owner_pid = ?\",\n );\n const upsertAttention = database.prepare(`\n INSERT INTO attention (pane, kind, message, source, session, window,\n session_name, window_name, requested_at)\n VALUES (@pane, @kind, @message, @source, @session, @window,\n @session_name, @window_name, @requested_at)\n ON CONFLICT (pane, kind) DO UPDATE SET\n message = excluded.message,\n source = excluded.source,\n session = excluded.session,\n window = excluded.window,\n session_name = excluded.session_name,\n window_name = excluded.window_name\n `);\n const selectAgents = database.prepare(\"SELECT * FROM agents\");\n const selectAttention = database.prepare(\"SELECT * FROM attention\");\n const setActivityByPane = database.prepare(\n \"UPDATE agents SET activity = ?, updated_at = ? WHERE pane = ?\",\n );\n\n /**\n * `.immediate`, not deferred, and this is load-bearing.\n *\n * The transaction reads the incumbent row and then writes, so a deferred one\n * starts as a READER and must upgrade. Two doing that at once fails the loser\n * with SQLITE_BUSY_SNAPSHOT, which no busy_timeout can fix: waiting longer\n * cannot make a stale snapshot fresh. Measured previously at 5 of 8\n * concurrent writers failing.\n */\n const claimAgent = database.transaction((claim: AgentClaim): ClaimResult => {\n const now = claim.now ?? Date.now();\n const isAlive = claim.isAlive ?? pidAlive;\n const { location, meta, owner_pid } = claim;\n const incumbent = selectAgentByPane.get(location.pane) as AgentDbRow | undefined;\n\n const values = {\n pane: location.pane,\n owner_pid,\n session: location.session,\n window: location.window,\n session_name: location.session_name,\n window_name: location.window_name,\n agent_name: meta.agent_name,\n pi_session: meta.pi_session,\n workstream: meta.workstream,\n role: meta.role,\n cli: meta.cli,\n driver: meta.driver,\n updated_at: now,\n };\n\n if (!incumbent) {\n const agentId = randomUUID();\n insertAgent.run({ ...values, agent_id: agentId, activity: \"stopped\", claimed_at: now });\n return { outcome: \"claimed\", agent_id: agentId };\n }\n\n // Our own claim, seen again. This is what makes pi's `/reload` a no-op: pi\n // re-runs the extension factory in the same process, and a check that could\n // not recognise its own claim would silence the real agent. `activity` and\n // `agent_id` are deliberately untouched.\n if (incumbent.owner_pid === owner_pid) {\n retainAgent.run({ ...values, agent_id: incumbent.agent_id });\n return { outcome: \"retained\", agent_id: incumbent.agent_id };\n }\n\n // A different LIVE process in one pane: the nested-agent case, and the only\n // answer for it. Fails closed — `pidAlive` reports death only on ESRCH, so\n // an unanswerable probe (EPERM) reads as alive and refuses. An unknown must\n // never let a second writer displace a possibly-live owner.\n if (isAlive(incumbent.owner_pid)) {\n return { outcome: \"refused\", held_by_pid: incumbent.owner_pid };\n }\n\n // The previous occupant is gone. Its attention described a process that no\n // longer exists, and a human looking at the pane now sees a different agent.\n deleteAgentByPane.run(location.pane);\n deleteAttentionForPane.run(location.pane);\n const agentId = randomUUID();\n insertAgent.run({ ...values, agent_id: agentId, activity: \"stopped\", claimed_at: now });\n return { outcome: \"replaced\", agent_id: agentId, previous_agent_id: incumbent.agent_id };\n }).immediate;\n\n /**\n * One transaction, because the `stopped` write and its `crashed` attention row\n * must land together or not at all.\n *\n * A no-op when tmux could not answer: `panes === null` is absence of evidence,\n * not evidence of death, and conflating the two once deleted ten live agents.\n */\n const reconcileLocal = database.transaction((world: LocalWorld): ReconcileSummary => {\n const summary: ReconcileSummary = { crashed: [], removed: [], attention_removed: [] };\n if (world.panes === null) return summary;\n const live = world.panes;\n const isAlive = world.isAlive ?? pidAlive;\n const now = world.now ?? Date.now();\n\n // Which panes already carry a crash we recorded. Read once, before any\n // write, so the loop below sees the state reconciliation started from.\n const alreadyCrashed = new Set(\n (selectAttention.all() as AttentionDbRow[])\n .filter((row) => row.kind === \"crashed\")\n .map((row) => row.pane),\n );\n\n for (const row of selectAgents.all() as AgentDbRow[]) {\n const pane = asPaneId(row.pane);\n if (!live.has(pane)) {\n deleteAgentByPane.run(row.pane);\n deleteAttentionForPane.run(row.pane);\n summary.removed.push(pane);\n continue;\n }\n if (isAlive(row.owner_pid)) continue;\n\n // The asymmetry below is the point. A dead RUNNING owner is an unreported\n // crash and must leave a durable trace. A dead STOPPED owner finished\n // normally, so its row is noise — but any `done` it raised is a fact a\n // human has not yet seen, so the attention stays.\n if (row.activity === \"running\") {\n setActivityByPane.run(\"stopped\", now, row.pane);\n upsertAttention.run({\n pane: row.pane,\n kind: \"crashed\",\n message: \"\",\n source: \"murmur\",\n session: row.session,\n window: row.window,\n session_name: row.session_name,\n window_name: row.window_name,\n requested_at: now,\n });\n summary.crashed.push(pane);\n } else if (!alreadyCrashed.has(row.pane)) {\n deleteAgentByPane.run(row.pane);\n summary.removed.push(pane);\n }\n // A pane we already recorded a crash for keeps its agent row, and that is\n // the one place this deviates from a literal reading of the contract's\n // table -- which says a live pane with a dead STOPPED owner loses its row.\n // Taken literally, the second reconcile after a crash deletes the row the\n // first one had just marked `stopped`, so the crashed pane loses its\n // agent_name, workstream, role and cli one tick after the crash is\n // reported. That contradicts the contract's own idempotence requirement\n // (\"running it again changes nothing\") and it strips exactly the fields a\n // human needs to know WHICH agent died.\n //\n // The distinction the table is drawing is between an owner that finished\n // normally -- whose row is noise -- and one that died mid-run. The\n // `crashed` row we wrote is the record of which case this was, so it is\n // also the right thing to key on.\n }\n\n // Reaps attention for a pane that never had an agent row — an\n // attention-only codex pane whose window was closed. Nothing else would.\n for (const row of selectAttention.all() as AttentionDbRow[]) {\n const pane = asPaneId(row.pane);\n if (live.has(pane)) continue;\n deleteAttentionForPane.run(row.pane);\n if (!summary.attention_removed.includes(pane)) summary.attention_removed.push(pane);\n }\n\n return summary;\n }).immediate;\n\n /**\n * Both tables read at ONE point in time, or a pane can appear with an agent\n * and without the attention that was there when the agent was read.\n */\n const readLocalPanes = database.transaction((): SnapshotPane[] => {\n const agents = selectAgents.all() as AgentDbRow[];\n const attention = selectAttention.all() as AttentionDbRow[];\n const panes = new Map<string, SnapshotPane>();\n\n const locate = (row: AgentDbRow | AttentionDbRow): SnapshotPane => {\n const existing = panes.get(row.pane);\n if (existing) return existing;\n const created: SnapshotPane = {\n pane: asPaneId(row.pane),\n session: asSessionId(row.session),\n window: asWindowId(row.window),\n session_name: row.session_name,\n window_name: row.window_name,\n agent: null,\n attention: [],\n };\n panes.set(row.pane, created);\n return created;\n };\n\n for (const row of agents) locate(row).agent = toAgent(row);\n for (const row of attention) locate(row).attention.push(toAttention(row));\n\n for (const pane of panes.values()) pane.attention.sort(attentionOrder);\n return [...panes.values()].sort((left, right) => left.pane.localeCompare(right.pane));\n });\n\n function peerRecord(row: PeerDbRow): PeerRecord {\n let snapshot: Snapshot | null = null;\n if (row.snapshot !== null) {\n try {\n // Parsed leniently on the way OUT: it was validated on the way in, and\n // a read path must not throw. A stored document that no longer parses\n // reads as \"no snapshot\" and is left in place, not deleted.\n snapshot = JSON.parse(row.snapshot) as Snapshot;\n } catch {\n snapshot = null;\n }\n }\n return {\n name: row.name,\n target: row.target,\n host_id: row.host_id,\n display_name: row.display_name,\n snapshot,\n snapshot_at: row.snapshot_at,\n fetched_at: row.fetched_at,\n last_attempt_at: row.last_attempt_at,\n last_error: row.last_error,\n murmur_version: row.murmur_version,\n snapshot_version: row.snapshot_version,\n };\n }\n\n return {\n claimAgent,\n reconcileLocal,\n\n setActivity(update) {\n // Both key components are required, so a write from a REPLACED owner\n // matches nothing and returns false. That is not an error and must not be\n // retried: it means this process is no longer the owner of record, and the\n // correct response is silence.\n return (\n updateActivity.run({\n activity: update.activity,\n session: update.location.session,\n window: update.location.window,\n session_name: update.location.session_name,\n window_name: update.location.window_name,\n updated_at: update.now ?? Date.now(),\n agent_id: update.agent_id,\n owner_pid: update.owner_pid,\n }).changes === 1\n );\n },\n\n releaseAgent(release) {\n // Attention is deliberately NOT deleted: a `done` raised at settle must\n // survive the agent exiting, or completion becomes invisible the moment\n // the process quits.\n return deleteAgentOwned.run(release.agent_id, release.owner_pid).changes === 1;\n },\n\n requestAttention(request) {\n // `requested_at` is absent from the DO UPDATE list on purpose. Age means\n // \"how long this has gone unmet\", so a repeat must not reset the clock —\n // which also makes crash attention idempotent for free. Touches no\n // `agents` row, ever; there is no column here that could.\n upsertAttention.run({\n pane: request.location.pane,\n kind: request.kind,\n message: request.message,\n source: request.source,\n session: request.location.session,\n window: request.location.window,\n session_name: request.location.session_name,\n window_name: request.location.window_name,\n requested_at: request.now ?? Date.now(),\n });\n },\n\n acknowledgePane(pane) {\n // Every kind, one statement, no agent row touched: focusing a pane cannot\n // alter activity or owner metadata. This is the whole `murmur clear`\n // write path.\n return deleteAttentionForPane.run(pane).changes;\n },\n\n localPanes() {\n return readLocalPanes();\n },\n\n buildLocalSnapshot(identity, world) {\n // Reconcile first, which is what makes \"a snapshot is authoritative\"\n // true: absence from a successful snapshot means absence, so it must\n // never be produced from unreconciled rows. Two transactions rather than\n // one — a write transaction held open across the read would serialise\n // every focus hook on the machine behind an export.\n reconcileLocal(world);\n return {\n murmur_snapshot: 1,\n host_id: identity.host_id,\n display_name: identity.display_name,\n murmur_version: MURMUR_VERSION,\n generated_at: world.now ?? Date.now(),\n // Rule 3: a pane with no agent and no attention must not be published.\n // A no-op against today's `readLocalPanes`, which builds a pane entry\n // only from a row and so cannot produce an empty one -- kept because the\n // rule belongs to the DOCUMENT, and the validator rejects such an entry\n // outright. Without it, one narrowing of the local read would make this\n // node reachable-but-broken on every peer that collects it, and the\n // symptom would show up on the other machines.\n panes: readLocalPanes().filter((pane) => pane.agent !== null || pane.attention.length > 0),\n };\n },\n\n peers() {\n return (database.prepare(\"SELECT * FROM peers ORDER BY name\").all() as PeerDbRow[]).map(\n peerRecord,\n );\n },\n\n addPeer(name, target) {\n // Correcting a target must not discard the cache, so this updates only\n // the field the operator retyped.\n database\n .prepare(\n `INSERT INTO peers (name, target) VALUES (?, ?)\n ON CONFLICT(name) DO UPDATE SET target = excluded.target`,\n )\n .run(name, target);\n },\n\n removePeer(name) {\n return database.prepare(\"DELETE FROM peers WHERE name = ?\").run(name).changes > 0;\n },\n\n replacePeerSnapshot(name, fetch) {\n if (!fetch.ok) {\n // Failure touches neither snapshot, snapshot_at nor fetched_at, so the\n // last-known document stands and the peer ages into `stale` on its own.\n database\n .prepare(\"UPDATE peers SET last_attempt_at = ?, last_error = ? WHERE name = ?\")\n .run(fetch.at, fetch.error, name);\n return;\n }\n // Two clocks, and conflating them is how a freshly fetched three-hour-old\n // fact reads as new. `snapshot_at` is the PEER's clock (when it built the\n // document); `fetched_at` is OURS (when we reached it), and freshness is\n // computed from `fetched_at` only.\n database\n .prepare(\n `UPDATE peers\n SET snapshot = ?, snapshot_at = ?, fetched_at = ?, last_attempt_at = ?,\n last_error = NULL, host_id = ?, display_name = ?,\n murmur_version = ?, snapshot_version = ?\n WHERE name = ?`,\n )\n .run(\n JSON.stringify(fetch.snapshot),\n fetch.snapshot.generated_at,\n fetch.at,\n fetch.at,\n fetch.snapshot.host_id,\n fetch.snapshot.display_name,\n fetch.snapshot.murmur_version,\n fetch.snapshot.murmur_snapshot,\n name,\n );\n },\n\n close() {\n database.close();\n },\n };\n}\n","/**\n * tmux's three id kinds, kept apart by the type system.\n *\n * tmux itself is unambiguous about this and prints a sigil on every id --\n * `session=$25 window=@75 pane=%89` -- but they are all strings, so murmur\n * could and did pass one where another was meant. Twice, in shipped code: a\n * sweep keyed on window liveness deleted ten live agents, and a window cached\n * at extension startup badged the window a moved pane had left.\n *\n * An agent is addressed by its PANE, which keeps its id across `move-pane`,\n * `break-pane`, and a window closed and reopened. A session and a window are\n * only where that pane currently lives, and both may differ between two reports\n * from one agent. So the rule the brands enforce is:\n *\n * only a pane may decide whether an agent exists.\n *\n * Branding is a compile-time fiction: at runtime these are the same strings\n * tmux printed, which is what keeps the snapshot document and every stored row\n * byte-identical.\n */\n\ndeclare const brand: unique symbol;\n\n/** A tmux session id, `$N`. Mutable location. */\nexport type SessionId = string & { readonly [brand]: \"session\" };\n\n/** A tmux window id, `@N`. Mutable location -- never an agent's identity. */\nexport type WindowId = string & { readonly [brand]: \"window\" };\n\n/** A tmux pane id, `%N`. The agent's identity, stable for its whole life. */\nexport type PaneId = string & { readonly [brand]: \"pane\" };\n\n/*\n * The boundary. Every raw string that becomes an id passes through one of these\n * three, so the unsafe step is in one file and countable rather than scattered\n * as `as` at each call site.\n *\n * Deliberately not validating the sigil. These are called on tmux stdout, on\n * JSON off the wire, on sqlite rows and on argv, and a node that recorded an id\n * murmur does not recognise -- a future tmux, a different harness -- must still\n * round-trip it. Rejecting here would turn a naming change into a behaviour\n * change.\n */\n\nexport function asSessionId(raw: string): SessionId {\n return raw as SessionId;\n}\n\nexport function asWindowId(raw: string): WindowId {\n return raw as WindowId;\n}\n\nexport function asPaneId(raw: string): PaneId {\n return raw as PaneId;\n}\n","import { execFileSync } from \"node:child_process\";\nimport {\n asPaneId,\n asSessionId,\n asWindowId,\n type PaneId,\n type SessionId,\n type WindowId,\n} from \"./ids.js\";\nimport type { Location } from \"./types.js\";\nimport type { RenderState } from \"./view.js\";\n\nexport interface Mux {\n currentWindow(): Location | null;\n livePanes(): Set<PaneId> | null;\n // Sets `@agent_state` on a WINDOW, even though the attention it expresses\n // belongs to a pane. The asymmetry is tmux's: the status bar and the `tms`\n // picker read a window option, and there is no per-pane equivalent they\n // would read instead. Its consequence is that a pane moving between windows\n // must clear the badge it left behind, since nothing else knows it moved.\n setWindowBadge(window: WindowId, state: RenderState | null): void;\n // Reports whether the attach actually happened. runTmux swallows failures to\n // return null, and a jump that silently failed looked exactly like \"enter did\n // nothing\" -- the symptom the remote probe was added to prevent, reproduced\n // on the local path.\n attach(session: SessionId, window: WindowId): boolean;\n windowForPane(pane: PaneId): WindowId | null;\n panesInWindow(window: WindowId): PaneId[];\n capture(pane: PaneId, lines?: number): string | null;\n // --- remote-jump session seam -------------------------------------------\n // A remote attach lives in its own local session rather than a window, so it\n // can be full-screen (no local status bar) and prefix-free (no nested ^b).\n // See jumpToAgent for why that is worth five extra methods.\n clientName(): string | null;\n currentTarget(): string | null;\n sessionNamed(name: string): boolean;\n newSession(name: string, command: string): boolean;\n setSessionOption(session: string, option: string, value: string): void;\n switchClient(client: string | null, session: string): boolean;\n}\n\nfunction runTmux(args: string[]): string | null {\n try {\n return execFileSync(\"tmux\", args, {\n encoding: \"utf8\",\n timeout: 3000,\n stdio: [\"ignore\", \"pipe\", \"ignore\"],\n }).trim();\n } catch {\n return null;\n }\n}\n\n/**\n * A session name as an exact target, in the two spellings tmux needs.\n *\n * Bare names match by PREFIX, so a wrapper for host `bub` silently retargets a\n * session called `bubba` once one exists -- verified, and it sets options on\n * the wrong session rather than failing. A leading `=` demands an exact match.\n * (`name=` is not the syntax; it reads as part of the name and matches nothing.)\n *\n * The trailing colon is the part that is easy to get wrong. `switch-client -t`\n * takes a target-SESSION, where `=name` is right, but `set-option -t` and\n * `show-options -t` take a target-PANE, where `=name` fails outright with `no\n * such session` and the exact form is `=name:` -- the empty window/pane part\n * resolving to the session's current pane.\n *\n * Neither rescues a name starting with `@`, `$` or `%`: those introduce tmux's\n * window, session and pane id syntax. remoteSessionName keeps them out.\n *\n * Both take a session NAME -- not a SessionId, which is why neither is branded.\n * `exactPaneTarget` is named for what it RETURNS, a tmux target-pane, because\n * what it takes and what it produces are different things and the old name\n * `exactPane` read as though it took a pane.\n */\nexport function exactSession(session: string): string {\n return `=${session}`;\n}\n\nexport function exactPaneTarget(session: string): string {\n return `=${session}:`;\n}\n\nexport function tmuxBadgeState(state: RenderState): string {\n // @agent_state is consumed by existing tmux configuration, whose public\n // vocabulary calls active work \"working\". Keep the internal activity named\n // \"running\" without forcing a coordinated config rollout.\n return state === \"running\" ? \"working\" : state;\n}\n\nexport const tmux: Mux = {\n currentWindow() {\n // $TMUX_PANE is the only trustworthy signal that we are inside a pane, and\n // it is set by tmux for every process in one.\n //\n // Asking tmux instead does not work: `display-message` answers from any\n // process on a machine with a running server, and reports whichever pane\n // that server considers active. A pi started outside tmux -- a bare ssh\n // login, a plain terminal, cron -- would then record itself as living in\n // some unrelated agent's pane and overwrite that agent's state. Falling\n // back to `display-message` here was exactly that bug.\n const raw = process.env.TMUX_PANE;\n if (!raw) return null;\n const pane = asPaneId(raw);\n\n // One call for ids and names together. The names travel with every row a\n // snapshot carries, because a reader cannot resolve a remote session or\n // window id against its own tmux.\n const fields = runTmux([\n \"display-message\",\n \"-t\",\n pane,\n \"-p\",\n \"#{session_id}\\t#{window_id}\\t#{session_name}\\t#{window_name}\",\n ]);\n const [session, window, sessionName, windowName] = fields?.split(\"\\t\") ?? [];\n if (!session || !window) return null;\n return {\n session: asSessionId(session),\n window: asWindowId(window),\n pane,\n session_name: sessionName || null,\n window_name: windowName || null,\n };\n },\n\n // Which of this host's PANES still exist. The only liveness question tmux is\n // ever asked, and the one that matches how an agent is addressed: a pane keeps\n // its id when it moves between windows, so a recorded window id can be gone\n // while the agent is very much alive.\n //\n // null means tmux could not answer; an empty set means it did and there are\n // none. Conflating the two would delete every agent on the host the moment\n // tmux was briefly unreachable.\n livePanes() {\n const out = runTmux([\"list-panes\", \"-a\", \"-F\", \"#{pane_id}\"]);\n if (out === null) return null;\n return new Set(out.split(\"\\n\").filter(Boolean).map(asPaneId));\n },\n\n setWindowBadge(window, state) {\n if (state === null) {\n runTmux([\"set-window-option\", \"-qu\", \"-t\", window, \"@agent_state\"]);\n } else {\n runTmux([\"set-window-option\", \"-q\", \"-t\", window, \"@agent_state\", tmuxBadgeState(state)]);\n runTmux([\"set-window-option\", \"-q\", \"-t\", window, \"@pane_agent\", \"1\"]);\n }\n runTmux([\"refresh-client\", \"-S\"]);\n },\n\n attach(session, window) {\n // Two steps, because switch-client alone is a no-op when the target window\n // is in the session you are already attached to — which is the common case\n // for a local agent, and why \"enter\" appeared to do nothing.\n // switch-client moves the client between sessions; select-window moves\n // that session to the right window.\n //\n // Only select-window decides the result. switch-client legitimately fails\n // when there is no client to switch (running outside tmux), and treating\n // that as a failed jump would report an error for a working attach.\n runTmux([\"switch-client\", \"-t\", session]);\n return runTmux([\"select-window\", \"-t\", window]) !== null;\n },\n\n // Sibling panes, for deciding whether an unowned pane may clear the window's\n // badge. A window holding an agent and a shell must not lose the badge when\n // you focus the shell.\n panesInWindow(window) {\n const out = runTmux([\"list-panes\", \"-t\", window, \"-F\", \"#{pane_id}\"]);\n return out?.split(\"\\n\").filter(Boolean).map(asPaneId) ?? [];\n },\n\n // Which client to send home when the remote attach exits. `switch-client`\n // with no -c moves whichever client tmux considers current, and `murmur pick`\n // usually runs in a popup -- a client of its own, which dies with the popup.\n // Naming the real client is what lets the return outlive the picker.\n clientName() {\n return runTmux([\"display-message\", \"-p\", \"#{client_name}\"]) || null;\n },\n\n // Where the jump started, as a switch-client target. Window-level, not just\n // the session: coming back to the right session but the wrong window is\n // still the wrong place. The window id is stable where its index is not,\n // since renumber-windows renumbers on every close.\n currentTarget() {\n return runTmux([\"display-message\", \"-p\", \"#{session_name}:#{window_id}\"]) || null;\n },\n\n // Whether a wrapper session for this host already exists. Deliberately not\n // returning an id: a session is addressed by name, so a `#{session_id}` would\n // only have to be turned back into one.\n sessionNamed(name) {\n const out = runTmux([\"list-sessions\", \"-F\", \"#{session_name}\"]);\n if (out === null) return false;\n return out.split(\"\\n\").includes(name);\n },\n\n newSession(name, command) {\n // Detached, because the caller sets the per-session options before showing\n // it. Creating it attached would paint one frame with the local status bar\n // up and the local prefix live, which is the flicker this design exists to\n // remove.\n return runTmux([\"new-session\", \"-d\", \"-s\", name, command]) !== null;\n },\n\n setSessionOption(session, option, value) {\n runTmux([\"set-option\", \"-t\", exactPaneTarget(session), option, value]);\n },\n\n switchClient(client, session) {\n const target = exactSession(session);\n const args = client\n ? [\"switch-client\", \"-c\", client, \"-t\", target]\n : [\"switch-client\", \"-t\", target];\n return runTmux(args) !== null;\n },\n\n // The window a pane belongs to, for a pane murmur holds no row for. Clearing\n // a badge is a tmux operation and does not require murmur to own the pane.\n windowForPane(pane) {\n const out = runTmux([\"display-message\", \"-t\", pane, \"-p\", \"#{window_id}\"]);\n return out ? asWindowId(out) : null;\n },\n\n capture(pane, lines) {\n const args = [\"capture-pane\", \"-p\", \"-t\", pane];\n if (lines !== undefined) args.push(\"-S\", `-${lines}`);\n return runTmux(args);\n },\n};\n\nexport function pidAlive(pid: number): boolean {\n try {\n process.kill(pid, 0);\n return true;\n } catch (error) {\n return (error as NodeJS.ErrnoException).code !== \"ESRCH\";\n }\n}\n","import { createRequire } from \"node:module\";\n\n/**\n * This node's murmur version, read from the manifest.\n *\n * Read rather than restated, for the reason index.ts already gives: two copies\n * of one fact drift, and npm bumps the manifest. It lives in its own module\n * because THREE bundles need it and they sit at different depths --\n * `dist/index.js`, `dist/cli.js` and `dist/extension/store.js` -- so a single\n * hardcoded `\"../package.json\"` resolves in two of them and throws in the third.\n *\n * That is not hypothetical. `openStore` moved into the extension bundle during\n * the current-state rewrite, and its `../package.json` became\n * `dist/package.json`, which does not exist. The extension catches every store\n * failure and degrades to silence, so the symptom was an agent that reported\n * nothing at all, with no error anywhere -- exactly the failure mode the\n * three-state store handle exists to make survivable, hiding a hard one.\n *\n * Hence both candidates, tried in order, and a throw if neither works: a version\n * this node cannot state belongs in a snapshot even less than a wrong one does.\n */\nfunction readVersion(): string {\n const require = createRequire(import.meta.url);\n for (const candidate of [\"../package.json\", \"../../package.json\"]) {\n try {\n return (require(candidate) as { version: string }).version;\n } catch {\n // Wrong depth for this bundle; try the next.\n }\n }\n throw new Error(\"cannot locate package.json to read the murmur version\");\n}\n\nexport const MURMUR_VERSION: string = readVersion();\n","import type { NodeIdentity } from \"./identity.js\";\nimport type { PaneId, SessionId, WindowId } from \"./ids.js\";\nimport type { Store } from \"./store.js\";\nimport {\n type Activity,\n type AttentionKind,\n DEFAULT_DRIVER,\n type Driver,\n type SnapshotPane,\n} from \"./types.js\";\n\nexport type Freshness = \"fresh\" | \"stale\";\n\n/**\n * What a surface paints. Presentation only, derived from the three independent\n * facts and never stored.\n */\nexport type RenderState = \"crashed\" | \"blocked\" | \"done\" | \"running\" | \"idle\";\n\n/**\n * THE single ordering table: which state matters most, for sorting and for\n * choosing one word to show.\n *\n * `status.ts` and `pick.ts` import this rather than declaring their own copies,\n * so no two surfaces can sort one list differently.\n */\nexport const RENDER_PRIORITY: readonly RenderState[] = [\n \"crashed\",\n \"blocked\",\n \"done\",\n \"running\",\n \"idle\",\n];\n\n/**\n * The attention kinds only a human can answer, and the second table both\n * surfaces must agree on.\n *\n * `blocked` means waiting for an answer an orchestrator cannot give -- mu places\n * work, it cannot choose between two approaches. `crashed` means the process\n * died, which a supervisor may or may not retry. Everything else about an\n * orchestrated agent is its supervisor's business.\n *\n * `pick.ts` uses it to decide which crew rows are visible by default and\n * `status.ts` to decide which crew states reach the status bar. They were two\n * literals in two files answering one question, which is how a row that needed a\n * human became one a human could not see.\n */\nexport const NEEDS_HUMAN: readonly AttentionKind[] = [\"blocked\", \"crashed\"];\n\n/**\n * One pane, as every surface reads it: address, the three independent facts,\n * owner metadata, and ages.\n *\n * Local and remote panes are the same type, built by the same mapping, because\n * `Store.localPanes()` and a peer's cached snapshot both return\n * `SnapshotPane[]`. One mapping means local and remote cannot drift apart.\n */\nexport type PaneView = {\n // address\n host_id: string;\n /** The name the operator typed, or this node's display_name. */\n host: string;\n local: boolean;\n pane: PaneId;\n session: SessionId;\n window: WindowId;\n session_name: string | null;\n window_name: string | null;\n // the three independent facts\n /** Null for an attention-only pane, which has no agent row. */\n activity: Activity | null;\n attention: AttentionKind[];\n freshness: Freshness;\n // owner-reported metadata, null for an attention-only pane\n agent_id: string | null;\n agent_name: string | null;\n pi_session: string | null;\n workstream: string | null;\n role: string | null;\n cli: string | null;\n driver: Driver;\n // ages\n /** When the pane's own node last said something. Never `fetched_at`. */\n updated_at: number | null;\n /** When that node generated its snapshot. Null for local. */\n snapshot_at: number | null;\n /** When we last reached that node. Null for local. */\n fetched_at: number | null;\n};\n\n/**\n * How long a peer may go unfetched before its panes render stale.\n *\n * Re-exported from here rather than imported from the collector by view\n * consumers, so freshness has one definition. See collector.ts for why sixty\n * seconds.\n */\nexport const STALENESS_MS = 60_000;\n\n/**\n * A duration as the shortest thing worth reading: \"5m\", \"2h\", \"3d\".\n *\n * Under a minute is the empty string: an age that changes every second is noise\n * in a status column. This and `freshness` are the only two places a duration\n * becomes text or a verdict.\n */\nexport function age(ms: number | null): string {\n if (ms === null || ms < 60_000) return \"\";\n if (ms < 3_600_000) return `${Math.floor(ms / 60_000)}m`;\n if (ms < 86_400_000) return `${Math.floor(ms / 3_600_000)}h`;\n return `${Math.floor(ms / 86_400_000)}d`;\n}\n\n/**\n * Freshness of a NODE, never of an agent.\n *\n * A peer we have never reached is stale rather than fresh: null means the first\n * collect has not succeeded yet, and an unreachable host you just added must not\n * render as up to date.\n */\nexport function freshness(\n fetchedAt: number | null,\n now: number,\n thresholdMs = STALENESS_MS,\n): Freshness {\n return fetchedAt !== null && now - fetchedAt <= thresholdMs ? \"fresh\" : \"stale\";\n}\n\n/**\n * One word for a pane. Attention wins over activity, because attention is a\n * request and activity is a description.\n *\n * A running agent with `blocked` attention is a valid and expected state, and\n * surfaces that can show both, do — this is only for the ones that must pick.\n */\nexport function renderState(view: Pick<PaneView, \"activity\" | \"attention\">): RenderState {\n for (const kind of [\"crashed\", \"blocked\", \"done\"] as const) {\n if (view.attention.includes(kind)) return kind;\n }\n return view.activity === \"running\" ? \"running\" : \"idle\";\n}\n\n/** The newest attention request on a pane, for the `updated_at` of one with no agent. */\nfunction newestAttention(pane: SnapshotPane): number | null {\n let newest: number | null = null;\n for (const entry of pane.attention) {\n if (newest === null || entry.requested_at > newest) newest = entry.requested_at;\n }\n return newest;\n}\n\ntype ViewSource = {\n host_id: string;\n host: string;\n local: boolean;\n freshness: Freshness;\n snapshot_at: number | null;\n fetched_at: number | null;\n};\n\nfunction paneView(pane: SnapshotPane, source: ViewSource): PaneView {\n const agent = pane.agent;\n return {\n host_id: source.host_id,\n host: source.host,\n local: source.local,\n pane: pane.pane,\n session: pane.session,\n window: pane.window,\n session_name: pane.session_name,\n window_name: pane.window_name,\n activity: agent?.activity ?? null,\n attention: pane.attention.map((entry) => entry.kind),\n freshness: source.freshness,\n agent_id: agent?.agent_id ?? null,\n agent_name: agent?.agent_name ?? null,\n pi_session: agent?.pi_session ?? null,\n workstream: agent?.workstream ?? null,\n role: agent?.role ?? null,\n cli: agent?.cli ?? null,\n driver: agent?.driver ?? DEFAULT_DRIVER,\n updated_at: agent?.updated_at ?? newestAttention(pane),\n snapshot_at: source.snapshot_at,\n fetched_at: source.fetched_at,\n };\n}\n\n/**\n * Every pane this node knows about: its own, plus one cached snapshot per peer.\n *\n * `identity` is non-null because every caller is a command that already requires\n * `murmur init`, so no pane can be misclassified as remote by an absent one.\n *\n * No liveness is probed here, for local or remote. A remote pane's `activity` is\n * whatever its own node last said; a stale node keeps its last-known fields\n * verbatim beside an explicit warning.\n */\nexport function paneViews(store: Store, identity: NodeIdentity, now = Date.now()): PaneView[] {\n const views = store.localPanes().map((pane) =>\n paneView(pane, {\n host_id: identity.host_id,\n host: identity.display_name,\n local: true,\n // Local panes are always fresh: we are the node that authored them.\n freshness: \"fresh\",\n snapshot_at: null,\n fetched_at: null,\n }),\n );\n\n for (const peer of store.peers()) {\n const snapshot = peer.snapshot;\n if (!snapshot) continue;\n const source: ViewSource = {\n host_id: snapshot.host_id,\n // The name the human typed, not the machine's self-reported hostname: a\n // peer added as `linuxpc` can report a container id, which appears\n // nowhere else in the tool and cannot be typed at `peer remove`.\n host: peer.name,\n local: false,\n freshness: freshness(peer.fetched_at, now),\n snapshot_at: peer.snapshot_at,\n fetched_at: peer.fetched_at,\n };\n for (const pane of snapshot.panes) views.push(paneView(pane, source));\n }\n\n return views;\n}\n\nconst ORDER = new Map<RenderState, number>(RENDER_PRIORITY.map((state, index) => [state, index]));\n\n/**\n * Attention-first ordering, then the newest news, then address.\n *\n * TOTAL on purpose, and that is the whole reason the last two comparisons\n * exist. Ties on state and age are ordinary rather than exotic -- a pair of\n * crashed panes reconciled in one transaction shares a `requested_at` exactly --\n * and `Array.prototype.sort` is stable only with respect to the order it was\n * GIVEN, which here is whatever SQLite and the peer loop happened to produce. An\n * unbroken tie therefore makes the list depend on that order: a status bar\n * reshuffles between two identical ticks, and a picker row moves under the\n * keypress that was aimed at it.\n *\n * Presentation only. No caller may read meaning into the position of a row --\n * pane order in a snapshot carries none either, so a reader sorts for itself\n * rather than trusting what it was served.\n */\nexport function viewSort(views: PaneView[]): PaneView[] {\n return [...views].sort((left, right) => {\n const byState = (ORDER.get(renderState(left)) ?? 99) - (ORDER.get(renderState(right)) ?? 99);\n if (byState !== 0) return byState;\n // Unknown age sorts last within its state: an attention-only pane with no\n // timestamp is not news, and 0 is older than any real clock reading.\n const byAge = (right.updated_at ?? 0) - (left.updated_at ?? 0);\n if (byAge !== 0) return byAge;\n // Address as the final key, because it is the only field guaranteed unique\n // across the whole view: `pane` is unique per node and `host` per peer.\n const byHost = left.host.localeCompare(right.host);\n return byHost !== 0 ? byHost : left.pane.localeCompare(right.pane);\n });\n}\n"],"mappings":";AAAA,SAAS,kBAAkB;AAC3B,SAAS,YAAY,WAAW,cAAc,qBAAqB;AACnE,SAAS,gBAAgB;AACzB,SAAS,QAAAA,aAAY;;;ACHrB,SAAS,eAAe;AACxB,SAAS,YAAY;AAEd,SAAS,WAAmB;AACjC,SACE,QAAQ,IAAI,oBACZ,KAAK,QAAQ,IAAI,kBAAkB,KAAK,QAAQ,GAAG,UAAU,OAAO,GAAG,QAAQ;AAEnF;AAUO,SAAS,SAAiB;AAC/B,SAAO,KAAK,SAAS,GAAG,UAAU;AACpC;;;ADTA,SAAS,eAAuB;AAC9B,SAAOC,MAAK,SAAS,GAAG,eAAe;AACzC;AAUA,IAAI,QAAgE;AAU7D,SAAS,eAAoC;AAClD,QAAM,OAAO,aAAa;AAC1B,MAAI,OAAO,SAAS,KAAM,QAAO,MAAM;AACvC,QAAM,WAAW,WAAW,IAAI,IAC3B,KAAK,MAAM,aAAa,MAAM,MAAM,CAAC,IACtC;AACJ,UAAQ,EAAE,MAAM,SAAS;AACzB,SAAO;AACT;;;AEzCA,SAAS,cAAAC,mBAAkB;AAC3B,SAAS,aAAAC,YAAW,cAAc;AAClC,SAAS,eAAe;AACxB,OAAO,cAAc;;;ACyCd,SAAS,YAAY,KAAwB;AAClD,SAAO;AACT;AAEO,SAAS,WAAW,KAAuB;AAChD,SAAO;AACT;AAEO,SAAS,SAAS,KAAqB;AAC5C,SAAO;AACT;;;ACtDA,SAAS,oBAAoB;AAuOtB,SAAS,SAAS,KAAsB;AAC7C,MAAI;AACF,YAAQ,KAAK,KAAK,CAAC;AACnB,WAAO;AAAA,EACT,SAAS,OAAO;AACd,WAAQ,MAAgC,SAAS;AAAA,EACnD;AACF;;;AC9OA,SAAS,qBAAqB;AAqB9B,SAAS,cAAsB;AAC7B,QAAMC,WAAU,cAAc,YAAY,GAAG;AAC7C,aAAW,aAAa,CAAC,mBAAmB,oBAAoB,GAAG;AACjE,QAAI;AACF,aAAQA,SAAQ,SAAS,EAA0B;AAAA,IACrD,QAAQ;AAAA,IAER;AAAA,EACF;AACA,QAAM,IAAI,MAAM,uDAAuD;AACzE;AAEO,IAAM,iBAAyB,YAAY;;;ACP3C,IAAM,kBAA0C;AAAA,EACrD;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAuMA,IAAM,QAAQ,IAAI,IAAyB,gBAAgB,IAAI,CAAC,OAAO,UAAU,CAAC,OAAO,KAAK,CAAC,CAAC;;;AJpMhG,IAAM,sBAAsB;AAE5B,IAAM,SAAS;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAiIf,SAAS,aAAa,MAAkD;AACtE,MAAI;AACF,UAAM,WAAW,IAAI,SAAS,MAAM,EAAE,eAAe,KAAK,CAAC;AAC3D,QAAI;AACF,YAAM,UAAW,SAAS,OAAO,gBAAgB,EAAE,QAAQ,KAAK,CAAC,KAAgB;AACjF,UAAI,YAAY,oBAAqB,QAAO,CAAC;AAC7C,aAAO,SAAS,QAAQ,gCAAgC,EAAE,IAAI;AAAA,IAIhE,QAAQ;AAEN,aAAO,CAAC;AAAA,IACV,UAAE;AACA,eAAS,MAAM;AAAA,IACjB;AAAA,EACF,QAAQ;AAEN,WAAO,CAAC;AAAA,EACV;AACF;AAEA,SAAS,WAAW,MAAuB;AACzC,MAAI;AACF,UAAM,WAAW,IAAI,SAAS,MAAM,EAAE,eAAe,KAAK,CAAC;AAC3D,QAAI;AACF,cACI,SAAS,OAAO,gBAAgB,EAAE,QAAQ,KAAK,CAAC,KAAgB,OAAO;AAAA,IAE7E,UAAE;AACA,eAAS,MAAM;AAAA,IACjB;AAAA,EACF,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEA,SAAS,YAAY,KAAwC;AAC3D,SAAO;AAAA,IACL,MAAM,IAAI;AAAA,IACV,SAAS,IAAI;AAAA,IACb,QAAQ,IAAI;AAAA,IACZ,cAAc,IAAI;AAAA,EACpB;AACF;AAEA,SAAS,QAAQ,KAAgC;AAC/C,SAAO;AAAA,IACL,UAAU,IAAI;AAAA,IACd,UAAU,IAAI;AAAA,IACd,YAAY,IAAI;AAAA,IAChB,YAAY,IAAI;AAAA,IAChB,YAAY,IAAI;AAAA,IAChB,MAAM,IAAI;AAAA,IACV,KAAK,IAAI;AAAA,IACT,QAAQ,IAAI;AAAA,IACZ,YAAY,IAAI;AAAA,IAChB,YAAY,IAAI;AAAA,EAClB;AACF;AAEA,IAAM,WAAW,IAAI,IAAoB,gBAAgB,IAAI,CAAC,MAAM,UAAU,CAAC,MAAM,KAAK,CAAC,CAAC;AAE5F,SAAS,eAAe,MAAyB,OAAkC;AACjF,UAAQ,SAAS,IAAI,KAAK,IAAI,KAAK,OAAO,SAAS,IAAI,MAAM,IAAI,KAAK;AACxE;AASO,SAAS,YAAmB;AACjC,QAAM,OAAO,OAAO;AACpB,EAAAC,WAAU,QAAQ,IAAI,GAAG,EAAE,WAAW,KAAK,CAAC;AAE5C,QAAM,WAAW,aAAa,IAAI;AAClC,MAAI,WAAW,IAAI,GAAG;AACpB,eAAW,UAAU,CAAC,IAAI,QAAQ,MAAM,EAAG,QAAO,GAAG,IAAI,GAAG,MAAM,IAAI,EAAE,OAAO,KAAK,CAAC;AAAA,EACvF;AAEA,QAAM,WAAW,IAAI,SAAS,IAAI;AAClC,WAAS,OAAO,oBAAoB;AACpC,WAAS,OAAO,qBAAqB;AACrC,QAAM,UAAW,SAAS,OAAO,gBAAgB,EAAE,QAAQ,KAAK,CAAC,KAAgB;AACjF,MAAI,YAAY,qBAAqB;AACnC,aAAS,KAAK,MAAM;AACpB,aAAS,OAAO,kBAAkB,mBAAmB,EAAE;AAIvD,UAAM,UAAU,SAAS,QAAQ,0DAA0D;AAC3F,eAAW,QAAQ,SAAU,SAAQ,IAAI,KAAK,MAAM,KAAK,MAAM;AAAA,EACjE;AAEA,QAAM,oBAAoB,SAAS,QAAQ,qCAAqC;AAChF,QAAM,cAAc,SAAS,QAAQ;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,GAOpC;AACD,QAAM,cAAc,SAAS,QAAQ;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,GAOpC;AACD,QAAM,oBAAoB,SAAS,QAAQ,mCAAmC;AAC9E,QAAM,yBAAyB,SAAS,QAAQ,sCAAsC;AACtF,QAAM,iBAAiB,SAAS,QAAQ;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,GAMvC;AACD,QAAM,mBAAmB,SAAS;AAAA,IAChC;AAAA,EACF;AACA,QAAM,kBAAkB,SAAS,QAAQ;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,GAYxC;AACD,QAAM,eAAe,SAAS,QAAQ,sBAAsB;AAC5D,QAAM,kBAAkB,SAAS,QAAQ,yBAAyB;AAClE,QAAM,oBAAoB,SAAS;AAAA,IACjC;AAAA,EACF;AAWA,QAAM,aAAa,SAAS,YAAY,CAAC,UAAmC;AAC1E,UAAM,MAAM,MAAM,OAAO,KAAK,IAAI;AAClC,UAAM,UAAU,MAAM,WAAW;AACjC,UAAM,EAAE,UAAU,MAAM,UAAU,IAAI;AACtC,UAAM,YAAY,kBAAkB,IAAI,SAAS,IAAI;AAErD,UAAM,SAAS;AAAA,MACb,MAAM,SAAS;AAAA,MACf;AAAA,MACA,SAAS,SAAS;AAAA,MAClB,QAAQ,SAAS;AAAA,MACjB,cAAc,SAAS;AAAA,MACvB,aAAa,SAAS;AAAA,MACtB,YAAY,KAAK;AAAA,MACjB,YAAY,KAAK;AAAA,MACjB,YAAY,KAAK;AAAA,MACjB,MAAM,KAAK;AAAA,MACX,KAAK,KAAK;AAAA,MACV,QAAQ,KAAK;AAAA,MACb,YAAY;AAAA,IACd;AAEA,QAAI,CAAC,WAAW;AACd,YAAMC,WAAUC,YAAW;AAC3B,kBAAY,IAAI,EAAE,GAAG,QAAQ,UAAUD,UAAS,UAAU,WAAW,YAAY,IAAI,CAAC;AACtF,aAAO,EAAE,SAAS,WAAW,UAAUA,SAAQ;AAAA,IACjD;AAMA,QAAI,UAAU,cAAc,WAAW;AACrC,kBAAY,IAAI,EAAE,GAAG,QAAQ,UAAU,UAAU,SAAS,CAAC;AAC3D,aAAO,EAAE,SAAS,YAAY,UAAU,UAAU,SAAS;AAAA,IAC7D;AAMA,QAAI,QAAQ,UAAU,SAAS,GAAG;AAChC,aAAO,EAAE,SAAS,WAAW,aAAa,UAAU,UAAU;AAAA,IAChE;AAIA,sBAAkB,IAAI,SAAS,IAAI;AACnC,2BAAuB,IAAI,SAAS,IAAI;AACxC,UAAM,UAAUC,YAAW;AAC3B,gBAAY,IAAI,EAAE,GAAG,QAAQ,UAAU,SAAS,UAAU,WAAW,YAAY,IAAI,CAAC;AACtF,WAAO,EAAE,SAAS,YAAY,UAAU,SAAS,mBAAmB,UAAU,SAAS;AAAA,EACzF,CAAC,EAAE;AASH,QAAM,iBAAiB,SAAS,YAAY,CAAC,UAAwC;AACnF,UAAM,UAA4B,EAAE,SAAS,CAAC,GAAG,SAAS,CAAC,GAAG,mBAAmB,CAAC,EAAE;AACpF,QAAI,MAAM,UAAU,KAAM,QAAO;AACjC,UAAM,OAAO,MAAM;AACnB,UAAM,UAAU,MAAM,WAAW;AACjC,UAAM,MAAM,MAAM,OAAO,KAAK,IAAI;AAIlC,UAAM,iBAAiB,IAAI;AAAA,MACxB,gBAAgB,IAAI,EAClB,OAAO,CAAC,QAAQ,IAAI,SAAS,SAAS,EACtC,IAAI,CAAC,QAAQ,IAAI,IAAI;AAAA,IAC1B;AAEA,eAAW,OAAO,aAAa,IAAI,GAAmB;AACpD,YAAM,OAAO,SAAS,IAAI,IAAI;AAC9B,UAAI,CAAC,KAAK,IAAI,IAAI,GAAG;AACnB,0BAAkB,IAAI,IAAI,IAAI;AAC9B,+BAAuB,IAAI,IAAI,IAAI;AACnC,gBAAQ,QAAQ,KAAK,IAAI;AACzB;AAAA,MACF;AACA,UAAI,QAAQ,IAAI,SAAS,EAAG;AAM5B,UAAI,IAAI,aAAa,WAAW;AAC9B,0BAAkB,IAAI,WAAW,KAAK,IAAI,IAAI;AAC9C,wBAAgB,IAAI;AAAA,UAClB,MAAM,IAAI;AAAA,UACV,MAAM;AAAA,UACN,SAAS;AAAA,UACT,QAAQ;AAAA,UACR,SAAS,IAAI;AAAA,UACb,QAAQ,IAAI;AAAA,UACZ,cAAc,IAAI;AAAA,UAClB,aAAa,IAAI;AAAA,UACjB,cAAc;AAAA,QAChB,CAAC;AACD,gBAAQ,QAAQ,KAAK,IAAI;AAAA,MAC3B,WAAW,CAAC,eAAe,IAAI,IAAI,IAAI,GAAG;AACxC,0BAAkB,IAAI,IAAI,IAAI;AAC9B,gBAAQ,QAAQ,KAAK,IAAI;AAAA,MAC3B;AAAA,IAeF;AAIA,eAAW,OAAO,gBAAgB,IAAI,GAAuB;AAC3D,YAAM,OAAO,SAAS,IAAI,IAAI;AAC9B,UAAI,KAAK,IAAI,IAAI,EAAG;AACpB,6BAAuB,IAAI,IAAI,IAAI;AACnC,UAAI,CAAC,QAAQ,kBAAkB,SAAS,IAAI,EAAG,SAAQ,kBAAkB,KAAK,IAAI;AAAA,IACpF;AAEA,WAAO;AAAA,EACT,CAAC,EAAE;AAMH,QAAM,iBAAiB,SAAS,YAAY,MAAsB;AAChE,UAAM,SAAS,aAAa,IAAI;AAChC,UAAM,YAAY,gBAAgB,IAAI;AACtC,UAAM,QAAQ,oBAAI,IAA0B;AAE5C,UAAM,SAAS,CAAC,QAAmD;AACjE,YAAM,WAAW,MAAM,IAAI,IAAI,IAAI;AACnC,UAAI,SAAU,QAAO;AACrB,YAAM,UAAwB;AAAA,QAC5B,MAAM,SAAS,IAAI,IAAI;AAAA,QACvB,SAAS,YAAY,IAAI,OAAO;AAAA,QAChC,QAAQ,WAAW,IAAI,MAAM;AAAA,QAC7B,cAAc,IAAI;AAAA,QAClB,aAAa,IAAI;AAAA,QACjB,OAAO;AAAA,QACP,WAAW,CAAC;AAAA,MACd;AACA,YAAM,IAAI,IAAI,MAAM,OAAO;AAC3B,aAAO;AAAA,IACT;AAEA,eAAW,OAAO,OAAQ,QAAO,GAAG,EAAE,QAAQ,QAAQ,GAAG;AACzD,eAAW,OAAO,UAAW,QAAO,GAAG,EAAE,UAAU,KAAK,YAAY,GAAG,CAAC;AAExE,eAAW,QAAQ,MAAM,OAAO,EAAG,MAAK,UAAU,KAAK,cAAc;AACrE,WAAO,CAAC,GAAG,MAAM,OAAO,CAAC,EAAE,KAAK,CAAC,MAAM,UAAU,KAAK,KAAK,cAAc,MAAM,IAAI,CAAC;AAAA,EACtF,CAAC;AAED,WAAS,WAAW,KAA4B;AAC9C,QAAI,WAA4B;AAChC,QAAI,IAAI,aAAa,MAAM;AACzB,UAAI;AAIF,mBAAW,KAAK,MAAM,IAAI,QAAQ;AAAA,MACpC,QAAQ;AACN,mBAAW;AAAA,MACb;AAAA,IACF;AACA,WAAO;AAAA,MACL,MAAM,IAAI;AAAA,MACV,QAAQ,IAAI;AAAA,MACZ,SAAS,IAAI;AAAA,MACb,cAAc,IAAI;AAAA,MAClB;AAAA,MACA,aAAa,IAAI;AAAA,MACjB,YAAY,IAAI;AAAA,MAChB,iBAAiB,IAAI;AAAA,MACrB,YAAY,IAAI;AAAA,MAChB,gBAAgB,IAAI;AAAA,MACpB,kBAAkB,IAAI;AAAA,IACxB;AAAA,EACF;AAEA,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IAEA,YAAY,QAAQ;AAKlB,aACE,eAAe,IAAI;AAAA,QACjB,UAAU,OAAO;AAAA,QACjB,SAAS,OAAO,SAAS;AAAA,QACzB,QAAQ,OAAO,SAAS;AAAA,QACxB,cAAc,OAAO,SAAS;AAAA,QAC9B,aAAa,OAAO,SAAS;AAAA,QAC7B,YAAY,OAAO,OAAO,KAAK,IAAI;AAAA,QACnC,UAAU,OAAO;AAAA,QACjB,WAAW,OAAO;AAAA,MACpB,CAAC,EAAE,YAAY;AAAA,IAEnB;AAAA,IAEA,aAAa,SAAS;AAIpB,aAAO,iBAAiB,IAAI,QAAQ,UAAU,QAAQ,SAAS,EAAE,YAAY;AAAA,IAC/E;AAAA,IAEA,iBAAiB,SAAS;AAKxB,sBAAgB,IAAI;AAAA,QAClB,MAAM,QAAQ,SAAS;AAAA,QACvB,MAAM,QAAQ;AAAA,QACd,SAAS,QAAQ;AAAA,QACjB,QAAQ,QAAQ;AAAA,QAChB,SAAS,QAAQ,SAAS;AAAA,QAC1B,QAAQ,QAAQ,SAAS;AAAA,QACzB,cAAc,QAAQ,SAAS;AAAA,QAC/B,aAAa,QAAQ,SAAS;AAAA,QAC9B,cAAc,QAAQ,OAAO,KAAK,IAAI;AAAA,MACxC,CAAC;AAAA,IACH;AAAA,IAEA,gBAAgB,MAAM;AAIpB,aAAO,uBAAuB,IAAI,IAAI,EAAE;AAAA,IAC1C;AAAA,IAEA,aAAa;AACX,aAAO,eAAe;AAAA,IACxB;AAAA,IAEA,mBAAmB,UAAU,OAAO;AAMlC,qBAAe,KAAK;AACpB,aAAO;AAAA,QACL,iBAAiB;AAAA,QACjB,SAAS,SAAS;AAAA,QAClB,cAAc,SAAS;AAAA,QACvB,gBAAgB;AAAA,QAChB,cAAc,MAAM,OAAO,KAAK,IAAI;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,QAQpC,OAAO,eAAe,EAAE,OAAO,CAAC,SAAS,KAAK,UAAU,QAAQ,KAAK,UAAU,SAAS,CAAC;AAAA,MAC3F;AAAA,IACF;AAAA,IAEA,QAAQ;AACN,aAAQ,SAAS,QAAQ,mCAAmC,EAAE,IAAI,EAAkB;AAAA,QAClF;AAAA,MACF;AAAA,IACF;AAAA,IAEA,QAAQ,MAAM,QAAQ;AAGpB,eACG;AAAA,QACC;AAAA;AAAA,MAEF,EACC,IAAI,MAAM,MAAM;AAAA,IACrB;AAAA,IAEA,WAAW,MAAM;AACf,aAAO,SAAS,QAAQ,kCAAkC,EAAE,IAAI,IAAI,EAAE,UAAU;AAAA,IAClF;AAAA,IAEA,oBAAoB,MAAM,OAAO;AAC/B,UAAI,CAAC,MAAM,IAAI;AAGb,iBACG,QAAQ,qEAAqE,EAC7E,IAAI,MAAM,IAAI,MAAM,OAAO,IAAI;AAClC;AAAA,MACF;AAKA,eACG;AAAA,QACC;AAAA;AAAA;AAAA;AAAA;AAAA,MAKF,EACC;AAAA,QACC,KAAK,UAAU,MAAM,QAAQ;AAAA,QAC7B,MAAM,SAAS;AAAA,QACf,MAAM;AAAA,QACN,MAAM;AAAA,QACN,MAAM,SAAS;AAAA,QACf,MAAM,SAAS;AAAA,QACf,MAAM,SAAS;AAAA,QACf,MAAM,SAAS;AAAA,QACf;AAAA,MACF;AAAA,IACJ;AAAA,IAEA,QAAQ;AACN,eAAS,MAAM;AAAA,IACjB;AAAA,EACF;AACF;","names":["join","join","randomUUID","mkdirSync","require","mkdirSync","agentId","randomUUID"]}
|
|
1
|
+
{"version":3,"sources":["../../src/identity.ts","../../src/paths.ts","../../src/store.ts","../../src/ids.ts","../../src/mux.ts","../../src/version.ts","../../src/view.ts"],"sourcesContent":["import { randomUUID } from \"node:crypto\";\nimport { existsSync, mkdirSync, readFileSync, writeFileSync } from \"node:fs\";\nimport { hostname } from \"node:os\";\nimport { join } from \"node:path\";\nimport { stateDir } from \"./paths.js\";\n\nexport type NodeIdentity = {\n host_id: string;\n display_name: string;\n};\n\nfunction identityPath(): string {\n return join(stateDir(), \"identity.json\");\n}\n\n/**\n * Memoized per process, keyed on the resolved path.\n *\n * `identity.json` cannot change under a running command, and the audit measured\n * eight redundant reads per invocation. Keyed on the path rather than a bare\n * boolean so a test that repoints `MURMUR_STATE_DIR` mid-process is not served\n * another directory's identity.\n */\nlet cache: { path: string; identity: NodeIdentity | null } | null = null;\n\n/**\n * This node's identity, or null when it has none.\n *\n * A READ, and only a read: nothing mints here. Every command that needs a\n * host_id fails with \"murmur is not initialised on this node; run: murmur init\"\n * rather than bringing a node into existence as a side effect of a status-bar\n * tick.\n */\nexport function loadIdentity(): NodeIdentity | null {\n const path = identityPath();\n if (cache?.path === path) return cache.identity;\n const identity = existsSync(path)\n ? (JSON.parse(readFileSync(path, \"utf8\")) as NodeIdentity)\n : null;\n cache = { path, identity };\n return identity;\n}\n\nfunction write(identity: NodeIdentity): NodeIdentity {\n mkdirSync(stateDir(), { recursive: true });\n writeFileSync(identityPath(), `${JSON.stringify(identity, null, 2)}\\n`);\n cache = { path: identityPath(), identity };\n return identity;\n}\n\n/** Create this node's identity. Only `murmur init` calls it. */\nexport function createIdentity(displayName = hostname()): NodeIdentity {\n if (loadIdentity()) throw new Error(`identity already exists: ${identityPath()}`);\n return write({ host_id: randomUUID(), display_name: displayName });\n}\n\n/**\n * Rename an existing node, keeping its `host_id`.\n *\n * `murmur init --name` on an already-initialised node used to ignore the flag\n * silently, which is the one thing a rename must not do.\n */\nexport function setDisplayName(displayName: string): NodeIdentity {\n const existing = loadIdentity();\n return write(\n existing\n ? { host_id: existing.host_id, display_name: displayName }\n : { host_id: randomUUID(), display_name: displayName },\n );\n}\n","import { homedir } from \"node:os\";\nimport { join } from \"node:path\";\n\nexport function stateDir(): string {\n return (\n process.env.MURMUR_STATE_DIR ??\n join(process.env.XDG_STATE_HOME ?? join(homedir(), \".local\", \"state\"), \"murmur\")\n );\n}\n\nexport function configDir(): string {\n return (\n process.env.MURMUR_CONFIG_DIR ??\n join(process.env.XDG_CONFIG_HOME ?? join(homedir(), \".config\"), \"murmur\")\n );\n}\n\n/** The current-state database. The only database murmur holds. */\nexport function dbPath(): string {\n return join(stateDir(), \"state.db\");\n}\n","import { randomUUID } from \"node:crypto\";\nimport { mkdirSync, rmSync } from \"node:fs\";\nimport { dirname } from \"node:path\";\nimport Database from \"better-sqlite3\";\nimport type { NodeIdentity } from \"./identity.js\";\nimport type { PaneId } from \"./ids.js\";\nimport { asPaneId, asSessionId, asWindowId } from \"./ids.js\";\nimport { pidAlive } from \"./mux.js\";\nimport { dbPath } from \"./paths.js\";\nimport type {\n ActivityUpdate,\n AgentClaim,\n AgentRelease,\n AttentionKind,\n AttentionRequest,\n ClaimResult,\n LocalWorld,\n PeerFetch,\n PeerRecord,\n ReconcileSummary,\n Snapshot,\n SnapshotAgent,\n SnapshotAttention,\n SnapshotPane,\n} from \"./types.js\";\nimport { MURMUR_VERSION } from \"./version.js\";\nimport { RENDER_PRIORITY } from \"./view.js\";\n\n/**\n * The storage version. Any change to any table bumps it.\n *\n * ONE version strategy: a mismatch salvages the peer names and targets a human\n * typed, deletes the file, and recreates the schema. No ALTER TABLE anywhere, so\n * there is no additive path to forget to use.\n */\nconst SCHEMA_USER_VERSION = 3;\n\nconst SCHEMA = `\n CREATE TABLE agents (\n agent_id TEXT NOT NULL PRIMARY KEY,\n pane TEXT NOT NULL UNIQUE,\n owner_pid INTEGER NOT NULL CHECK (owner_pid > 0),\n activity TEXT NOT NULL CHECK (activity IN ('running', 'stopped')),\n session TEXT NOT NULL,\n window TEXT NOT NULL,\n session_name TEXT,\n window_name TEXT,\n agent_name TEXT,\n pi_session TEXT,\n workstream TEXT,\n role TEXT,\n cli TEXT NOT NULL,\n driver TEXT NOT NULL CHECK (driver IN ('human', 'orchestrated')),\n claimed_at INTEGER NOT NULL,\n updated_at INTEGER NOT NULL\n ) STRICT;\n\n CREATE TABLE attention (\n pane TEXT NOT NULL,\n kind TEXT NOT NULL CHECK (kind IN ('done', 'blocked', 'crashed')),\n message TEXT NOT NULL,\n source TEXT NOT NULL,\n session TEXT NOT NULL,\n window TEXT NOT NULL,\n session_name TEXT,\n window_name TEXT,\n requested_at INTEGER NOT NULL,\n PRIMARY KEY (pane, kind)\n ) STRICT;\n\n CREATE TABLE peers (\n name TEXT NOT NULL PRIMARY KEY,\n target TEXT NOT NULL,\n host_id TEXT,\n display_name TEXT,\n snapshot TEXT,\n snapshot_at INTEGER,\n fetched_at INTEGER,\n last_attempt_at INTEGER,\n last_error TEXT,\n murmur_version TEXT,\n snapshot_version INTEGER\n ) STRICT;\n`;\n\n/**\n * The store, and the only place in murmur that holds a database handle or\n * writes SQL.\n *\n * This interface is CLOSED. There is no `append`, no `ingest`, no log read, no\n * partial-row update, and no local read other than `localPanes` — each of those\n * shapes let a writer say something it had no standing to say, and each cost a\n * shipped bug. Attention methods take no agent identity at all, which is what\n * makes \"a notifier cannot corrupt an agent row\" structural.\n */\nexport interface Store {\n // --- agent lifecycle: owner-only, pid-gated -----------------------------\n claimAgent(claim: AgentClaim): ClaimResult;\n setActivity(update: ActivityUpdate): boolean;\n releaseAgent(release: AgentRelease): boolean;\n\n // --- attention: pane-addressed, no agent authority ----------------------\n requestAttention(request: AttentionRequest): void;\n acknowledgePane(pane: PaneId): number;\n\n // --- local truth --------------------------------------------------------\n /** The one local read. Joins agents and attention by pane. No reconciliation. */\n localPanes(): SnapshotPane[];\n reconcileLocal(world: LocalWorld): ReconcileSummary;\n buildLocalSnapshot(identity: NodeIdentity, world: LocalWorld): Snapshot;\n\n // --- peer cache ---------------------------------------------------------\n peers(): PeerRecord[];\n addPeer(name: string, target: string): void;\n removePeer(name: string): boolean;\n replacePeerSnapshot(name: string, fetch: PeerFetch): void;\n\n close(): void;\n}\n\ntype AgentDbRow = {\n agent_id: string;\n pane: string;\n owner_pid: number;\n activity: string;\n session: string;\n window: string;\n session_name: string | null;\n window_name: string | null;\n agent_name: string | null;\n pi_session: string | null;\n workstream: string | null;\n role: string | null;\n cli: string;\n driver: string;\n claimed_at: number;\n updated_at: number;\n};\n\ntype AttentionDbRow = {\n pane: string;\n kind: string;\n message: string;\n source: string;\n session: string;\n window: string;\n session_name: string | null;\n window_name: string | null;\n requested_at: number;\n};\n\ntype PeerDbRow = {\n name: string;\n target: string;\n host_id: string | null;\n display_name: string | null;\n snapshot: string | null;\n snapshot_at: number | null;\n fetched_at: number | null;\n last_attempt_at: number | null;\n last_error: string | null;\n murmur_version: string | null;\n snapshot_version: number | null;\n};\n\n/** Peer names and targets: the two fields a human typed, and all we salvage. */\nfunction salvagePeers(path: string): { name: string; target: string }[] {\n try {\n const existing = new Database(path, { fileMustExist: true });\n try {\n const version = (existing.pragma(\"user_version\", { simple: true }) as number) ?? 0;\n if (version === SCHEMA_USER_VERSION) return [];\n return existing.prepare(\"SELECT name, target FROM peers\").all() as {\n name: string;\n target: string;\n }[];\n } catch {\n // Too old to have the table, or unreadable. Nothing to save.\n return [];\n } finally {\n existing.close();\n }\n } catch {\n // No database yet, or one too broken to open.\n return [];\n }\n}\n\nfunction needsReset(path: string): boolean {\n try {\n const existing = new Database(path, { fileMustExist: true });\n try {\n return (\n ((existing.pragma(\"user_version\", { simple: true }) as number) ?? 0) !== SCHEMA_USER_VERSION\n );\n } finally {\n existing.close();\n }\n } catch {\n return false;\n }\n}\n\nfunction toAttention(row: AttentionDbRow): SnapshotAttention {\n return {\n kind: row.kind as AttentionKind,\n message: row.message,\n source: row.source,\n requested_at: row.requested_at,\n };\n}\n\nfunction toAgent(row: AgentDbRow): SnapshotAgent {\n return {\n agent_id: row.agent_id,\n activity: row.activity as SnapshotAgent[\"activity\"],\n agent_name: row.agent_name,\n pi_session: row.pi_session,\n workstream: row.workstream,\n role: row.role,\n cli: row.cli,\n driver: row.driver as SnapshotAgent[\"driver\"],\n claimed_at: row.claimed_at,\n updated_at: row.updated_at,\n };\n}\n\nconst PRIORITY = new Map<string, number>(RENDER_PRIORITY.map((kind, index) => [kind, index]));\n\nfunction attentionOrder(left: SnapshotAttention, right: SnapshotAttention): number {\n return (PRIORITY.get(left.kind) ?? 99) - (PRIORITY.get(right.kind) ?? 99);\n}\n\n/**\n * Open the store. Takes no arguments and mints no identity.\n *\n * `openStore` deliberately does NOT read or create `identity.json`: identity is\n * created only by `murmur init`, so a read path — a status-bar tick, a focus\n * hook — cannot bring a node into existence as a side effect.\n */\nexport function openStore(): Store {\n const path = dbPath();\n mkdirSync(dirname(path), { recursive: true });\n\n const salvaged = salvagePeers(path);\n if (needsReset(path)) {\n for (const suffix of [\"\", \"-wal\", \"-shm\"]) rmSync(`${path}${suffix}`, { force: true });\n }\n\n const database = new Database(path);\n database.pragma(\"journal_mode = WAL\");\n database.pragma(\"busy_timeout = 5000\");\n const version = (database.pragma(\"user_version\", { simple: true }) as number) ?? 0;\n if (version !== SCHEMA_USER_VERSION) {\n database.exec(SCHEMA);\n database.pragma(`user_version = ${SCHEMA_USER_VERSION}`);\n // Re-inserted with every OBSERVED column null: a salvaged peer has no\n // snapshot and has never been fetched, and saying otherwise would render a\n // never-reached host as fresh.\n const restore = database.prepare(\"INSERT OR IGNORE INTO peers (name, target) VALUES (?, ?)\");\n for (const peer of salvaged) restore.run(peer.name, peer.target);\n }\n\n const selectAgentByPane = database.prepare(\"SELECT * FROM agents WHERE pane = ?\");\n const insertAgent = database.prepare(`\n INSERT INTO agents (agent_id, pane, owner_pid, activity, session, window,\n session_name, window_name, agent_name, pi_session,\n workstream, role, cli, driver, claimed_at, updated_at)\n VALUES (@agent_id, @pane, @owner_pid, @activity, @session, @window,\n @session_name, @window_name, @agent_name, @pi_session,\n @workstream, @role, @cli, @driver, @claimed_at, @updated_at)\n `);\n const retainAgent = database.prepare(`\n UPDATE agents\n SET session = @session, window = @window, session_name = @session_name,\n window_name = @window_name, agent_name = @agent_name,\n pi_session = @pi_session, workstream = @workstream, role = @role,\n cli = @cli, driver = @driver, updated_at = @updated_at\n WHERE agent_id = @agent_id\n `);\n const deleteAgentByPane = database.prepare(\"DELETE FROM agents WHERE pane = ?\");\n const deleteAttentionForPane = database.prepare(\"DELETE FROM attention WHERE pane = ?\");\n const updateActivity = database.prepare(`\n UPDATE agents\n SET activity = @activity, session = @session, window = @window,\n session_name = @session_name, window_name = @window_name,\n updated_at = @updated_at\n WHERE agent_id = @agent_id AND owner_pid = @owner_pid\n `);\n const deleteAgentOwned = database.prepare(\n \"DELETE FROM agents WHERE agent_id = ? AND owner_pid = ?\",\n );\n const upsertAttention = database.prepare(`\n INSERT INTO attention (pane, kind, message, source, session, window,\n session_name, window_name, requested_at)\n VALUES (@pane, @kind, @message, @source, @session, @window,\n @session_name, @window_name, @requested_at)\n ON CONFLICT (pane, kind) DO UPDATE SET\n message = excluded.message,\n source = excluded.source,\n session = excluded.session,\n window = excluded.window,\n session_name = excluded.session_name,\n window_name = excluded.window_name\n `);\n const selectAgents = database.prepare(\"SELECT * FROM agents\");\n const selectAttention = database.prepare(\"SELECT * FROM attention\");\n const setActivityByPane = database.prepare(\n \"UPDATE agents SET activity = ?, updated_at = ? WHERE pane = ?\",\n );\n\n /**\n * `.immediate`, not deferred, and this is load-bearing.\n *\n * The transaction reads the incumbent row and then writes, so a deferred one\n * starts as a READER and must upgrade. Two doing that at once fails the loser\n * with SQLITE_BUSY_SNAPSHOT, which no busy_timeout can fix: waiting longer\n * cannot make a stale snapshot fresh. Measured previously at 5 of 8\n * concurrent writers failing.\n */\n const claimAgent = database.transaction((claim: AgentClaim): ClaimResult => {\n const now = claim.now ?? Date.now();\n const isAlive = claim.isAlive ?? pidAlive;\n const { location, meta, owner_pid } = claim;\n const incumbent = selectAgentByPane.get(location.pane) as AgentDbRow | undefined;\n\n const values = {\n pane: location.pane,\n owner_pid,\n session: location.session,\n window: location.window,\n session_name: location.session_name,\n window_name: location.window_name,\n agent_name: meta.agent_name,\n pi_session: meta.pi_session,\n workstream: meta.workstream,\n role: meta.role,\n cli: meta.cli,\n driver: meta.driver,\n updated_at: now,\n };\n\n if (!incumbent) {\n const agentId = randomUUID();\n insertAgent.run({ ...values, agent_id: agentId, activity: \"stopped\", claimed_at: now });\n return { outcome: \"claimed\", agent_id: agentId };\n }\n\n // Our own claim, seen again. This is what makes pi's `/reload` a no-op: pi\n // re-runs the extension factory in the same process, and a check that could\n // not recognise its own claim would silence the real agent. `activity` and\n // `agent_id` are deliberately untouched.\n if (incumbent.owner_pid === owner_pid) {\n retainAgent.run({ ...values, agent_id: incumbent.agent_id });\n return { outcome: \"retained\", agent_id: incumbent.agent_id };\n }\n\n // A different LIVE process in one pane: the nested-agent case, and the only\n // answer for it. Fails closed — `pidAlive` reports death only on ESRCH, so\n // an unanswerable probe (EPERM) reads as alive and refuses. An unknown must\n // never let a second writer displace a possibly-live owner.\n if (isAlive(incumbent.owner_pid)) {\n return { outcome: \"refused\", held_by_pid: incumbent.owner_pid };\n }\n\n // The previous occupant is gone. Its attention described a process that no\n // longer exists, and a human looking at the pane now sees a different agent.\n deleteAgentByPane.run(location.pane);\n deleteAttentionForPane.run(location.pane);\n const agentId = randomUUID();\n insertAgent.run({ ...values, agent_id: agentId, activity: \"stopped\", claimed_at: now });\n return { outcome: \"replaced\", agent_id: agentId, previous_agent_id: incumbent.agent_id };\n }).immediate;\n\n /**\n * One transaction, because the `stopped` write and its `crashed` attention row\n * must land together or not at all.\n *\n * A no-op when tmux could not answer: `panes === null` is absence of evidence,\n * not evidence of death, and conflating the two once deleted ten live agents.\n */\n const reconcileLocal = database.transaction((world: LocalWorld): ReconcileSummary => {\n const summary: ReconcileSummary = { crashed: [], removed: [], attention_removed: [] };\n if (world.panes === null) return summary;\n const live = world.panes;\n const isAlive = world.isAlive ?? pidAlive;\n const now = world.now ?? Date.now();\n\n // Which panes already carry a crash we recorded. Read once, before any\n // write, so the loop below sees the state reconciliation started from.\n const alreadyCrashed = new Set(\n (selectAttention.all() as AttentionDbRow[])\n .filter((row) => row.kind === \"crashed\")\n .map((row) => row.pane),\n );\n\n for (const row of selectAgents.all() as AgentDbRow[]) {\n const pane = asPaneId(row.pane);\n if (!live.has(pane)) {\n deleteAgentByPane.run(row.pane);\n deleteAttentionForPane.run(row.pane);\n summary.removed.push(pane);\n continue;\n }\n if (isAlive(row.owner_pid)) continue;\n\n // The asymmetry below is the point. A dead RUNNING owner is an unreported\n // crash and must leave a durable trace. A dead STOPPED owner finished\n // normally, so its row is noise — but any `done` it raised is a fact a\n // human has not yet seen, so the attention stays.\n if (row.activity === \"running\") {\n setActivityByPane.run(\"stopped\", now, row.pane);\n upsertAttention.run({\n pane: row.pane,\n kind: \"crashed\",\n message: \"\",\n source: \"murmur\",\n session: row.session,\n window: row.window,\n session_name: row.session_name,\n window_name: row.window_name,\n requested_at: now,\n });\n summary.crashed.push(pane);\n } else if (!alreadyCrashed.has(row.pane)) {\n deleteAgentByPane.run(row.pane);\n summary.removed.push(pane);\n }\n // A pane we already recorded a crash for keeps its agent row, and that is\n // the one place this deviates from a literal reading of the contract's\n // table -- which says a live pane with a dead STOPPED owner loses its row.\n // Taken literally, the second reconcile after a crash deletes the row the\n // first one had just marked `stopped`, so the crashed pane loses its\n // agent_name, workstream, role and cli one tick after the crash is\n // reported. That contradicts the contract's own idempotence requirement\n // (\"running it again changes nothing\") and it strips exactly the fields a\n // human needs to know WHICH agent died.\n //\n // The distinction the table is drawing is between an owner that finished\n // normally -- whose row is noise -- and one that died mid-run. The\n // `crashed` row we wrote is the record of which case this was, so it is\n // also the right thing to key on.\n }\n\n // Reaps attention for a pane that never had an agent row — an\n // attention-only codex pane whose window was closed. Nothing else would.\n for (const row of selectAttention.all() as AttentionDbRow[]) {\n const pane = asPaneId(row.pane);\n if (live.has(pane)) continue;\n deleteAttentionForPane.run(row.pane);\n if (!summary.attention_removed.includes(pane)) summary.attention_removed.push(pane);\n }\n\n return summary;\n }).immediate;\n\n /**\n * Both tables read at ONE point in time, or a pane can appear with an agent\n * and without the attention that was there when the agent was read.\n */\n const readLocalPanes = database.transaction((): SnapshotPane[] => {\n const agents = selectAgents.all() as AgentDbRow[];\n const attention = selectAttention.all() as AttentionDbRow[];\n const panes = new Map<string, SnapshotPane>();\n\n const locate = (row: AgentDbRow | AttentionDbRow): SnapshotPane => {\n const existing = panes.get(row.pane);\n if (existing) return existing;\n const created: SnapshotPane = {\n pane: asPaneId(row.pane),\n session: asSessionId(row.session),\n window: asWindowId(row.window),\n session_name: row.session_name,\n window_name: row.window_name,\n agent: null,\n attention: [],\n };\n panes.set(row.pane, created);\n return created;\n };\n\n for (const row of agents) locate(row).agent = toAgent(row);\n for (const row of attention) locate(row).attention.push(toAttention(row));\n\n for (const pane of panes.values()) pane.attention.sort(attentionOrder);\n return [...panes.values()].sort((left, right) => left.pane.localeCompare(right.pane));\n });\n\n function peerRecord(row: PeerDbRow): PeerRecord {\n let snapshot: Snapshot | null = null;\n if (row.snapshot !== null) {\n try {\n // Parsed leniently on the way OUT: it was validated on the way in, and\n // a read path must not throw. A stored document that no longer parses\n // reads as \"no snapshot\" and is left in place, not deleted.\n snapshot = JSON.parse(row.snapshot) as Snapshot;\n } catch {\n snapshot = null;\n }\n }\n return {\n name: row.name,\n target: row.target,\n host_id: row.host_id,\n display_name: row.display_name,\n snapshot,\n snapshot_at: row.snapshot_at,\n fetched_at: row.fetched_at,\n last_attempt_at: row.last_attempt_at,\n last_error: row.last_error,\n murmur_version: row.murmur_version,\n snapshot_version: row.snapshot_version,\n };\n }\n\n return {\n claimAgent,\n reconcileLocal,\n\n setActivity(update) {\n // Both key components are required, so a write from a REPLACED owner\n // matches nothing and returns false. That is not an error and must not be\n // retried: it means this process is no longer the owner of record, and the\n // correct response is silence.\n return (\n updateActivity.run({\n activity: update.activity,\n session: update.location.session,\n window: update.location.window,\n session_name: update.location.session_name,\n window_name: update.location.window_name,\n updated_at: update.now ?? Date.now(),\n agent_id: update.agent_id,\n owner_pid: update.owner_pid,\n }).changes === 1\n );\n },\n\n releaseAgent(release) {\n // Attention is deliberately NOT deleted: a `done` raised at settle must\n // survive the agent exiting, or completion becomes invisible the moment\n // the process quits.\n return deleteAgentOwned.run(release.agent_id, release.owner_pid).changes === 1;\n },\n\n requestAttention(request) {\n // `requested_at` is absent from the DO UPDATE list on purpose. Age means\n // \"how long this has gone unmet\", so a repeat must not reset the clock —\n // which also makes crash attention idempotent for free. Touches no\n // `agents` row, ever; there is no column here that could.\n upsertAttention.run({\n pane: request.location.pane,\n kind: request.kind,\n message: request.message,\n source: request.source,\n session: request.location.session,\n window: request.location.window,\n session_name: request.location.session_name,\n window_name: request.location.window_name,\n requested_at: request.now ?? Date.now(),\n });\n },\n\n acknowledgePane(pane) {\n // Every kind, one statement, no agent row touched: focusing a pane cannot\n // alter activity or owner metadata. This is the whole `murmur clear`\n // write path.\n return deleteAttentionForPane.run(pane).changes;\n },\n\n localPanes() {\n return readLocalPanes();\n },\n\n buildLocalSnapshot(identity, world) {\n // Reconcile first, which is what makes \"a snapshot is authoritative\"\n // true: absence from a successful snapshot means absence, so it must\n // never be produced from unreconciled rows. Two transactions rather than\n // one — a write transaction held open across the read would serialise\n // every focus hook on the machine behind an export.\n reconcileLocal(world);\n return {\n murmur_snapshot: 1,\n host_id: identity.host_id,\n display_name: identity.display_name,\n murmur_version: MURMUR_VERSION,\n generated_at: world.now ?? Date.now(),\n // Rule 3: a pane with no agent and no attention must not be published.\n // A no-op against today's `readLocalPanes`, which builds a pane entry\n // only from a row and so cannot produce an empty one -- kept because the\n // rule belongs to the DOCUMENT, and the validator rejects such an entry\n // outright. Without it, one narrowing of the local read would make this\n // node reachable-but-broken on every peer that collects it, and the\n // symptom would show up on the other machines.\n panes: readLocalPanes().filter((pane) => pane.agent !== null || pane.attention.length > 0),\n };\n },\n\n peers() {\n return (database.prepare(\"SELECT * FROM peers ORDER BY name\").all() as PeerDbRow[]).map(\n peerRecord,\n );\n },\n\n addPeer(name, target) {\n // Correcting a target must not discard the cache, so this updates only\n // the field the operator retyped.\n database\n .prepare(\n `INSERT INTO peers (name, target) VALUES (?, ?)\n ON CONFLICT(name) DO UPDATE SET target = excluded.target`,\n )\n .run(name, target);\n },\n\n removePeer(name) {\n return database.prepare(\"DELETE FROM peers WHERE name = ?\").run(name).changes > 0;\n },\n\n replacePeerSnapshot(name, fetch) {\n if (!fetch.ok) {\n // Failure touches neither snapshot, snapshot_at nor fetched_at, so the\n // last-known document stands and the peer ages into `stale` on its own.\n database\n .prepare(\"UPDATE peers SET last_attempt_at = ?, last_error = ? WHERE name = ?\")\n .run(fetch.at, fetch.error, name);\n return;\n }\n // Two clocks, and conflating them is how a freshly fetched three-hour-old\n // fact reads as new. `snapshot_at` is the PEER's clock (when it built the\n // document); `fetched_at` is OURS (when we reached it), and freshness is\n // computed from `fetched_at` only.\n database\n .prepare(\n `UPDATE peers\n SET snapshot = ?, snapshot_at = ?, fetched_at = ?, last_attempt_at = ?,\n last_error = NULL, host_id = ?, display_name = ?,\n murmur_version = ?, snapshot_version = ?\n WHERE name = ?`,\n )\n .run(\n JSON.stringify(fetch.snapshot),\n fetch.snapshot.generated_at,\n fetch.at,\n fetch.at,\n fetch.snapshot.host_id,\n fetch.snapshot.display_name,\n fetch.snapshot.murmur_version,\n fetch.snapshot.murmur_snapshot,\n name,\n );\n },\n\n close() {\n database.close();\n },\n };\n}\n","/**\n * tmux's three id kinds, kept apart by the type system.\n *\n * tmux itself is unambiguous about this and prints a sigil on every id --\n * `session=$25 window=@75 pane=%89` -- but they are all strings, so murmur\n * could and did pass one where another was meant. Twice, in shipped code: a\n * sweep keyed on window liveness deleted ten live agents, and a window cached\n * at extension startup badged the window a moved pane had left.\n *\n * An agent is addressed by its PANE, which keeps its id across `move-pane`,\n * `break-pane`, and a window closed and reopened. A session and a window are\n * only where that pane currently lives, and both may differ between two reports\n * from one agent. So the rule the brands enforce is:\n *\n * only a pane may decide whether an agent exists.\n *\n * Branding is a compile-time fiction: at runtime these are the same strings\n * tmux printed, which is what keeps the snapshot document and every stored row\n * byte-identical.\n */\n\ndeclare const brand: unique symbol;\n\n/** A tmux session id, `$N`. Mutable location. */\nexport type SessionId = string & { readonly [brand]: \"session\" };\n\n/** A tmux window id, `@N`. Mutable location -- never an agent's identity. */\nexport type WindowId = string & { readonly [brand]: \"window\" };\n\n/** A tmux pane id, `%N`. The agent's identity, stable for its whole life. */\nexport type PaneId = string & { readonly [brand]: \"pane\" };\n\n/*\n * The boundary. Every raw string that becomes an id passes through one of these\n * three, so the unsafe step is in one file and countable rather than scattered\n * as `as` at each call site.\n *\n * Deliberately not validating the sigil. These are called on tmux stdout, on\n * JSON off the wire, on sqlite rows and on argv, and a node that recorded an id\n * murmur does not recognise -- a future tmux, a different harness -- must still\n * round-trip it. Rejecting here would turn a naming change into a behaviour\n * change.\n */\n\nexport function asSessionId(raw: string): SessionId {\n return raw as SessionId;\n}\n\nexport function asWindowId(raw: string): WindowId {\n return raw as WindowId;\n}\n\nexport function asPaneId(raw: string): PaneId {\n return raw as PaneId;\n}\n","import { execFileSync } from \"node:child_process\";\nimport {\n asPaneId,\n asSessionId,\n asWindowId,\n type PaneId,\n type SessionId,\n type WindowId,\n} from \"./ids.js\";\nimport type { Location } from \"./types.js\";\nimport type { RenderState } from \"./view.js\";\n\nexport interface Mux {\n currentWindow(): Location | null;\n livePanes(): Set<PaneId> | null;\n // Sets `@agent_state` on a WINDOW, even though the attention it expresses\n // belongs to a pane. The asymmetry is tmux's: the status bar and the `tms`\n // picker read a window option, and there is no per-pane equivalent they\n // would read instead. Its consequence is that a pane moving between windows\n // must clear the badge it left behind, since nothing else knows it moved.\n setWindowBadge(window: WindowId, state: RenderState | null): void;\n // Reports whether the attach actually happened. runTmux swallows failures to\n // return null, and a jump that silently failed looked exactly like \"enter did\n // nothing\" -- the symptom the remote probe was added to prevent, reproduced\n // on the local path.\n attach(session: SessionId, window: WindowId): boolean;\n windowForPane(pane: PaneId): WindowId | null;\n panesInWindow(window: WindowId): PaneId[];\n capture(pane: PaneId, lines?: number): string | null;\n // --- remote-jump session seam -------------------------------------------\n // A remote attach lives in its own local session rather than a window, so it\n // can be full-screen (no local status bar) and prefix-free (no nested ^b).\n // See jumpToAgent for why that is worth five extra methods.\n clientName(): string | null;\n currentTarget(): string | null;\n sessionNamed(name: string): boolean;\n newSession(name: string, command: string): boolean;\n setSessionOption(session: string, option: string, value: string): void;\n switchClient(client: string | null, session: string): boolean;\n}\n\nfunction runTmux(args: string[]): string | null {\n try {\n return execFileSync(\"tmux\", args, {\n encoding: \"utf8\",\n timeout: 3000,\n stdio: [\"ignore\", \"pipe\", \"ignore\"],\n }).trim();\n } catch {\n return null;\n }\n}\n\n/**\n * A session name as an exact target, in the two spellings tmux needs.\n *\n * Bare names match by PREFIX, so a wrapper for host `bub` silently retargets a\n * session called `bubba` once one exists -- verified, and it sets options on\n * the wrong session rather than failing. A leading `=` demands an exact match.\n * (`name=` is not the syntax; it reads as part of the name and matches nothing.)\n *\n * The trailing colon is the part that is easy to get wrong. `switch-client -t`\n * takes a target-SESSION, where `=name` is right, but `set-option -t` and\n * `show-options -t` take a target-PANE, where `=name` fails outright with `no\n * such session` and the exact form is `=name:` -- the empty window/pane part\n * resolving to the session's current pane.\n *\n * Neither rescues a name starting with `@`, `$` or `%`: those introduce tmux's\n * window, session and pane id syntax. remoteSessionName keeps them out.\n *\n * Both take a session NAME -- not a SessionId, which is why neither is branded.\n * `exactPaneTarget` is named for what it RETURNS, a tmux target-pane, because\n * what it takes and what it produces are different things and the old name\n * `exactPane` read as though it took a pane.\n */\n/**\n * The window name worth RECORDING, given tmux's own answer and whether tmux is\n * renaming that window itself.\n *\n * Null while `automatic-rename` is on -- which is tmux's DEFAULT -- because the\n * name is then just the foreground process. The picker's `agent` column showed\n * `Python`, `node` and `zsh` for real agents: pi's own interpreter, labelled\n * \"agent\".\n *\n * A name nobody chose is not a name, and recording it as one is worse than\n * recording nothing: `agentLabel` prefers the window over the session, so a\n * process name shadowed `hacking/murmur` -- the string the reader actually\n * searches on. Dropped at the point of RECORDING rather than at render, so\n * every surface, and every peer reading this node's snapshot, agrees on what\n * counts as a name.\n *\n * Split out of `currentWindow` to be testable: that method shells out to a real\n * tmux server, so the decision had no reachable seam and the format string was\n * the only thing a test could have asserted on.\n */\nexport function chosenWindowName(\n name: string | undefined,\n autoRename: string | undefined,\n): string | null {\n if (autoRename === \"1\") return null;\n return name || null;\n}\n\nexport function exactSession(session: string): string {\n return `=${session}`;\n}\n\nexport function exactPaneTarget(session: string): string {\n return `=${session}:`;\n}\n\nexport function tmuxBadgeState(state: RenderState): string {\n // @agent_state is consumed by existing tmux configuration, whose public\n // vocabulary calls active work \"working\". Keep the internal activity named\n // \"running\" without forcing a coordinated config rollout.\n return state === \"running\" ? \"working\" : state;\n}\n\nexport const tmux: Mux = {\n currentWindow() {\n // $TMUX_PANE is the only trustworthy signal that we are inside a pane, and\n // it is set by tmux for every process in one.\n //\n // Asking tmux instead does not work: `display-message` answers from any\n // process on a machine with a running server, and reports whichever pane\n // that server considers active. A pi started outside tmux -- a bare ssh\n // login, a plain terminal, cron -- would then record itself as living in\n // some unrelated agent's pane and overwrite that agent's state. Falling\n // back to `display-message` here was exactly that bug.\n const raw = process.env.TMUX_PANE;\n if (!raw) return null;\n const pane = asPaneId(raw);\n\n // One call for ids and names together. The names travel with every row a\n // snapshot carries, because a reader cannot resolve a remote session or\n // window id against its own tmux.\n const fields = runTmux([\n \"display-message\",\n \"-t\",\n pane,\n \"-p\",\n \"#{session_id}\\t#{window_id}\\t#{session_name}\\t#{window_name}\\t#{?automatic-rename,1,0}\",\n ]);\n const [session, window, sessionName, windowName, autoRename] = fields?.split(\"\\t\") ?? [];\n if (!session || !window) return null;\n return {\n session: asSessionId(session),\n window: asWindowId(window),\n pane,\n session_name: sessionName || null,\n window_name: chosenWindowName(windowName, autoRename),\n };\n },\n\n // Which of this host's PANES still exist. The only liveness question tmux is\n // ever asked, and the one that matches how an agent is addressed: a pane keeps\n // its id when it moves between windows, so a recorded window id can be gone\n // while the agent is very much alive.\n //\n // null means tmux could not answer; an empty set means it did and there are\n // none. Conflating the two would delete every agent on the host the moment\n // tmux was briefly unreachable.\n livePanes() {\n const out = runTmux([\"list-panes\", \"-a\", \"-F\", \"#{pane_id}\"]);\n if (out === null) return null;\n return new Set(out.split(\"\\n\").filter(Boolean).map(asPaneId));\n },\n\n setWindowBadge(window, state) {\n if (state === null) {\n runTmux([\"set-window-option\", \"-qu\", \"-t\", window, \"@agent_state\"]);\n } else {\n runTmux([\"set-window-option\", \"-q\", \"-t\", window, \"@agent_state\", tmuxBadgeState(state)]);\n runTmux([\"set-window-option\", \"-q\", \"-t\", window, \"@pane_agent\", \"1\"]);\n }\n runTmux([\"refresh-client\", \"-S\"]);\n },\n\n attach(session, window) {\n // Two steps, because switch-client alone is a no-op when the target window\n // is in the session you are already attached to — which is the common case\n // for a local agent, and why \"enter\" appeared to do nothing.\n // switch-client moves the client between sessions; select-window moves\n // that session to the right window.\n //\n // Only select-window decides the result. switch-client legitimately fails\n // when there is no client to switch (running outside tmux), and treating\n // that as a failed jump would report an error for a working attach.\n runTmux([\"switch-client\", \"-t\", session]);\n return runTmux([\"select-window\", \"-t\", window]) !== null;\n },\n\n // Sibling panes, for deciding whether an unowned pane may clear the window's\n // badge. A window holding an agent and a shell must not lose the badge when\n // you focus the shell.\n panesInWindow(window) {\n const out = runTmux([\"list-panes\", \"-t\", window, \"-F\", \"#{pane_id}\"]);\n return out?.split(\"\\n\").filter(Boolean).map(asPaneId) ?? [];\n },\n\n // Which client to send home when the remote attach exits. `switch-client`\n // with no -c moves whichever client tmux considers current, and `murmur pick`\n // usually runs in a popup -- a client of its own, which dies with the popup.\n // Naming the real client is what lets the return outlive the picker.\n clientName() {\n return runTmux([\"display-message\", \"-p\", \"#{client_name}\"]) || null;\n },\n\n // Where the jump started, as a switch-client target. Window-level, not just\n // the session: coming back to the right session but the wrong window is\n // still the wrong place. The window id is stable where its index is not,\n // since renumber-windows renumbers on every close.\n currentTarget() {\n return runTmux([\"display-message\", \"-p\", \"#{session_name}:#{window_id}\"]) || null;\n },\n\n // Whether a wrapper session for this host already exists. Deliberately not\n // returning an id: a session is addressed by name, so a `#{session_id}` would\n // only have to be turned back into one.\n sessionNamed(name) {\n const out = runTmux([\"list-sessions\", \"-F\", \"#{session_name}\"]);\n if (out === null) return false;\n return out.split(\"\\n\").includes(name);\n },\n\n newSession(name, command) {\n // Detached, because the caller sets the per-session options before showing\n // it. Creating it attached would paint one frame with the local status bar\n // up and the local prefix live, which is the flicker this design exists to\n // remove.\n return runTmux([\"new-session\", \"-d\", \"-s\", name, command]) !== null;\n },\n\n setSessionOption(session, option, value) {\n runTmux([\"set-option\", \"-t\", exactPaneTarget(session), option, value]);\n },\n\n switchClient(client, session) {\n const target = exactSession(session);\n const args = client\n ? [\"switch-client\", \"-c\", client, \"-t\", target]\n : [\"switch-client\", \"-t\", target];\n return runTmux(args) !== null;\n },\n\n // The window a pane belongs to, for a pane murmur holds no row for. Clearing\n // a badge is a tmux operation and does not require murmur to own the pane.\n windowForPane(pane) {\n const out = runTmux([\"display-message\", \"-t\", pane, \"-p\", \"#{window_id}\"]);\n return out ? asWindowId(out) : null;\n },\n\n capture(pane, lines) {\n const args = [\"capture-pane\", \"-p\", \"-t\", pane];\n if (lines !== undefined) args.push(\"-S\", `-${lines}`);\n return runTmux(args);\n },\n};\n\nexport function pidAlive(pid: number): boolean {\n try {\n process.kill(pid, 0);\n return true;\n } catch (error) {\n return (error as NodeJS.ErrnoException).code !== \"ESRCH\";\n }\n}\n","import { createRequire } from \"node:module\";\n\n/**\n * This node's murmur version, read from the manifest.\n *\n * Read rather than restated, for the reason index.ts already gives: two copies\n * of one fact drift, and npm bumps the manifest. It lives in its own module\n * because THREE bundles need it and they sit at different depths --\n * `dist/index.js`, `dist/cli.js` and `dist/extension/store.js` -- so a single\n * hardcoded `\"../package.json\"` resolves in two of them and throws in the third.\n *\n * That is not hypothetical. `openStore` moved into the extension bundle during\n * the current-state rewrite, and its `../package.json` became\n * `dist/package.json`, which does not exist. The extension catches every store\n * failure and degrades to silence, so the symptom was an agent that reported\n * nothing at all, with no error anywhere -- exactly the failure mode the\n * three-state store handle exists to make survivable, hiding a hard one.\n *\n * Hence both candidates, tried in order, and a throw if neither works: a version\n * this node cannot state belongs in a snapshot even less than a wrong one does.\n */\nfunction readVersion(): string {\n const require = createRequire(import.meta.url);\n for (const candidate of [\"../package.json\", \"../../package.json\"]) {\n try {\n return (require(candidate) as { version: string }).version;\n } catch {\n // Wrong depth for this bundle; try the next.\n }\n }\n throw new Error(\"cannot locate package.json to read the murmur version\");\n}\n\nexport const MURMUR_VERSION: string = readVersion();\n","import type { NodeIdentity } from \"./identity.js\";\nimport type { PaneId, SessionId, WindowId } from \"./ids.js\";\nimport type { Store } from \"./store.js\";\nimport {\n type Activity,\n type AttentionKind,\n DEFAULT_DRIVER,\n type Driver,\n type SnapshotPane,\n} from \"./types.js\";\n\nexport type Freshness = \"fresh\" | \"stale\";\n\n/**\n * What a surface paints. Presentation only, derived from the three independent\n * facts and never stored.\n */\nexport type RenderState = \"crashed\" | \"blocked\" | \"done\" | \"running\" | \"idle\";\n\n/**\n * THE single ordering table: which state matters most, for sorting and for\n * choosing one word to show.\n *\n * `status.ts` and `pick.ts` import this rather than declaring their own copies,\n * so no two surfaces can sort one list differently.\n */\nexport const RENDER_PRIORITY: readonly RenderState[] = [\n \"crashed\",\n \"blocked\",\n \"done\",\n \"running\",\n \"idle\",\n];\n\n/**\n * The attention kinds only a human can answer, and the second table both\n * surfaces must agree on.\n *\n * `blocked` means waiting for an answer an orchestrator cannot give -- mu places\n * work, it cannot choose between two approaches. `crashed` means the process\n * died, which a supervisor may or may not retry. Everything else about an\n * orchestrated agent is its supervisor's business.\n *\n * `pick.ts` uses it to decide which crew rows are visible by default and\n * `status.ts` to decide which crew states reach the status bar. They were two\n * literals in two files answering one question, which is how a row that needed a\n * human became one a human could not see.\n */\nexport const NEEDS_HUMAN: readonly AttentionKind[] = [\"blocked\", \"crashed\"];\n\n/**\n * One pane, as every surface reads it: address, the three independent facts,\n * owner metadata, and ages.\n *\n * Local and remote panes are the same type, built by the same mapping, because\n * `Store.localPanes()` and a peer's cached snapshot both return\n * `SnapshotPane[]`. One mapping means local and remote cannot drift apart.\n */\nexport type PaneView = {\n // address\n host_id: string;\n /** The name the operator typed, or this node's display_name. */\n host: string;\n local: boolean;\n pane: PaneId;\n session: SessionId;\n window: WindowId;\n session_name: string | null;\n window_name: string | null;\n // the three independent facts\n /** Null for an attention-only pane, which has no agent row. */\n activity: Activity | null;\n attention: AttentionKind[];\n freshness: Freshness;\n // owner-reported metadata, null for an attention-only pane\n agent_id: string | null;\n agent_name: string | null;\n pi_session: string | null;\n workstream: string | null;\n role: string | null;\n cli: string | null;\n driver: Driver;\n // ages\n /** When the pane's own node last said something. Never `fetched_at`. */\n updated_at: number | null;\n /** When that node generated its snapshot. Null for local. */\n snapshot_at: number | null;\n /** When we last reached that node. Null for local. */\n fetched_at: number | null;\n};\n\n/**\n * How long a peer may go unfetched before its panes render stale.\n *\n * Re-exported from here rather than imported from the collector by view\n * consumers, so freshness has one definition. See collector.ts for why sixty\n * seconds.\n */\nexport const STALENESS_MS = 60_000;\n\n/**\n * A duration as the shortest thing worth reading: \"5m\", \"2h\", \"3d\".\n *\n * Under a minute is the empty string: an age that changes every second is noise\n * in a status column. This and `freshness` are the only two places a duration\n * becomes text or a verdict.\n */\nexport function age(ms: number | null): string {\n if (ms === null || ms < 60_000) return \"\";\n if (ms < 3_600_000) return `${Math.floor(ms / 60_000)}m`;\n if (ms < 86_400_000) return `${Math.floor(ms / 3_600_000)}h`;\n return `${Math.floor(ms / 86_400_000)}d`;\n}\n\n/**\n * Freshness of a NODE, never of an agent.\n *\n * A peer we have never reached is stale rather than fresh: null means the first\n * collect has not succeeded yet, and an unreachable host you just added must not\n * render as up to date.\n */\nexport function freshness(\n fetchedAt: number | null,\n now: number,\n thresholdMs = STALENESS_MS,\n): Freshness {\n return fetchedAt !== null && now - fetchedAt <= thresholdMs ? \"fresh\" : \"stale\";\n}\n\n/**\n * One word for a pane. Attention wins over activity, because attention is a\n * request and activity is a description.\n *\n * A running agent with `blocked` attention is a valid and expected state, and\n * surfaces that can show both, do — this is only for the ones that must pick.\n */\nexport function renderState(view: Pick<PaneView, \"activity\" | \"attention\">): RenderState {\n for (const kind of [\"crashed\", \"blocked\", \"done\"] as const) {\n if (view.attention.includes(kind)) return kind;\n }\n return view.activity === \"running\" ? \"running\" : \"idle\";\n}\n\n/** The newest attention request on a pane, for the `updated_at` of one with no agent. */\nfunction newestAttention(pane: SnapshotPane): number | null {\n let newest: number | null = null;\n for (const entry of pane.attention) {\n if (newest === null || entry.requested_at > newest) newest = entry.requested_at;\n }\n return newest;\n}\n\ntype ViewSource = {\n host_id: string;\n host: string;\n local: boolean;\n freshness: Freshness;\n snapshot_at: number | null;\n fetched_at: number | null;\n};\n\nfunction paneView(pane: SnapshotPane, source: ViewSource): PaneView {\n const agent = pane.agent;\n return {\n host_id: source.host_id,\n host: source.host,\n local: source.local,\n pane: pane.pane,\n session: pane.session,\n window: pane.window,\n session_name: pane.session_name,\n window_name: pane.window_name,\n activity: agent?.activity ?? null,\n attention: pane.attention.map((entry) => entry.kind),\n freshness: source.freshness,\n agent_id: agent?.agent_id ?? null,\n agent_name: agent?.agent_name ?? null,\n pi_session: agent?.pi_session ?? null,\n workstream: agent?.workstream ?? null,\n role: agent?.role ?? null,\n cli: agent?.cli ?? null,\n driver: agent?.driver ?? DEFAULT_DRIVER,\n updated_at: agent?.updated_at ?? newestAttention(pane),\n snapshot_at: source.snapshot_at,\n fetched_at: source.fetched_at,\n };\n}\n\n/**\n * Every pane this node knows about: its own, plus one cached snapshot per peer.\n *\n * `identity` is non-null because every caller is a command that already requires\n * `murmur init`, so no pane can be misclassified as remote by an absent one.\n *\n * No liveness is probed here, for local or remote. A remote pane's `activity` is\n * whatever its own node last said; a stale node keeps its last-known fields\n * verbatim beside an explicit warning.\n */\nexport function paneViews(store: Store, identity: NodeIdentity, now = Date.now()): PaneView[] {\n const views = store.localPanes().map((pane) =>\n paneView(pane, {\n host_id: identity.host_id,\n host: identity.display_name,\n local: true,\n // Local panes are always fresh: we are the node that authored them.\n freshness: \"fresh\",\n snapshot_at: null,\n fetched_at: null,\n }),\n );\n\n for (const peer of store.peers()) {\n const snapshot = peer.snapshot;\n if (!snapshot) continue;\n const source: ViewSource = {\n host_id: snapshot.host_id,\n // The name the human typed, not the machine's self-reported hostname: a\n // peer added as `linuxpc` can report a container id, which appears\n // nowhere else in the tool and cannot be typed at `peer remove`.\n host: peer.name,\n local: false,\n freshness: freshness(peer.fetched_at, now),\n snapshot_at: peer.snapshot_at,\n fetched_at: peer.fetched_at,\n };\n for (const pane of snapshot.panes) views.push(paneView(pane, source));\n }\n\n return views;\n}\n\nconst ORDER = new Map<RenderState, number>(RENDER_PRIORITY.map((state, index) => [state, index]));\n\n/**\n * Attention-first ordering, then the newest news, then address.\n *\n * TOTAL on purpose, and that is the whole reason the last two comparisons\n * exist. Ties on state and age are ordinary rather than exotic -- a pair of\n * crashed panes reconciled in one transaction shares a `requested_at` exactly --\n * and `Array.prototype.sort` is stable only with respect to the order it was\n * GIVEN, which here is whatever SQLite and the peer loop happened to produce. An\n * unbroken tie therefore makes the list depend on that order: a status bar\n * reshuffles between two identical ticks, and a picker row moves under the\n * keypress that was aimed at it.\n *\n * Presentation only. No caller may read meaning into the position of a row --\n * pane order in a snapshot carries none either, so a reader sorts for itself\n * rather than trusting what it was served.\n */\nexport function viewSort(views: PaneView[]): PaneView[] {\n return [...views].sort((left, right) => {\n const byState = (ORDER.get(renderState(left)) ?? 99) - (ORDER.get(renderState(right)) ?? 99);\n if (byState !== 0) return byState;\n // Unknown age sorts last within its state: an attention-only pane with no\n // timestamp is not news, and 0 is older than any real clock reading.\n const byAge = (right.updated_at ?? 0) - (left.updated_at ?? 0);\n if (byAge !== 0) return byAge;\n // Address as the final key, because it is the only field guaranteed unique\n // across the whole view: `pane` is unique per node and `host` per peer.\n const byHost = left.host.localeCompare(right.host);\n return byHost !== 0 ? byHost : left.pane.localeCompare(right.pane);\n });\n}\n"],"mappings":"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","names":["join","join","randomUUID","mkdirSync","require","mkdirSync","agentId","randomUUID"]}
|