@martintrojer/murmur 0.1.3 → 0.1.4

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.
@@ -66,7 +66,7 @@ var tmux = {
66
66
  },
67
67
  attach(session, window) {
68
68
  runTmux(["switch-client", "-t", session]);
69
- runTmux(["select-window", "-t", window]);
69
+ return runTmux(["select-window", "-t", window]) !== null;
70
70
  },
71
71
  // Window ids are what the log stores, because they are stable; names are
72
72
  // what a human recognises in a picker. Names are live tmux state, not
@@ -98,7 +98,10 @@ var tmux = {
98
98
  return null;
99
99
  },
100
100
  selectWindow(window) {
101
- runTmux(["select-window", "-t", window]);
101
+ return runTmux(["select-window", "-t", window]) !== null;
102
+ },
103
+ newWindow(name, command) {
104
+ return runTmux(["new-window", "-n", name, command]) !== null;
102
105
  },
103
106
  // The window a pane belongs to, for a pane murmur has no event for. Clearing
104
107
  // a badge is a tmux operation and does not require murmur to own the pane.
@@ -175,6 +178,13 @@ function murmurPi(pi) {
175
178
  return store;
176
179
  }
177
180
  };
181
+ const dropStore = () => {
182
+ try {
183
+ store?.close();
184
+ } catch {
185
+ }
186
+ store = null;
187
+ };
178
188
  const append = async (state, pid) => {
179
189
  try {
180
190
  const currentStore = await getStore();
@@ -203,7 +213,7 @@ function murmurPi(pi) {
203
213
  extra: {}
204
214
  });
205
215
  } catch {
206
- store = null;
216
+ dropStore();
207
217
  }
208
218
  };
209
219
  pi.on("agent_start", () => {
@@ -223,11 +233,7 @@ function murmurPi(pi) {
223
233
  await enqueue(async () => {
224
234
  tmux.setState(location.window, null);
225
235
  await append("cleared", null);
226
- try {
227
- store?.close();
228
- } catch {
229
- }
230
- store = null;
236
+ dropStore();
231
237
  });
232
238
  });
233
239
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/extension/murmur-pi.ts","../../src/mux.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 { AgentState } from \"../types.js\";\nimport { driverFromEnv, endState } 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.\ntype ExtensionAPI = {\n on(\n event: \"agent_start\" | \"agent_end\" | \"session_shutdown\",\n handler: () => void | Promise<void>,\n ): void;\n getSessionName?(): string | undefined;\n};\n\n// `murmur link pi` rewrites this line to an absolute path at install time.\n// The bare specifier only resolves when murmur is a dependency of the importer,\n// which it never is: the extension is copied into ~/.pi/agent/extensions, and a\n// globally linked or installed murmur is not resolvable from there. Falling\n// back to the bare specifier keeps a hand-copied extension working inside a\n// project that does depend on murmur.\nconst storeModule = \"@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 an event 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 location = tmux.currentWindow();\n if (!location) return;\n\n let store: Store | null | undefined;\n let hostId: 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 getStore = async (): Promise<Store | null> => {\n if (store !== undefined) return store;\n try {\n const { loadIdentity, openStore } = (await import(storeModule)) as StoreModule;\n hostId = loadIdentity()?.host_id ?? null;\n if (!hostId) {\n store = null;\n return store;\n }\n store = openStore();\n return store;\n } catch {\n store = null;\n return store;\n }\n };\n\n const append = async (state: AgentState, pid: number | null): Promise<void> => {\n try {\n const currentStore = await getStore();\n if (!currentStore || !hostId) return;\n currentStore.append({\n agent_id: `${hostId}:${location.pane}`,\n session: location.session,\n window: location.window,\n pane: location.pane,\n session_name: location.session_name,\n window_name: location.window_name,\n // mu names its agents; pi names its sessions. Both beat a window name\n // when 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 kind: \"state\",\n state,\n message: \"\",\n pid,\n synthetic: false,\n reason: \"\",\n extra: {},\n });\n } catch {\n store = null;\n }\n };\n\n pi.on(\"agent_start\", () => {\n void enqueue(async () => {\n tmux.setState(location.window, \"working\");\n await append(\"working\", process.pid);\n });\n });\n\n pi.on(\"agent_end\", () => {\n void enqueue(async () => {\n const state = endState(focused(location.pane), muManaged);\n tmux.setState(location.window, state === \"cleared\" ? null : state);\n await append(state, null);\n });\n });\n\n pi.on(\"session_shutdown\", async () => {\n await enqueue(async () => {\n tmux.setState(location.window, null);\n await append(\"cleared\", null);\n try {\n store?.close();\n } catch {\n // Best effort: extension failures must never reach pi.\n }\n store = null;\n });\n });\n}\n","import { execFileSync } from \"node:child_process\";\nimport type { AgentState } from \"./types.js\";\n\nexport type Location = {\n session: string;\n window: string;\n pane: string;\n session_name: string | null;\n window_name: string | null;\n};\n\nexport interface Mux {\n currentWindow(): Location | null;\n liveWindows(): Set<string> | null;\n setState(window: string, state: AgentState | null): void;\n attach(session: string, window: string): void;\n windowNames(): Map<string, string>;\n windowForPane(pane: string): string | null;\n panesInWindow(window: string): string[];\n windowNamed(name: string): string | null;\n selectWindow(window: string): void;\n capture(pane: string, lines?: number): string | null;\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\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 pane = process.env.TMUX_PANE;\n if (!pane) return null;\n\n // One call for ids and names together. The names are recorded on every\n // event because a reader cannot resolve a remote window id against its own\n // tmux, so they have to travel with the event.\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,\n window,\n pane,\n session_name: sessionName || null,\n window_name: windowName || null,\n };\n },\n\n // Which of this host's windows still exist. Only the authoring node can\n // answer this, which is why the check runs on export rather than on the\n // reader: a peer holding a `blocked` row for a window that died has nothing\n // to supersede it, and the agent stays in every HUD forever.\n //\n // null means \"could not tell\" (no tmux server, tmux missing) and is\n // deliberately distinct from an empty set, which means \"tmux answered, and\n // there are no windows\". Treating the first as the second would clear every\n // agent on the host the moment tmux was unreachable.\n //\n // Unlike currentWindow, this deliberately asks tmux rather than reading the\n // environment, and it is right to: \"which windows exist on this host\" is a\n // server-wide question with one answer, and export runs over ssh with no\n // pane of its own. currentWindow asks \"which pane am I in\", which only\n // $TMUX_PANE can answer.\n liveWindows() {\n const out = runTmux([\"list-windows\", \"-a\", \"-F\", \"#{window_id}\"]);\n if (out === null) return null;\n return new Set(out.split(\"\\n\").filter(Boolean));\n },\n\n setState(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\", 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 runTmux([\"switch-client\", \"-t\", session]);\n runTmux([\"select-window\", \"-t\", window]);\n },\n\n // Window ids are what the log stores, because they are stable; names are\n // what a human recognises in a picker. Names are live tmux state, not\n // history, so they are resolved at render time rather than recorded.\n windowNames() {\n const out = runTmux([\"list-windows\", \"-a\", \"-F\", \"#{window_id}\\t#{window_name}\"]);\n const names = new Map<string, string>();\n for (const line of out?.split(\"\\n\") ?? []) {\n const [id, name] = line.split(\"\\t\");\n if (id && name) names.set(id, name);\n }\n return names;\n },\n\n // First window carrying this exact name, or null. Used to reuse a per-host\n // ssh window instead of opening another one.\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) ?? [];\n },\n\n windowNamed(name) {\n const out = runTmux([\"list-windows\", \"-a\", \"-F\", \"#{window_id}\\t#{window_name}\"]);\n for (const line of out?.split(\"\\n\") ?? []) {\n const [id, windowName] = line.split(\"\\t\");\n if (id && windowName === name) return id;\n }\n return null;\n },\n\n selectWindow(window) {\n runTmux([\"select-window\", \"-t\", window]);\n },\n\n // The window a pane belongs to, for a pane murmur has no event for. Clearing\n // a badge is a tmux operation and does not require murmur to own the pane.\n windowForPane(pane) {\n return runTmux([\"display-message\", \"-t\", pane, \"-p\", \"#{window_id}\"]) || 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 type { Driver } from \"../types.js\";\n\nexport function endState(focused: boolean, muManaged: boolean): \"cleared\" | \"done\" {\n if (muManaged) return \"cleared\";\n return focused ? \"cleared\" : \"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"]}
1
+ {"version":3,"sources":["../../src/extension/murmur-pi.ts","../../src/mux.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 { AgentState } from \"../types.js\";\nimport { driverFromEnv, endState } 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.\ntype ExtensionAPI = {\n on(\n event: \"agent_start\" | \"agent_end\" | \"session_shutdown\",\n handler: () => void | Promise<void>,\n ): void;\n getSessionName?(): string | undefined;\n};\n\n// `murmur link pi` rewrites this line to an absolute path at install time.\n// The bare specifier only resolves when murmur is a dependency of the importer,\n// which it never is: the extension is copied into ~/.pi/agent/extensions, and a\n// globally linked or installed murmur is not resolvable from there. Falling\n// back to the bare specifier keeps a hand-copied extension working inside a\n// project that does depend on murmur.\nconst storeModule = \"@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 an event 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 location = tmux.currentWindow();\n if (!location) return;\n\n let store: Store | null | undefined;\n let hostId: 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 getStore = async (): Promise<Store | null> => {\n if (store !== undefined) return store;\n try {\n const { loadIdentity, openStore } = (await import(storeModule)) as StoreModule;\n hostId = loadIdentity()?.host_id ?? null;\n if (!hostId) {\n store = null;\n return store;\n }\n store = openStore();\n return store;\n } catch {\n store = null;\n return store;\n }\n };\n\n // Drop the cached handle, closing it first. The catch in `append` used to\n // just assign null, which left an open SQLite connection to garbage\n // collection while the next event opened another one -- so a peer with a\n // recurring transient write failure leaked a connection and its WAL read\n // state per event, inside a pi process that can run for days. Shared with\n // session_shutdown so there is one way to let go of the store.\n const dropStore = (): void => {\n try {\n store?.close();\n } catch {\n // Best effort: extension failures must never reach pi.\n }\n store = null;\n };\n\n const append = async (state: AgentState, pid: number | null): Promise<void> => {\n try {\n const currentStore = await getStore();\n if (!currentStore || !hostId) return;\n currentStore.append({\n agent_id: `${hostId}:${location.pane}`,\n session: location.session,\n window: location.window,\n pane: location.pane,\n session_name: location.session_name,\n window_name: location.window_name,\n // mu names its agents; pi names its sessions. Both beat a window name\n // when 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 kind: \"state\",\n state,\n message: \"\",\n pid,\n synthetic: false,\n reason: \"\",\n extra: {},\n });\n } catch {\n dropStore();\n }\n };\n\n pi.on(\"agent_start\", () => {\n void enqueue(async () => {\n tmux.setState(location.window, \"working\");\n await append(\"working\", process.pid);\n });\n });\n\n pi.on(\"agent_end\", () => {\n void enqueue(async () => {\n const state = endState(focused(location.pane), muManaged);\n tmux.setState(location.window, state === \"cleared\" ? null : state);\n await append(state, null);\n });\n });\n\n pi.on(\"session_shutdown\", async () => {\n await enqueue(async () => {\n tmux.setState(location.window, null);\n await append(\"cleared\", null);\n dropStore();\n });\n });\n}\n","import { execFileSync } from \"node:child_process\";\nimport type { AgentState } from \"./types.js\";\n\nexport type Location = {\n session: string;\n window: string;\n pane: string;\n session_name: string | null;\n window_name: string | null;\n};\n\nexport interface Mux {\n currentWindow(): Location | null;\n liveWindows(): Set<string> | null;\n setState(window: string, state: AgentState | 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: string, window: string): boolean;\n windowNames(): Map<string, string>;\n windowForPane(pane: string): string | null;\n panesInWindow(window: string): string[];\n windowNamed(name: string): string | null;\n selectWindow(window: string): boolean;\n newWindow(name: string, command: string): boolean;\n capture(pane: string, lines?: number): string | null;\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\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 pane = process.env.TMUX_PANE;\n if (!pane) return null;\n\n // One call for ids and names together. The names are recorded on every\n // event because a reader cannot resolve a remote window id against its own\n // tmux, so they have to travel with the event.\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,\n window,\n pane,\n session_name: sessionName || null,\n window_name: windowName || null,\n };\n },\n\n // Which of this host's windows still exist. Only the authoring node can\n // answer this, which is why the check runs on export rather than on the\n // reader: a peer holding a `blocked` row for a window that died has nothing\n // to supersede it, and the agent stays in every HUD forever.\n //\n // null means \"could not tell\" (no tmux server, tmux missing) and is\n // deliberately distinct from an empty set, which means \"tmux answered, and\n // there are no windows\". Treating the first as the second would clear every\n // agent on the host the moment tmux was unreachable.\n //\n // Unlike currentWindow, this deliberately asks tmux rather than reading the\n // environment, and it is right to: \"which windows exist on this host\" is a\n // server-wide question with one answer, and export runs over ssh with no\n // pane of its own. currentWindow asks \"which pane am I in\", which only\n // $TMUX_PANE can answer.\n liveWindows() {\n const out = runTmux([\"list-windows\", \"-a\", \"-F\", \"#{window_id}\"]);\n if (out === null) return null;\n return new Set(out.split(\"\\n\").filter(Boolean));\n },\n\n setState(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\", 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 // Window ids are what the log stores, because they are stable; names are\n // what a human recognises in a picker. Names are live tmux state, not\n // history, so they are resolved at render time rather than recorded.\n windowNames() {\n const out = runTmux([\"list-windows\", \"-a\", \"-F\", \"#{window_id}\\t#{window_name}\"]);\n const names = new Map<string, string>();\n for (const line of out?.split(\"\\n\") ?? []) {\n const [id, name] = line.split(\"\\t\");\n if (id && name) names.set(id, name);\n }\n return names;\n },\n\n // First window carrying this exact name, or null. Used to reuse a per-host\n // ssh window instead of opening another one.\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) ?? [];\n },\n\n windowNamed(name) {\n const out = runTmux([\"list-windows\", \"-a\", \"-F\", \"#{window_id}\\t#{window_name}\"]);\n for (const line of out?.split(\"\\n\") ?? []) {\n const [id, windowName] = line.split(\"\\t\");\n if (id && windowName === name) return id;\n }\n return null;\n },\n\n selectWindow(window) {\n return runTmux([\"select-window\", \"-t\", window]) !== null;\n },\n\n newWindow(name, command) {\n return runTmux([\"new-window\", \"-n\", name, command]) !== null;\n },\n\n // The window a pane belongs to, for a pane murmur has no event for. Clearing\n // a badge is a tmux operation and does not require murmur to own the pane.\n windowForPane(pane) {\n return runTmux([\"display-message\", \"-t\", pane, \"-p\", \"#{window_id}\"]) || 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 type { Driver } from \"../types.js\";\n\nexport function endState(focused: boolean, muManaged: boolean): \"cleared\" | \"done\" {\n if (muManaged) return \"cleared\";\n return focused ? \"cleared\" : \"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"]}
@@ -195,6 +195,14 @@ function openStore() {
195
195
  const rows = database.prepare("SELECT * FROM events ORDER BY ts, host_id, seq").all();
196
196
  return rows.map(toEvent);
197
197
  },
198
+ latestForAgent(hostId, agentId) {
199
+ const row = database.prepare(
200
+ `SELECT * FROM events
201
+ WHERE host_id = ? AND agent_id = ?
202
+ ORDER BY seq DESC LIMIT 1`
203
+ ).get(hostId, agentId);
204
+ return row ? toEvent(row) : null;
205
+ },
198
206
  maxSeq(hostId) {
199
207
  return selectMaxSeq.get(hostId).seq;
200
208
  },
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/identity.ts","../../src/paths.ts","../../src/store.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\nexport function loadIdentity(): NodeIdentity | null {\n const path = join(stateDir(), \"identity.json\");\n return existsSync(path) ? JSON.parse(readFileSync(path, \"utf8\")) : null;\n}\n\nexport function ensureIdentity(displayName = hostname()): NodeIdentity {\n const existing = loadIdentity();\n if (existing) return existing;\n\n const identity = { host_id: randomUUID(), display_name: displayName };\n mkdirSync(stateDir(), { recursive: true });\n writeFileSync(join(stateDir(), \"identity.json\"), `${JSON.stringify(identity, null, 2)}\\n`);\n return identity;\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\nexport function dbPath(): string {\n return join(stateDir(), \"events.db\");\n}\n","import { rmSync } from \"node:fs\";\nimport Database from \"better-sqlite3\";\nimport { ensureIdentity } from \"./identity.js\";\nimport { dbPath } from \"./paths.js\";\nimport type { Driver, Event, Peer } from \"./types.js\";\n\nconst DEFAULT_RETENTION_MS = 7 * 86_400_000;\n\n/**\n * Local storage shape. Bump on any change to the events or peers tables.\n *\n * Distinct from `SCHEMA_VERSION` in export.ts, which versions the *wire*: a\n * node can change how it stores events without changing what it sends, and a\n * wire change should not throw away local history.\n */\nexport const STORE_VERSION = 2;\n\n/**\n * Migration strategy: there isn't one. A version mismatch deletes the database\n * and starts again.\n *\n * This is only acceptable because nothing in events.db is authoritative or\n * irreplaceable. It is a bounded-retention observability log: remote events\n * re-sync from their authoring peer on the next collect, local agents re-report\n * on their next state change, and node identity deliberately lives in a\n * separate file. If anything durable is ever added here, this stops being safe\n * and a real migration is required.\n *\n * Peers survive, because they are the one thing a human typed. Watermarks are\n * reset with the events they indexed -- keeping them would skip the events the\n * new database no longer has -- and re-reading a peer from zero is free, since\n * ingest is idempotent.\n */\nfunction resetIfStale(path: string): Peer[] {\n let salvaged: Peer[] = [];\n try {\n const existing = new Database(path, { fileMustExist: true });\n const version = (existing.pragma(\"user_version\", { simple: true }) as number) ?? 0;\n if (version === STORE_VERSION) {\n existing.close();\n return salvaged;\n }\n try {\n salvaged = existing\n .prepare(\"SELECT name, target, host_id, display_name FROM peers\")\n .all() as Peer[];\n } catch {\n // Old enough not to have the table, or unreadable. Nothing to save.\n }\n existing.close();\n } catch {\n // No database yet, or one too broken to open. Either way, recreate.\n return salvaged;\n }\n\n // -wal and -shm must go too: a stale sidecar against a fresh main file is a\n // documented way to corrupt sqlite.\n for (const suffix of [\"\", \"-wal\", \"-shm\"]) rmSync(`${path}${suffix}`, { force: true });\n return salvaged;\n}\n\n// The name fields are optional on the way in: a caller that has no name for a\n// thing should not have to say `null` four times, and a non-tmux harness has\n// none of them. They are non-optional on `Event` itself, so a reader never has\n// to distinguish absent from null.\nexport type NewEvent = Omit<\n Event,\n \"host_id\" | \"seq\" | \"ts\" | \"session_name\" | \"window_name\" | \"agent_name\" | \"pi_session\"\n> & {\n ts?: number;\n session_name?: string | null;\n window_name?: string | null;\n agent_name?: string | null;\n pi_session?: string | null;\n};\n\ntype EventRow = Omit<Event, \"synthetic\" | \"extra\"> & {\n synthetic: number;\n extra: string;\n};\n\nfunction eventValues(event: Event): unknown[] {\n return [\n event.host_id,\n event.seq,\n event.ts,\n event.agent_id,\n event.session,\n event.window,\n event.pane,\n event.session_name,\n event.window_name,\n event.agent_name,\n event.pi_session,\n event.workstream,\n event.role,\n event.cli,\n event.driver,\n event.kind,\n event.state,\n event.message,\n event.pid,\n Number(event.synthetic),\n event.reason,\n JSON.stringify(event.extra),\n ];\n}\n\nfunction toEvent(row: EventRow): Event {\n return {\n ...row,\n driver: row.driver as Driver | null,\n synthetic: row.synthetic === 1,\n extra: JSON.parse(row.extra) as Record<string, unknown>,\n };\n}\n\nexport interface Store {\n append(event: NewEvent): Event;\n ingest(events: Event[]): number;\n eventsSince(hostId: string, seq: number): Event[];\n allEvents(): Event[];\n maxSeq(hostId: string): number;\n prune(horizonMs?: number): number;\n peers(): Peer[];\n /**\n * Drop every event for one agent from this node's replica.\n *\n * For a remote agent this is a replica eviction, not a claim about truth: the\n * authoring node still owns it, and a collect re-reads from the watermark if\n * it is still alive.\n */\n forgetAgent(agentId: string): number;\n forgetHost(hostId: string): number;\n upsertPeer(peer: Partial<Peer> & { name: string; target: string }): void;\n removePeer(name: string): boolean;\n close(): void;\n}\n\nexport function openStore(): Store {\n const identity = ensureIdentity();\n const path = dbPath();\n const salvagedPeers = resetIfStale(path);\n const database = new Database(path);\n database.pragma(\"journal_mode = WAL\");\n database.pragma(`user_version = ${STORE_VERSION}`);\n database.exec(`\n CREATE TABLE IF NOT EXISTS events (\n host_id TEXT NOT NULL,\n seq INTEGER NOT NULL,\n ts INTEGER NOT NULL,\n agent_id TEXT NOT NULL,\n session TEXT NOT NULL,\n window TEXT NOT NULL,\n pane 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,\n driver TEXT,\n kind TEXT NOT NULL,\n state TEXT NOT NULL,\n message TEXT NOT NULL,\n pid INTEGER,\n synthetic INTEGER NOT NULL,\n reason TEXT NOT NULL,\n extra TEXT NOT NULL,\n PRIMARY KEY (host_id, seq)\n );\n CREATE INDEX IF NOT EXISTS events_agent_seq ON events (agent_id, seq);\n CREATE TABLE IF NOT EXISTS peers (\n name TEXT PRIMARY KEY,\n target TEXT NOT NULL,\n host_id TEXT,\n display_name TEXT,\n watermark INTEGER NOT NULL,\n fetched_at INTEGER,\n -- When a jump last proved this peer's tmux was not answering. Reader\n -- state, not an event: this node cannot author facts about another\n -- node's agents, and a jump is a local observation, not something the\n -- peer said. Cleared by the next successful collect.\n tmux_down_at INTEGER\n );\n `);\n\n // Additive migration: an existing peers table predates tmux_down_at.\n try {\n database.exec(\"ALTER TABLE peers ADD COLUMN tmux_down_at INTEGER\");\n } catch {\n // Already present.\n }\n\n // Put back the peers the wipe took, at watermark 0 so the next collect\n // re-reads each one from the start.\n if (salvagedPeers.length > 0) {\n const restore = database.prepare(\n `INSERT OR IGNORE INTO peers (name, target, host_id, display_name, watermark, fetched_at)\n VALUES (?, ?, ?, ?, 0, NULL)`,\n );\n for (const peer of salvagedPeers) {\n restore.run(peer.name, peer.target, peer.host_id ?? null, peer.display_name ?? null);\n }\n }\n\n const eventColumns = `\n host_id, seq, ts, agent_id, session, window, pane,\n session_name, window_name, agent_name, pi_session,\n workstream, role, cli, driver, kind, state, message, pid,\n synthetic, reason, extra`;\n const eventPlaceholders = new Array(22).fill(\"?\").join(\", \");\n const insertEvent = database.prepare(\n `INSERT INTO events (${eventColumns}) VALUES (${eventPlaceholders})`,\n );\n const ingestEvent = database.prepare(\n `INSERT OR IGNORE INTO events (${eventColumns}) VALUES (${eventPlaceholders})`,\n );\n const selectMaxSeq = database.prepare(\n \"SELECT COALESCE(MAX(seq), 0) AS seq FROM events WHERE host_id = ?\",\n );\n const append = database.transaction((event: NewEvent): Event => {\n const row = selectMaxSeq.get(identity.host_id) as { seq: number };\n const stored: Event = {\n ...event,\n host_id: identity.host_id,\n seq: row.seq + 1,\n ts: event.ts ?? Date.now(),\n session_name: event.session_name ?? null,\n window_name: event.window_name ?? null,\n agent_name: event.agent_name ?? null,\n pi_session: event.pi_session ?? null,\n };\n insertEvent.run(...eventValues(stored));\n return stored;\n });\n const ingest = database.transaction((events: Event[]): number => {\n let inserted = 0;\n for (const event of events) inserted += ingestEvent.run(...eventValues(event)).changes;\n return inserted;\n });\n\n return {\n append,\n ingest,\n eventsSince(hostId, seq) {\n const rows = database\n .prepare(\"SELECT * FROM events WHERE host_id = ? AND seq > ? ORDER BY seq\")\n .all(hostId, seq) as EventRow[];\n return rows.map(toEvent);\n },\n allEvents() {\n const rows = database\n .prepare(\"SELECT * FROM events ORDER BY ts, host_id, seq\")\n .all() as EventRow[];\n return rows.map(toEvent);\n },\n maxSeq(hostId) {\n return (selectMaxSeq.get(hostId) as { seq: number }).seq;\n },\n prune(horizonMs = Number(process.env.MURMUR_RETENTION_MS ?? DEFAULT_RETENTION_MS)) {\n return database\n .prepare(`\n DELETE FROM events\n WHERE ts < ?\n AND (host_id, seq) NOT IN (\n SELECT host_id, seq FROM (\n SELECT host_id, seq,\n ROW_NUMBER() OVER (PARTITION BY agent_id ORDER BY ts DESC, seq DESC) rn\n FROM events\n ) WHERE rn = 1\n )\n `)\n .run(Date.now() - horizonMs).changes;\n },\n peers() {\n return database.prepare(\"SELECT * FROM peers ORDER BY name\").all() as Peer[];\n },\n forgetAgent(agentId) {\n return database.prepare(\"DELETE FROM events WHERE agent_id = ?\").run(agentId).changes;\n },\n forgetHost(hostId) {\n // Every replicated row for one origin node. Only ever called about a\n // REMOTE host: the local host's rows are this node's own authorship and\n // the retention horizon owns them.\n return database.prepare(\"DELETE FROM events WHERE host_id = ?\").run(hostId).changes;\n },\n upsertPeer(peer) {\n const current = database.prepare(\"SELECT * FROM peers WHERE name = ?\").get(peer.name) as\n | Peer\n | undefined;\n database\n .prepare(`\n INSERT INTO peers (name, target, host_id, display_name, watermark, fetched_at, tmux_down_at)\n VALUES (?, ?, ?, ?, ?, ?, ?)\n ON CONFLICT(name) DO UPDATE SET\n target = excluded.target,\n host_id = excluded.host_id,\n display_name = excluded.display_name,\n watermark = excluded.watermark,\n fetched_at = excluded.fetched_at,\n tmux_down_at = excluded.tmux_down_at\n `)\n .run(\n peer.name,\n peer.target,\n peer.host_id !== undefined ? peer.host_id : (current?.host_id ?? null),\n peer.display_name !== undefined ? peer.display_name : (current?.display_name ?? null),\n peer.watermark !== undefined ? peer.watermark : (current?.watermark ?? 0),\n peer.fetched_at !== undefined ? peer.fetched_at : (current?.fetched_at ?? null),\n peer.tmux_down_at !== undefined ? peer.tmux_down_at : (current?.tmux_down_at ?? null),\n );\n },\n removePeer(name) {\n // Drops the peer and its watermark. Replicated events stay: they are\n // real history authored elsewhere, and the retention horizon already\n // ages them out. Re-adding the peer re-syncs from zero, which ingest\n // makes free.\n return database.prepare(\"DELETE FROM peers WHERE name = ?\").run(name).changes > 0;\n },\n close() {\n database.close();\n },\n };\n}\n"],"mappings":"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","names":["join","join"]}
1
+ {"version":3,"sources":["../../src/identity.ts","../../src/paths.ts","../../src/store.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\nexport function loadIdentity(): NodeIdentity | null {\n const path = join(stateDir(), \"identity.json\");\n return existsSync(path) ? JSON.parse(readFileSync(path, \"utf8\")) : null;\n}\n\nexport function ensureIdentity(displayName = hostname()): NodeIdentity {\n const existing = loadIdentity();\n if (existing) return existing;\n\n const identity = { host_id: randomUUID(), display_name: displayName };\n mkdirSync(stateDir(), { recursive: true });\n writeFileSync(join(stateDir(), \"identity.json\"), `${JSON.stringify(identity, null, 2)}\\n`);\n return identity;\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\nexport function dbPath(): string {\n return join(stateDir(), \"events.db\");\n}\n","import { rmSync } from \"node:fs\";\nimport Database from \"better-sqlite3\";\nimport { ensureIdentity } from \"./identity.js\";\nimport { dbPath } from \"./paths.js\";\nimport type { Driver, Event, Peer } from \"./types.js\";\n\nconst DEFAULT_RETENTION_MS = 7 * 86_400_000;\n\n/**\n * Local storage shape. Bump on any change to the events or peers tables.\n *\n * Distinct from `SCHEMA_VERSION` in export.ts, which versions the *wire*: a\n * node can change how it stores events without changing what it sends, and a\n * wire change should not throw away local history.\n */\nexport const STORE_VERSION = 2;\n\n/**\n * Migration strategy: there isn't one. A version mismatch deletes the database\n * and starts again.\n *\n * This is only acceptable because nothing in events.db is authoritative or\n * irreplaceable. It is a bounded-retention observability log: remote events\n * re-sync from their authoring peer on the next collect, local agents re-report\n * on their next state change, and node identity deliberately lives in a\n * separate file. If anything durable is ever added here, this stops being safe\n * and a real migration is required.\n *\n * Peers survive, because they are the one thing a human typed. Watermarks are\n * reset with the events they indexed -- keeping them would skip the events the\n * new database no longer has -- and re-reading a peer from zero is free, since\n * ingest is idempotent.\n */\nfunction resetIfStale(path: string): Peer[] {\n let salvaged: Peer[] = [];\n try {\n const existing = new Database(path, { fileMustExist: true });\n const version = (existing.pragma(\"user_version\", { simple: true }) as number) ?? 0;\n if (version === STORE_VERSION) {\n existing.close();\n return salvaged;\n }\n try {\n salvaged = existing\n .prepare(\"SELECT name, target, host_id, display_name FROM peers\")\n .all() as Peer[];\n } catch {\n // Old enough not to have the table, or unreadable. Nothing to save.\n }\n existing.close();\n } catch {\n // No database yet, or one too broken to open. Either way, recreate.\n return salvaged;\n }\n\n // -wal and -shm must go too: a stale sidecar against a fresh main file is a\n // documented way to corrupt sqlite.\n for (const suffix of [\"\", \"-wal\", \"-shm\"]) rmSync(`${path}${suffix}`, { force: true });\n return salvaged;\n}\n\n// The name fields are optional on the way in: a caller that has no name for a\n// thing should not have to say `null` four times, and a non-tmux harness has\n// none of them. They are non-optional on `Event` itself, so a reader never has\n// to distinguish absent from null.\nexport type NewEvent = Omit<\n Event,\n \"host_id\" | \"seq\" | \"ts\" | \"session_name\" | \"window_name\" | \"agent_name\" | \"pi_session\"\n> & {\n ts?: number;\n session_name?: string | null;\n window_name?: string | null;\n agent_name?: string | null;\n pi_session?: string | null;\n};\n\ntype EventRow = Omit<Event, \"synthetic\" | \"extra\"> & {\n synthetic: number;\n extra: string;\n};\n\nfunction eventValues(event: Event): unknown[] {\n return [\n event.host_id,\n event.seq,\n event.ts,\n event.agent_id,\n event.session,\n event.window,\n event.pane,\n event.session_name,\n event.window_name,\n event.agent_name,\n event.pi_session,\n event.workstream,\n event.role,\n event.cli,\n event.driver,\n event.kind,\n event.state,\n event.message,\n event.pid,\n Number(event.synthetic),\n event.reason,\n JSON.stringify(event.extra),\n ];\n}\n\nfunction toEvent(row: EventRow): Event {\n return {\n ...row,\n driver: row.driver as Driver | null,\n synthetic: row.synthetic === 1,\n extra: JSON.parse(row.extra) as Record<string, unknown>,\n };\n}\n\nexport interface Store {\n append(event: NewEvent): Event;\n ingest(events: Event[]): number;\n eventsSince(hostId: string, seq: number): Event[];\n allEvents(): Event[];\n /**\n * The most recent event for one agent, or null.\n *\n * Exists so the `clear` hook does not have to open its own SQLite handle and\n * write its own `ORDER BY seq DESC LIMIT 1`, which is what it used to do --\n * making \"store is the only module touching SQL\" false, and putting knowledge\n * of agent_id construction and event ordering in a CLI file where a schema\n * change would miss it. That path swallows its own errors, so the miss would\n * have been silent.\n */\n latestForAgent(hostId: string, agentId: string): Event | null;\n maxSeq(hostId: string): number;\n prune(horizonMs?: number): number;\n peers(): Peer[];\n /**\n * Drop every event for one agent from this node's replica.\n *\n * For a remote agent this is a replica eviction, not a claim about truth: the\n * authoring node still owns it, and a collect re-reads from the watermark if\n * it is still alive.\n */\n forgetAgent(agentId: string): number;\n forgetHost(hostId: string): number;\n upsertPeer(peer: Partial<Peer> & { name: string; target: string }): void;\n removePeer(name: string): boolean;\n close(): void;\n}\n\nexport function openStore(): Store {\n const identity = ensureIdentity();\n const path = dbPath();\n const salvagedPeers = resetIfStale(path);\n const database = new Database(path);\n database.pragma(\"journal_mode = WAL\");\n database.pragma(`user_version = ${STORE_VERSION}`);\n database.exec(`\n CREATE TABLE IF NOT EXISTS events (\n host_id TEXT NOT NULL,\n seq INTEGER NOT NULL,\n ts INTEGER NOT NULL,\n agent_id TEXT NOT NULL,\n session TEXT NOT NULL,\n window TEXT NOT NULL,\n pane 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,\n driver TEXT,\n kind TEXT NOT NULL,\n state TEXT NOT NULL,\n message TEXT NOT NULL,\n pid INTEGER,\n synthetic INTEGER NOT NULL,\n reason TEXT NOT NULL,\n extra TEXT NOT NULL,\n PRIMARY KEY (host_id, seq)\n );\n CREATE INDEX IF NOT EXISTS events_agent_seq ON events (agent_id, seq);\n CREATE TABLE IF NOT EXISTS peers (\n name TEXT PRIMARY KEY,\n target TEXT NOT NULL,\n host_id TEXT,\n display_name TEXT,\n watermark INTEGER NOT NULL,\n fetched_at INTEGER,\n -- When a jump last proved this peer's tmux was not answering. Reader\n -- state, not an event: this node cannot author facts about another\n -- node's agents, and a jump is a local observation, not something the\n -- peer said. Cleared by the next successful collect.\n tmux_down_at INTEGER\n );\n `);\n\n // Additive migration: an existing peers table predates tmux_down_at.\n try {\n database.exec(\"ALTER TABLE peers ADD COLUMN tmux_down_at INTEGER\");\n } catch {\n // Already present.\n }\n\n // Put back the peers the wipe took, at watermark 0 so the next collect\n // re-reads each one from the start.\n if (salvagedPeers.length > 0) {\n const restore = database.prepare(\n `INSERT OR IGNORE INTO peers (name, target, host_id, display_name, watermark, fetched_at)\n VALUES (?, ?, ?, ?, 0, NULL)`,\n );\n for (const peer of salvagedPeers) {\n restore.run(peer.name, peer.target, peer.host_id ?? null, peer.display_name ?? null);\n }\n }\n\n const eventColumns = `\n host_id, seq, ts, agent_id, session, window, pane,\n session_name, window_name, agent_name, pi_session,\n workstream, role, cli, driver, kind, state, message, pid,\n synthetic, reason, extra`;\n const eventPlaceholders = new Array(22).fill(\"?\").join(\", \");\n const insertEvent = database.prepare(\n `INSERT INTO events (${eventColumns}) VALUES (${eventPlaceholders})`,\n );\n const ingestEvent = database.prepare(\n `INSERT OR IGNORE INTO events (${eventColumns}) VALUES (${eventPlaceholders})`,\n );\n const selectMaxSeq = database.prepare(\n \"SELECT COALESCE(MAX(seq), 0) AS seq FROM events WHERE host_id = ?\",\n );\n const append = database.transaction((event: NewEvent): Event => {\n const row = selectMaxSeq.get(identity.host_id) as { seq: number };\n const stored: Event = {\n ...event,\n host_id: identity.host_id,\n seq: row.seq + 1,\n ts: event.ts ?? Date.now(),\n session_name: event.session_name ?? null,\n window_name: event.window_name ?? null,\n agent_name: event.agent_name ?? null,\n pi_session: event.pi_session ?? null,\n };\n insertEvent.run(...eventValues(stored));\n return stored;\n });\n const ingest = database.transaction((events: Event[]): number => {\n let inserted = 0;\n for (const event of events) inserted += ingestEvent.run(...eventValues(event)).changes;\n return inserted;\n });\n\n return {\n append,\n ingest,\n eventsSince(hostId, seq) {\n const rows = database\n .prepare(\"SELECT * FROM events WHERE host_id = ? AND seq > ? ORDER BY seq\")\n .all(hostId, seq) as EventRow[];\n return rows.map(toEvent);\n },\n allEvents() {\n const rows = database\n .prepare(\"SELECT * FROM events ORDER BY ts, host_id, seq\")\n .all() as EventRow[];\n return rows.map(toEvent);\n },\n latestForAgent(hostId, agentId) {\n const row = database\n .prepare(\n `SELECT * FROM events\n WHERE host_id = ? AND agent_id = ?\n ORDER BY seq DESC LIMIT 1`,\n )\n .get(hostId, agentId) as EventRow | undefined;\n return row ? toEvent(row) : null;\n },\n maxSeq(hostId) {\n return (selectMaxSeq.get(hostId) as { seq: number }).seq;\n },\n prune(horizonMs = Number(process.env.MURMUR_RETENTION_MS ?? DEFAULT_RETENTION_MS)) {\n return database\n .prepare(`\n DELETE FROM events\n WHERE ts < ?\n AND (host_id, seq) NOT IN (\n SELECT host_id, seq FROM (\n SELECT host_id, seq,\n ROW_NUMBER() OVER (PARTITION BY agent_id ORDER BY ts DESC, seq DESC) rn\n FROM events\n ) WHERE rn = 1\n )\n `)\n .run(Date.now() - horizonMs).changes;\n },\n peers() {\n return database.prepare(\"SELECT * FROM peers ORDER BY name\").all() as Peer[];\n },\n forgetAgent(agentId) {\n return database.prepare(\"DELETE FROM events WHERE agent_id = ?\").run(agentId).changes;\n },\n forgetHost(hostId) {\n // Every replicated row for one origin node. Only ever called about a\n // REMOTE host: the local host's rows are this node's own authorship and\n // the retention horizon owns them.\n return database.prepare(\"DELETE FROM events WHERE host_id = ?\").run(hostId).changes;\n },\n upsertPeer(peer) {\n const current = database.prepare(\"SELECT * FROM peers WHERE name = ?\").get(peer.name) as\n | Peer\n | undefined;\n database\n .prepare(`\n INSERT INTO peers (name, target, host_id, display_name, watermark, fetched_at, tmux_down_at)\n VALUES (?, ?, ?, ?, ?, ?, ?)\n ON CONFLICT(name) DO UPDATE SET\n target = excluded.target,\n host_id = excluded.host_id,\n display_name = excluded.display_name,\n watermark = excluded.watermark,\n fetched_at = excluded.fetched_at,\n tmux_down_at = excluded.tmux_down_at\n `)\n .run(\n peer.name,\n peer.target,\n peer.host_id !== undefined ? peer.host_id : (current?.host_id ?? null),\n peer.display_name !== undefined ? peer.display_name : (current?.display_name ?? null),\n peer.watermark !== undefined ? peer.watermark : (current?.watermark ?? 0),\n peer.fetched_at !== undefined ? peer.fetched_at : (current?.fetched_at ?? null),\n peer.tmux_down_at !== undefined ? peer.tmux_down_at : (current?.tmux_down_at ?? null),\n );\n },\n removePeer(name) {\n // Drops the peer and its watermark. Replicated events stay: they are\n // real history authored elsewhere, and the retention horizon already\n // ages them out. Re-adding the peer re-syncs from zero, which ingest\n // makes free.\n return database.prepare(\"DELETE FROM peers WHERE name = ?\").run(name).changes > 0;\n },\n close() {\n database.close();\n },\n };\n}\n"],"mappings":"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","names":["join","join"]}
package/dist/index.d.ts CHANGED
@@ -47,17 +47,24 @@ interface Mux {
47
47
  currentWindow(): Location | null;
48
48
  liveWindows(): Set<string> | null;
49
49
  setState(window: string, state: AgentState | null): void;
50
- attach(session: string, window: string): void;
50
+ attach(session: string, window: string): boolean;
51
51
  windowNames(): Map<string, string>;
52
52
  windowForPane(pane: string): string | null;
53
53
  panesInWindow(window: string): string[];
54
54
  windowNamed(name: string): string | null;
55
- selectWindow(window: string): void;
55
+ selectWindow(window: string): boolean;
56
+ newWindow(name: string, command: string): boolean;
56
57
  capture(pane: string, lines?: number): string | null;
57
58
  }
58
59
  declare const tmux: Mux;
59
60
  declare function pidAlive(pid: number): boolean;
60
61
 
62
+ interface Channel {
63
+ exec(target: string, argv: string[]): Promise<string>;
64
+ }
65
+ declare const ssh: Channel;
66
+ declare function hasWarmSocket(target: string): boolean;
67
+
61
68
  type LiveCheck = (pid: number) => boolean;
62
69
  type AgentView = {
63
70
  agent_id: string;
@@ -105,6 +112,17 @@ interface Store {
105
112
  ingest(events: Event[]): number;
106
113
  eventsSince(hostId: string, seq: number): Event[];
107
114
  allEvents(): Event[];
115
+ /**
116
+ * The most recent event for one agent, or null.
117
+ *
118
+ * Exists so the `clear` hook does not have to open its own SQLite handle and
119
+ * write its own `ORDER BY seq DESC LIMIT 1`, which is what it used to do --
120
+ * making "store is the only module touching SQL" false, and putting knowledge
121
+ * of agent_id construction and event ordering in a CLI file where a schema
122
+ * change would miss it. That path swallows its own errors, so the miss would
123
+ * have been silent.
124
+ */
125
+ latestForAgent(hostId: string, agentId: string): Event | null;
108
126
  maxSeq(hostId: string): number;
109
127
  prune(horizonMs?: number): number;
110
128
  peers(): Peer[];
@@ -170,30 +188,65 @@ declare function agentLabel(agent: Agent): string;
170
188
  */
171
189
  declare function agentLocation(agent: Agent): string;
172
190
  declare function shellQuote(value: string): string;
191
+ /**
192
+ * The one process call jump makes that is not a tmux command: the remote probe,
193
+ * and the direct ssh attach when we are not inside tmux. Injectable so the jump
194
+ * decision table can be tested without an ssh binary or a live peer -- without
195
+ * this seam, `jumpToAgent` had no behavioural coverage at all and replacing its
196
+ * body with `return { ok: true }` kept every jump test green.
197
+ */
198
+ type Runner = (file: string, args: string[], inherit?: boolean) => {
199
+ status: number | null;
200
+ stdout: string;
201
+ failed: boolean;
202
+ };
173
203
  type JumpResult = {
174
204
  ok: true;
175
205
  } | {
176
206
  ok: false;
177
- reason: "no_peer" | "unreachable" | "no_tmux" | "window_gone";
207
+ reason: "no_peer" | "unreachable" | "no_tmux" | "window_gone" | "attach_failed";
178
208
  message: string;
179
209
  };
180
- declare function jumpToAgent(store: Store, agent: Agent): JumpResult;
181
-
182
- interface Channel {
183
- exec(target: string, argv: string[]): Promise<string>;
184
- }
185
- declare const ssh: Channel;
186
- declare function hasWarmSocket(target: string): boolean;
210
+ declare function jumpToAgent(store: Store, agent: Agent, mux?: Mux, run?: Runner): JumpResult;
187
211
 
188
- declare const COLLECT_INTERVAL_MS = 30000;
189
- declare const STALENESS_MS: number;
212
+ /**
213
+ * How long a peer may go unfetched before it renders stale.
214
+ *
215
+ * Not derived from a collect interval, because murmur has no scheduler: there
216
+ * is no timer here, and `collect` runs only when a command asks for it. In
217
+ * practice the cadence is the operator's tmux `status-interval`, since
218
+ * `murmur status` collects and tmux re-runs it on a tick.
219
+ *
220
+ * So this is a judgement about the operator's setup, not arithmetic on a
221
+ * constant murmur controls. Sixty seconds is comfortably above a default 15s
222
+ * status bar -- a peer needs to miss several ticks before it is called out,
223
+ * which keeps one slow fetch from flickering the HUD. A status bar slower than
224
+ * this will show every peer permanently stale; that is the number to change if
225
+ * so.
226
+ */
227
+ declare const STALENESS_MS = 60000;
228
+ declare const MAX_CONCURRENT_PEERS = 8;
190
229
  type CollectResult = {
191
230
  peer: string;
192
231
  ok: boolean;
193
232
  ingested: number;
194
233
  error?: string;
195
234
  };
196
- declare function collect(store: Store, channel: Channel, now?: number): Promise<CollectResult[]>;
235
+ /**
236
+ * Peers are fetched concurrently and applied serially.
237
+ *
238
+ * Concurrent because an unreachable peer costs the full ssh timeout, and a
239
+ * serial loop charged that to every other peer behind it: three asleep laptops
240
+ * made `murmur status` hang for thirty seconds and let the HUD tick overlap
241
+ * itself. Fanning out makes the whole collect cost the slowest peer, not the
242
+ * sum — capped at MAX_CONCURRENT_PEERS in flight and bounded overall by
243
+ * COLLECT_DEADLINE_MS.
244
+ *
245
+ * Applied serially, in peer order, because better-sqlite3 is synchronous: there
246
+ * is nothing to win by interleaving writes, and keeping the order stable keeps
247
+ * the result list aligned with `store.peers()`.
248
+ */
249
+ declare function collect(store: Store, channel: Channel, now?: number, deadline?: Promise<void>): Promise<CollectResult[]>;
197
250
 
198
251
  declare const SCHEMA_VERSION = 2;
199
252
  declare function eventFromWire(wire: Record<string, unknown>): Event;
@@ -214,4 +267,4 @@ declare function dbPath(): string;
214
267
 
215
268
  declare const VERSION: string;
216
269
 
217
- export { type Agent, type AgentState, type AgentView, COLLECT_INTERVAL_MS, type Channel, type CollectResult, DEFAULT_DRIVER, type Driver, type Event, type JumpResult, type LiveCheck, type Mux, type NewEvent, type NodeIdentity, type Peer, SCHEMA_VERSION, STALENESS_MS, STORE_VERSION, type Status, type Store, VERSION, agentLabel, agentLocation, attentionSort, collect, configDir, dbPath, ensureIdentity, eventFromWire, exportJsonl, foldAgent, foldAll, glance, hasWarmSocket, isStale, jumpToAgent, loadIdentity, openStore, pidAlive, shellQuote, ssh, stateDir, status, tmux };
270
+ export { type Agent, type AgentState, type AgentView, type Channel, type CollectResult, DEFAULT_DRIVER, type Driver, type Event, type JumpResult, type LiveCheck, MAX_CONCURRENT_PEERS, type Mux, type NewEvent, type NodeIdentity, type Peer, SCHEMA_VERSION, STALENESS_MS, STORE_VERSION, type Status, type Store, VERSION, agentLabel, agentLocation, attentionSort, collect, configDir, dbPath, ensureIdentity, eventFromWire, exportJsonl, foldAgent, foldAll, glance, hasWarmSocket, isStale, jumpToAgent, loadIdentity, openStore, pidAlive, shellQuote, ssh, stateDir, status, tmux };