@martintrojer/murmur 0.1.3 → 0.2.0

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/dist/index.js CHANGED
@@ -1,48 +1,59 @@
1
- // src/index.ts
2
- import { createRequire } from "module";
3
-
4
1
  // src/agents.ts
5
2
  import { spawnSync } from "child_process";
6
3
 
7
- // src/identity.ts
8
- import { randomUUID } from "crypto";
9
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
10
- import { hostname } from "os";
11
- import { join as join2 } from "path";
12
-
13
- // src/paths.ts
14
- import { homedir } from "os";
15
- import { join } from "path";
16
- function stateDir() {
17
- return process.env.MURMUR_STATE_DIR ?? join(process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state"), "murmur");
18
- }
19
- function configDir() {
20
- return process.env.MURMUR_CONFIG_DIR ?? join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "murmur");
21
- }
22
- function dbPath() {
23
- return join(stateDir(), "events.db");
4
+ // src/channel.ts
5
+ import { execFile, execFileSync } from "child_process";
6
+ import { promisify } from "util";
7
+ var execFileAsync = promisify(execFile);
8
+ var CONTROL_PATH = "~/.ssh/control/%r@%h:%p";
9
+ var CONNECT_TIMEOUT_S = 1;
10
+ var EXEC_TIMEOUT_MS = 3e3;
11
+ var SSH_OPTIONS = [
12
+ "-o",
13
+ "BatchMode=yes",
14
+ "-o",
15
+ "ControlMaster=no",
16
+ "-o",
17
+ `ControlPath=${CONTROL_PATH}`,
18
+ "-o",
19
+ `ConnectTimeout=${CONNECT_TIMEOUT_S}`
20
+ ];
21
+ var MAX_EXPORT_BYTES = 64 * 1024 * 1024;
22
+ var ssh = {
23
+ async exec(target, argv) {
24
+ const { stdout } = await execFileAsync("ssh", [...SSH_OPTIONS, target, ...argv], {
25
+ encoding: "utf8",
26
+ timeout: EXEC_TIMEOUT_MS,
27
+ maxBuffer: MAX_EXPORT_BYTES
28
+ });
29
+ return stdout;
30
+ }
31
+ };
32
+ function hasWarmSocket(target) {
33
+ try {
34
+ execFileSync("ssh", [...SSH_OPTIONS, "-O", "check", target], { stdio: "ignore" });
35
+ return true;
36
+ } catch {
37
+ return false;
38
+ }
24
39
  }
25
40
 
26
- // src/identity.ts
27
- function loadIdentity() {
28
- const path = join2(stateDir(), "identity.json");
29
- return existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) : null;
41
+ // src/ids.ts
42
+ function asSessionId(raw) {
43
+ return raw;
30
44
  }
31
- function ensureIdentity(displayName = hostname()) {
32
- const existing = loadIdentity();
33
- if (existing) return existing;
34
- const identity = { host_id: randomUUID(), display_name: displayName };
35
- mkdirSync(stateDir(), { recursive: true });
36
- writeFileSync(join2(stateDir(), "identity.json"), `${JSON.stringify(identity, null, 2)}
37
- `);
38
- return identity;
45
+ function asWindowId(raw) {
46
+ return raw;
47
+ }
48
+ function asPaneId(raw) {
49
+ return raw;
39
50
  }
40
51
 
41
52
  // src/mux.ts
42
- import { execFileSync } from "child_process";
53
+ import { execFileSync as execFileSync2 } from "child_process";
43
54
  function runTmux(args) {
44
55
  try {
45
- return execFileSync("tmux", args, {
56
+ return execFileSync2("tmux", args, {
46
57
  encoding: "utf8",
47
58
  timeout: 3e3,
48
59
  stdio: ["ignore", "pipe", "ignore"]
@@ -51,10 +62,20 @@ function runTmux(args) {
51
62
  return null;
52
63
  }
53
64
  }
65
+ function exactSession(session) {
66
+ return `=${session}`;
67
+ }
68
+ function exactPaneTarget(session) {
69
+ return `=${session}:`;
70
+ }
71
+ function tmuxBadgeState(state) {
72
+ return state === "running" ? "working" : state;
73
+ }
54
74
  var tmux = {
55
75
  currentWindow() {
56
- const pane = process.env.TMUX_PANE;
57
- if (!pane) return null;
76
+ const raw = process.env.TMUX_PANE;
77
+ if (!raw) return null;
78
+ const pane = asPaneId(raw);
58
79
  const fields = runTmux([
59
80
  "display-message",
60
81
  "-t",
@@ -65,82 +86,84 @@ var tmux = {
65
86
  const [session, window, sessionName, windowName] = fields?.split(" ") ?? [];
66
87
  if (!session || !window) return null;
67
88
  return {
68
- session,
69
- window,
89
+ session: asSessionId(session),
90
+ window: asWindowId(window),
70
91
  pane,
71
92
  session_name: sessionName || null,
72
93
  window_name: windowName || null
73
94
  };
74
95
  },
75
- // Which of this host's windows still exist. Only the authoring node can
76
- // answer this, which is why the check runs on export rather than on the
77
- // reader: a peer holding a `blocked` row for a window that died has nothing
78
- // to supersede it, and the agent stays in every HUD forever.
79
- //
80
- // null means "could not tell" (no tmux server, tmux missing) and is
81
- // deliberately distinct from an empty set, which means "tmux answered, and
82
- // there are no windows". Treating the first as the second would clear every
83
- // agent on the host the moment tmux was unreachable.
96
+ // Which of this host's PANES still exist. The only liveness question tmux is
97
+ // ever asked, and the one that matches how an agent is addressed: a pane keeps
98
+ // its id when it moves between windows, so a recorded window id can be gone
99
+ // while the agent is very much alive.
84
100
  //
85
- // Unlike currentWindow, this deliberately asks tmux rather than reading the
86
- // environment, and it is right to: "which windows exist on this host" is a
87
- // server-wide question with one answer, and export runs over ssh with no
88
- // pane of its own. currentWindow asks "which pane am I in", which only
89
- // $TMUX_PANE can answer.
90
- liveWindows() {
91
- const out = runTmux(["list-windows", "-a", "-F", "#{window_id}"]);
101
+ // null means tmux could not answer; an empty set means it did and there are
102
+ // none. Conflating the two would delete every agent on the host the moment
103
+ // tmux was briefly unreachable.
104
+ livePanes() {
105
+ const out = runTmux(["list-panes", "-a", "-F", "#{pane_id}"]);
92
106
  if (out === null) return null;
93
- return new Set(out.split("\n").filter(Boolean));
107
+ return new Set(out.split("\n").filter(Boolean).map(asPaneId));
94
108
  },
95
- setState(window, state) {
109
+ setWindowBadge(window, state) {
96
110
  if (state === null) {
97
111
  runTmux(["set-window-option", "-qu", "-t", window, "@agent_state"]);
98
112
  } else {
99
- runTmux(["set-window-option", "-q", "-t", window, "@agent_state", state]);
113
+ runTmux(["set-window-option", "-q", "-t", window, "@agent_state", tmuxBadgeState(state)]);
100
114
  runTmux(["set-window-option", "-q", "-t", window, "@pane_agent", "1"]);
101
115
  }
102
116
  runTmux(["refresh-client", "-S"]);
103
117
  },
104
118
  attach(session, window) {
105
119
  runTmux(["switch-client", "-t", session]);
106
- runTmux(["select-window", "-t", window]);
120
+ return runTmux(["select-window", "-t", window]) !== null;
107
121
  },
108
- // Window ids are what the log stores, because they are stable; names are
109
- // what a human recognises in a picker. Names are live tmux state, not
110
- // history, so they are resolved at render time rather than recorded.
111
- windowNames() {
112
- const out = runTmux(["list-windows", "-a", "-F", "#{window_id} #{window_name}"]);
113
- const names = /* @__PURE__ */ new Map();
114
- for (const line of out?.split("\n") ?? []) {
115
- const [id, name] = line.split(" ");
116
- if (id && name) names.set(id, name);
117
- }
118
- return names;
119
- },
120
- // First window carrying this exact name, or null. Used to reuse a per-host
121
- // ssh window instead of opening another one.
122
122
  // Sibling panes, for deciding whether an unowned pane may clear the window's
123
123
  // badge. A window holding an agent and a shell must not lose the badge when
124
124
  // you focus the shell.
125
125
  panesInWindow(window) {
126
126
  const out = runTmux(["list-panes", "-t", window, "-F", "#{pane_id}"]);
127
- return out?.split("\n").filter(Boolean) ?? [];
127
+ return out?.split("\n").filter(Boolean).map(asPaneId) ?? [];
128
128
  },
129
- windowNamed(name) {
130
- const out = runTmux(["list-windows", "-a", "-F", "#{window_id} #{window_name}"]);
131
- for (const line of out?.split("\n") ?? []) {
132
- const [id, windowName] = line.split(" ");
133
- if (id && windowName === name) return id;
134
- }
135
- return null;
129
+ // Which client to send home when the remote attach exits. `switch-client`
130
+ // with no -c moves whichever client tmux considers current, and `murmur pick`
131
+ // usually runs in a popup -- a client of its own, which dies with the popup.
132
+ // Naming the real client is what lets the return outlive the picker.
133
+ clientName() {
134
+ return runTmux(["display-message", "-p", "#{client_name}"]) || null;
135
+ },
136
+ // Where the jump started, as a switch-client target. Window-level, not just
137
+ // the session: coming back to the right session but the wrong window is
138
+ // still the wrong place. The window id is stable where its index is not,
139
+ // since renumber-windows renumbers on every close.
140
+ currentTarget() {
141
+ return runTmux(["display-message", "-p", "#{session_name}:#{window_id}"]) || null;
136
142
  },
137
- selectWindow(window) {
138
- runTmux(["select-window", "-t", window]);
143
+ // Whether a wrapper session for this host already exists. Deliberately not
144
+ // returning an id: a session is addressed by name, so a `#{session_id}` would
145
+ // only have to be turned back into one.
146
+ sessionNamed(name) {
147
+ const out = runTmux(["list-sessions", "-F", "#{session_name}"]);
148
+ if (out === null) return false;
149
+ return out.split("\n").includes(name);
139
150
  },
140
- // The window a pane belongs to, for a pane murmur has no event for. Clearing
151
+ newSession(name, command) {
152
+ return runTmux(["new-session", "-d", "-s", name, command]) !== null;
153
+ },
154
+ setSessionOption(session, option, value) {
155
+ runTmux(["set-option", "-t", exactPaneTarget(session), option, value]);
156
+ },
157
+ switchClient(client, session) {
158
+ const target = exactSession(session);
159
+ const args = client ? ["switch-client", "-c", client, "-t", target] : ["switch-client", "-t", target];
160
+ return runTmux(args) !== null;
161
+ },
162
+ // The window a pane belongs to, for a pane murmur holds no row for. Clearing
141
163
  // a badge is a tmux operation and does not require murmur to own the pane.
142
164
  windowForPane(pane) {
143
- return runTmux(["display-message", "-t", pane, "-p", "#{window_id}"]) || null;
165
+ const out = runTmux(["display-message", "-t", pane, "-p", "#{window_id}"]);
166
+ return out ? asWindowId(out) : null;
144
167
  },
145
168
  capture(pane, lines) {
146
169
  const args = ["capture-pane", "-p", "-t", pane];
@@ -176,41 +199,41 @@ function terminalText(value) {
176
199
  function shellQuote(value) {
177
200
  return `'${value.replaceAll("'", `'\\''`)}'`;
178
201
  }
179
- function forgetHostReplica(store, hostId) {
180
- try {
181
- const peer = store.peers().find((candidate) => candidate.host_id === hostId);
182
- store.forgetHost(hostId);
183
- if (peer) {
184
- store.upsertPeer({
185
- name: peer.name,
186
- target: peer.target,
187
- tmux_down_at: Date.now()
188
- });
189
- }
190
- } catch {
191
- }
192
- }
193
- function forgetReplica(store, agentId, hostId) {
194
- try {
195
- store.forgetAgent(agentId);
196
- const peer = store.peers().find((candidate) => candidate.host_id === hostId);
197
- if (peer) store.upsertPeer({ name: peer.name, target: peer.target, watermark: 0 });
198
- } catch {
199
- }
202
+ function remoteSessionName(peerName) {
203
+ return `${peerName.replace(/^[@$%=]+/, "")}~`;
200
204
  }
201
- function jumpToAgent(store, agent) {
202
- const identity = loadIdentity();
203
- if (agent.host_id === identity?.host_id) {
204
- const live = tmux.liveWindows();
205
- if (live && !live.has(agent.window)) {
206
- forgetReplica(store, agent.agent_id, agent.host_id);
205
+ var spawnRunner = (file, args, inherit = false) => {
206
+ const result = spawnSync(file, args, {
207
+ encoding: "utf8",
208
+ timeout: 1e4,
209
+ ...inherit ? { stdio: "inherit" } : {}
210
+ });
211
+ return {
212
+ status: result.status,
213
+ stdout: result.stdout ?? "",
214
+ // spawnSync reports a failure to even start the child in `error`, leaving
215
+ // status null. Collapsing both here keeps the decision table below reading
216
+ // as one question rather than two.
217
+ failed: result.error !== void 0
218
+ };
219
+ };
220
+ function jumpToAgent(store, agent, mux = tmux, run = spawnRunner) {
221
+ if (agent.local) {
222
+ const panes = mux.livePanes();
223
+ if (panes && !panes.has(agent.pane)) {
224
+ return {
225
+ ok: false,
226
+ reason: "pane_gone",
227
+ message: `${agentLabel(agent)} is gone -- its pane no longer exists.`
228
+ };
229
+ }
230
+ if (!mux.attach(agent.session, agent.window)) {
207
231
  return {
208
232
  ok: false,
209
- reason: "window_gone",
210
- message: `${agentLabel(agent)} is gone -- its window no longer exists. Cleared.`
233
+ reason: "attach_failed",
234
+ message: `could not attach to ${agentLabel(agent)} (tmux select-window failed).`
211
235
  };
212
236
  }
213
- tmux.attach(agent.session, agent.window);
214
237
  return { ok: true };
215
238
  }
216
239
  const peer = store.peers().find((candidate) => candidate.host_id === agent.host_id);
@@ -222,330 +245,421 @@ function jumpToAgent(store, agent) {
222
245
  message: `No peer configured for host ${agent.host_id.slice(0, 8)}. Try: murmur peer add <target>`
223
246
  };
224
247
  }
225
- const probe = spawnSync(
226
- "ssh",
227
- ["-o", "BatchMode=yes", target, `tmux list-windows -a -F ${shellQuote("#{window_id}")}`],
228
- { encoding: "utf8", timeout: 1e4 }
229
- );
248
+ const probe = run("ssh", [
249
+ ...SSH_OPTIONS,
250
+ target,
251
+ `tmux list-panes -a -F ${shellQuote("#{pane_id}")}`
252
+ ]);
230
253
  if (probe.status !== 0) {
231
- const sshFailed = probe.status === 255 || probe.error !== void 0;
254
+ const sshFailed = probe.status === 255 || probe.failed;
232
255
  if (sshFailed) {
233
256
  return {
234
257
  ok: false,
235
258
  reason: "unreachable",
236
- message: `cannot reach ${target} over ssh. The collector never prompts for auth, so connect once by hand to warm the connection, then retry.`
259
+ message: `cannot reach ${target} over ssh. Nothing here ever prompts for auth, so check the host is awake and reachable, or connect once by hand to see the real error.`
237
260
  };
238
261
  }
239
- forgetHostReplica(store, agent.host_id);
240
262
  return {
241
263
  ok: false,
242
264
  reason: "no_tmux",
243
- message: `${target} has no tmux server running, so its agents are gone. Removed them; they will come back when it reports again.`
265
+ message: `${target} has no tmux server running, so its agents are gone. They will disappear on the next collect.`
244
266
  };
245
267
  }
246
- const remoteWindows = new Set((probe.stdout ?? "").split("\n").filter(Boolean));
247
- if (!remoteWindows.has(agent.window)) {
248
- forgetReplica(store, agent.agent_id, agent.host_id);
268
+ const remotePanes = new Set(probe.stdout.split("\n").filter(Boolean).map(asPaneId));
269
+ if (!remotePanes.has(agent.pane)) {
249
270
  return {
250
271
  ok: false,
251
- reason: "window_gone",
252
- message: `${agentLabel(agent)} is gone -- ${target} no longer has that window. Cleared.`
272
+ reason: "pane_gone",
273
+ message: `${agentLabel(agent)} is gone -- ${target} no longer has that pane.`
253
274
  };
254
275
  }
255
276
  const attachTarget = shellQuote(`${agent.session}:${agent.window}`);
256
277
  if (process.env.TMUX) {
257
- const command = `ssh -t ${shellQuote(target)} tmux attach -t ${shellQuote(attachTarget)}`;
258
- const name = `@${peer?.name ?? target}`;
259
- const existing = tmux.windowNamed(name);
260
- if (existing) {
261
- tmux.selectWindow(existing);
262
- return { ok: true };
278
+ const client = mux.clientName();
279
+ const origin = mux.currentTarget();
280
+ const name = remoteSessionName(peer?.name ?? target);
281
+ if (mux.sessionNamed(name)) {
282
+ return mux.switchClient(client, name) ? { ok: true } : {
283
+ ok: false,
284
+ reason: "attach_failed",
285
+ message: `could not switch to the existing ${name} session.`
286
+ };
263
287
  }
264
- spawnSync("tmux", ["new-window", "-n", name, command], { stdio: "ignore" });
265
- return { ok: true };
288
+ const attach2 = `ssh -t ${shellQuote(target)} tmux attach -t ${shellQuote(attachTarget)}`;
289
+ const restore = origin ? `; tmux switch-client ${client ? `-c ${shellQuote(client)} ` : ""}-t ${shellQuote(`=${origin}`)}` : "";
290
+ if (!mux.newSession(name, `${attach2}${restore}`)) {
291
+ return {
292
+ ok: false,
293
+ reason: "attach_failed",
294
+ message: `could not open a session to attach to ${target}.`
295
+ };
296
+ }
297
+ mux.setSessionOption(name, "status", "off");
298
+ mux.setSessionOption(name, "prefix", "None");
299
+ mux.setSessionOption(name, "detach-on-destroy", "previous");
300
+ return mux.switchClient(client, name) ? { ok: true } : {
301
+ ok: false,
302
+ reason: "attach_failed",
303
+ message: `attached to ${target} in session ${name}, but could not switch to it.`
304
+ };
266
305
  }
267
- spawnSync("ssh", ["-t", target, "tmux", "attach", "-t", attachTarget], { stdio: "inherit" });
268
- return { ok: true };
306
+ const attach = run("ssh", ["-t", target, "tmux", "attach", "-t", attachTarget], true);
307
+ return attach.status === 0 && !attach.failed ? { ok: true } : {
308
+ ok: false,
309
+ reason: "attach_failed",
310
+ message: `ssh attach to ${target} failed.`
311
+ };
269
312
  }
270
313
 
271
- // src/channel.ts
272
- import { execFile, execFileSync as execFileSync2 } from "child_process";
273
- import { promisify } from "util";
274
- var execFileAsync = promisify(execFile);
275
- var CONTROL_PATH = "~/.ssh/control/%r@%h:%p";
276
- var CONNECT_TIMEOUT_S = 2;
277
- var EXEC_TIMEOUT_MS = 1e4;
278
- var SSH_OPTIONS = [
279
- "-o",
280
- "BatchMode=yes",
281
- "-o",
282
- "ControlMaster=no",
283
- "-o",
284
- `ControlPath=${CONTROL_PATH}`,
285
- "-o",
286
- `ConnectTimeout=${CONNECT_TIMEOUT_S}`
287
- ];
288
- var ssh = {
289
- async exec(target, argv) {
290
- const { stdout } = await execFileAsync("ssh", [...SSH_OPTIONS, target, ...argv], {
291
- encoding: "utf8",
292
- timeout: EXEC_TIMEOUT_MS
293
- });
294
- return stdout;
314
+ // src/snapshot.ts
315
+ var SnapshotInvalidError = class extends Error {
316
+ constructor(path, detail) {
317
+ super(path === "" ? detail : `${path}: ${detail}`);
318
+ this.path = path;
319
+ this.name = "SnapshotInvalidError";
295
320
  }
321
+ path;
296
322
  };
297
- function hasWarmSocket(target) {
298
- try {
299
- execFileSync2("ssh", [...SSH_OPTIONS, "-O", "check", target], { stdio: "ignore" });
300
- return true;
301
- } catch {
302
- return false;
303
- }
323
+ function fail(path, detail) {
324
+ throw new SnapshotInvalidError(path, detail);
304
325
  }
305
-
306
- // src/types.ts
307
- var DEFAULT_DRIVER = "human";
308
-
309
- // src/fold.ts
310
- function foldAgent(events, isAlive) {
311
- for (let index = events.length - 1; index >= 0; index -= 1) {
312
- const event = events[index];
313
- if (!event) continue;
314
- switch (event.state) {
315
- case "blocked":
316
- case "done":
317
- case "crashed":
318
- return { state: event.state, event };
319
- case "cleared":
320
- return { state: null, event: null };
321
- case "working":
322
- return {
323
- state: event.pid !== null && event.pid > 0 && isAlive(event.pid) ? "working" : "crashed",
324
- event
325
- };
326
- }
326
+ function object(value, path, keys) {
327
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
328
+ fail(path, "expected an object");
327
329
  }
328
- return { state: null, event: null };
329
- }
330
- function foldAll(events, isAlive) {
331
- const byAgent = /* @__PURE__ */ new Map();
332
- for (const event of events) {
333
- const agentEvents = byAgent.get(event.agent_id);
334
- if (agentEvents) agentEvents.push(event);
335
- else byAgent.set(event.agent_id, [event]);
330
+ const record = value;
331
+ for (const key of keys) if (!(key in record)) fail(path, `missing key ${key}`);
332
+ for (const key of Object.keys(record)) {
333
+ if (!keys.includes(key)) fail(path, `unknown key ${key}`);
336
334
  }
337
- return [...byAgent.values()].map((agentEvents) => {
338
- const folded = foldAgent(agentEvents, isAlive);
339
- const source = folded.event ?? agentEvents[agentEvents.length - 1];
340
- if (!source) throw new Error("agent event group cannot be empty");
341
- return {
342
- agent_id: source.agent_id,
343
- host_id: source.host_id,
344
- state: folded.state,
345
- event: folded.event,
346
- workstream: source.workstream,
347
- role: source.role,
348
- cli: source.cli,
349
- driver: source.driver ?? DEFAULT_DRIVER,
350
- session: source.session,
351
- window: source.window,
352
- pane: source.pane,
353
- session_name: source.session_name,
354
- window_name: source.window_name,
355
- agent_name: source.agent_name,
356
- pi_session: source.pi_session,
357
- fetched_at: null
358
- };
359
- });
335
+ return record;
360
336
  }
361
- var ATTENTION_ORDER = {
362
- blocked: 0,
363
- done: 1,
364
- crashed: 2,
365
- working: 3,
366
- cleared: 4
367
- };
368
- function attentionSort(views) {
369
- return [...views].sort((left, right) => {
370
- const stateOrder = (left.state === null ? 4 : ATTENTION_ORDER[left.state]) - (right.state === null ? 4 : ATTENTION_ORDER[right.state]);
371
- if (stateOrder !== 0) return stateOrder;
372
- return (right.event?.ts ?? 0) - (left.event?.ts ?? 0);
373
- });
337
+ function text(value, path) {
338
+ if (typeof value !== "string" || value === "") fail(path, "expected a non-empty string");
339
+ return value;
374
340
  }
375
- function isStale(fetchedAt, now, thresholdMs = 6e4) {
376
- return fetchedAt !== null && now - fetchedAt > thresholdMs;
341
+ function textOrNull(value, path) {
342
+ if (value === null) return null;
343
+ if (typeof value !== "string") fail(path, "expected a string or null");
344
+ return value;
377
345
  }
378
-
379
- // src/export.ts
380
- var SCHEMA_VERSION = 2;
381
- var EVENT_FIELDS = /* @__PURE__ */ new Set([
346
+ function anyText(value, path) {
347
+ if (typeof value !== "string") fail(path, "expected a string");
348
+ return value;
349
+ }
350
+ function timestamp(value, path) {
351
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 0) {
352
+ fail(path, "expected a non-negative integer");
353
+ }
354
+ return value;
355
+ }
356
+ function member(value, path, allowed) {
357
+ if (typeof value !== "string" || !allowed.includes(value)) {
358
+ fail(path, `expected one of ${allowed.join(", ")}`);
359
+ }
360
+ return value;
361
+ }
362
+ var ACTIVITIES = ["running", "stopped"];
363
+ var DRIVERS = ["human", "orchestrated"];
364
+ var KINDS = ["done", "blocked", "crashed"];
365
+ var TOP_KEYS = [
366
+ "murmur_snapshot",
382
367
  "host_id",
383
- "seq",
384
- "ts",
385
- "agent_id",
368
+ "display_name",
369
+ "murmur_version",
370
+ "generated_at",
371
+ "panes"
372
+ ];
373
+ var PANE_KEYS = [
374
+ "pane",
386
375
  "session",
387
376
  "window",
388
- "pane",
389
377
  "session_name",
390
378
  "window_name",
379
+ "agent",
380
+ "attention"
381
+ ];
382
+ var AGENT_KEYS = [
383
+ "agent_id",
384
+ "activity",
391
385
  "agent_name",
392
386
  "pi_session",
393
387
  "workstream",
394
388
  "role",
395
389
  "cli",
396
390
  "driver",
397
- "kind",
398
- "state",
399
- "message",
400
- "pid",
401
- "synthetic",
402
- "reason"
403
- ]);
404
- function eventToWire(event) {
405
- const { extra, ...known } = event;
406
- return { ...extra, ...known };
407
- }
408
- function eventFromWire(wire) {
409
- const extra = Object.fromEntries(Object.entries(wire).filter(([key]) => !EVENT_FIELDS.has(key)));
391
+ "claimed_at",
392
+ "updated_at"
393
+ ];
394
+ var ATTENTION_KEYS = ["kind", "message", "source", "requested_at"];
395
+ function parseAgent(value, path) {
396
+ if (value === null) return null;
397
+ const row = object(value, path, AGENT_KEYS);
410
398
  return {
411
- host_id: wire.host_id,
412
- seq: wire.seq,
413
- ts: wire.ts,
414
- agent_id: wire.agent_id,
415
- session: wire.session,
416
- window: wire.window,
417
- pane: wire.pane,
418
- session_name: wire.session_name ?? null,
419
- window_name: wire.window_name ?? null,
420
- agent_name: wire.agent_name ?? null,
421
- pi_session: wire.pi_session ?? null,
422
- workstream: wire.workstream ?? null,
423
- role: wire.role ?? null,
424
- cli: wire.cli ?? null,
425
- driver: wire.driver ?? null,
426
- kind: wire.kind,
427
- state: wire.state,
428
- message: wire.message,
429
- pid: wire.pid ?? null,
430
- synthetic: wire.synthetic,
431
- reason: wire.reason,
432
- extra
399
+ agent_id: text(row.agent_id, `${path}.agent_id`),
400
+ activity: member(row.activity, `${path}.activity`, ACTIVITIES),
401
+ agent_name: textOrNull(row.agent_name, `${path}.agent_name`),
402
+ pi_session: textOrNull(row.pi_session, `${path}.pi_session`),
403
+ workstream: textOrNull(row.workstream, `${path}.workstream`),
404
+ role: textOrNull(row.role, `${path}.role`),
405
+ cli: text(row.cli, `${path}.cli`),
406
+ driver: member(row.driver, `${path}.driver`, DRIVERS),
407
+ claimed_at: timestamp(row.claimed_at, `${path}.claimed_at`),
408
+ updated_at: timestamp(row.updated_at, `${path}.updated_at`)
433
409
  };
434
410
  }
435
- function synthesizeCrashes(store, hostId, isAlive) {
436
- const byAgent = /* @__PURE__ */ new Map();
437
- for (const event of store.allEvents()) {
438
- if (event.host_id !== hostId) continue;
439
- const events = byAgent.get(event.agent_id);
440
- if (events) events.push(event);
441
- else byAgent.set(event.agent_id, [event]);
442
- }
443
- for (const events of byAgent.values()) {
444
- events.sort((left, right) => left.seq - right.seq);
445
- const newest = events.at(-1);
446
- if (newest && newest.state === "working" && !newest.synthetic && foldAgent(events, isAlive).state === "crashed") {
447
- const { host_id: _hostId, seq: _seq, ts: _ts, ...event } = newest;
448
- store.append({ ...event, state: "crashed", synthetic: true, reason: "pid_gone" });
449
- }
450
- }
411
+ function parseAttention(value, path) {
412
+ if (!Array.isArray(value)) fail(path, "expected an array");
413
+ const seen = /* @__PURE__ */ new Set();
414
+ return value.map((entry, index) => {
415
+ const at = `${path}[${index}]`;
416
+ const row = object(entry, at, ATTENTION_KEYS);
417
+ const kind = member(row.kind, `${at}.kind`, KINDS);
418
+ if (seen.has(kind)) fail(`${at}.kind`, `duplicate kind ${kind} for this pane`);
419
+ seen.add(kind);
420
+ return {
421
+ kind,
422
+ message: anyText(row.message, `${at}.message`),
423
+ source: anyText(row.source, `${at}.source`),
424
+ requested_at: timestamp(row.requested_at, `${at}.requested_at`)
425
+ };
426
+ });
451
427
  }
452
- function clearDeadWindows(store, hostId, live) {
453
- if (live === null) return;
454
- const newest = /* @__PURE__ */ new Map();
455
- for (const event of store.allEvents()) {
456
- if (event.host_id !== hostId) continue;
457
- const previous = newest.get(event.agent_id);
458
- if (!previous || event.seq > previous.seq) newest.set(event.agent_id, event);
459
- }
460
- for (const event of newest.values()) {
461
- if (event.state === "cleared") continue;
462
- if (live.has(event.window)) continue;
463
- const { host_id: _hostId, seq: _seq, ts: _ts, ...rest } = event;
464
- store.append({
465
- ...rest,
466
- state: "cleared",
467
- synthetic: true,
468
- reason: "window_gone",
469
- message: ""
470
- });
428
+ function parsePane(value, path) {
429
+ const row = object(value, path, PANE_KEYS);
430
+ const agent = parseAgent(row.agent, `${path}.agent`);
431
+ const attention = parseAttention(row.attention, `${path}.attention`);
432
+ if (agent === null && attention.length === 0) {
433
+ fail(path, "a pane with no agent and no attention must not be emitted");
471
434
  }
435
+ return {
436
+ pane: asPaneId(text(row.pane, `${path}.pane`)),
437
+ session: asSessionId(text(row.session, `${path}.session`)),
438
+ window: asWindowId(text(row.window, `${path}.window`)),
439
+ session_name: textOrNull(row.session_name, `${path}.session_name`),
440
+ window_name: textOrNull(row.window_name, `${path}.window_name`),
441
+ agent,
442
+ attention
443
+ };
472
444
  }
473
- function exportJsonl(store, since, isAlive, live) {
474
- const identity = ensureIdentity();
475
- synthesizeCrashes(store, identity.host_id, isAlive);
476
- if (live !== void 0) clearDeadWindows(store, identity.host_id, live);
477
- const envelope = {
478
- schema_version: SCHEMA_VERSION,
479
- host_id: identity.host_id,
480
- display_name: identity.display_name,
481
- exported_at: Date.now()
445
+ function parseSnapshot(input) {
446
+ let parsed;
447
+ try {
448
+ parsed = JSON.parse(input);
449
+ } catch (error) {
450
+ fail("", `not JSON (${error instanceof Error ? error.message : String(error)})`);
451
+ }
452
+ const top = object(parsed, "", TOP_KEYS);
453
+ if (top.murmur_snapshot !== 1) {
454
+ fail("murmur_snapshot", `expected 1, got ${JSON.stringify(top.murmur_snapshot)}`);
455
+ }
456
+ if (!Array.isArray(top.panes)) fail("panes", "expected an array");
457
+ const panes = top.panes.map((entry, index) => parsePane(entry, `panes[${index}]`));
458
+ const seen = /* @__PURE__ */ new Set();
459
+ for (const pane of panes) {
460
+ if (seen.has(pane.pane)) fail("panes", `duplicate pane ${pane.pane}`);
461
+ seen.add(pane.pane);
462
+ }
463
+ return {
464
+ murmur_snapshot: 1,
465
+ host_id: text(top.host_id, "host_id"),
466
+ display_name: text(top.display_name, "display_name"),
467
+ murmur_version: text(top.murmur_version, "murmur_version"),
468
+ generated_at: timestamp(top.generated_at, "generated_at"),
469
+ panes
482
470
  };
483
- const lines = [
484
- JSON.stringify(envelope),
485
- ...store.eventsSince(identity.host_id, since).map((event) => JSON.stringify(eventToWire(event)))
486
- ];
487
- return `${lines.join("\n")}
488
- `;
489
471
  }
490
472
 
491
- // src/collector.ts
492
- var COLLECT_INTERVAL_MS = 3e4;
493
- var STALENESS_MS = 2 * COLLECT_INTERVAL_MS;
494
- function parseJsonl(output) {
495
- const lines = output.trim().split("\n");
496
- const envelope = JSON.parse(lines.shift() ?? "");
497
- if (envelope.schema_version > SCHEMA_VERSION) {
498
- throw new Error(
499
- `unsupported schema version ${envelope.schema_version} (supports ${SCHEMA_VERSION})`
500
- );
473
+ // src/types.ts
474
+ var DEFAULT_DRIVER = "human";
475
+
476
+ // src/view.ts
477
+ var RENDER_PRIORITY = [
478
+ "crashed",
479
+ "blocked",
480
+ "done",
481
+ "running",
482
+ "idle"
483
+ ];
484
+ var NEEDS_HUMAN = ["blocked", "crashed"];
485
+ var STALENESS_MS = 6e4;
486
+ function age(ms) {
487
+ if (ms === null || ms < 6e4) return "";
488
+ if (ms < 36e5) return `${Math.floor(ms / 6e4)}m`;
489
+ if (ms < 864e5) return `${Math.floor(ms / 36e5)}h`;
490
+ return `${Math.floor(ms / 864e5)}d`;
491
+ }
492
+ function freshness(fetchedAt, now, thresholdMs = STALENESS_MS) {
493
+ return fetchedAt !== null && now - fetchedAt <= thresholdMs ? "fresh" : "stale";
494
+ }
495
+ function renderState(view) {
496
+ for (const kind of ["crashed", "blocked", "done"]) {
497
+ if (view.attention.includes(kind)) return kind;
501
498
  }
499
+ return view.activity === "running" ? "running" : "idle";
500
+ }
501
+ function newestAttention(pane) {
502
+ let newest = null;
503
+ for (const entry of pane.attention) {
504
+ if (newest === null || entry.requested_at > newest) newest = entry.requested_at;
505
+ }
506
+ return newest;
507
+ }
508
+ function paneView(pane, source) {
509
+ const agent = pane.agent;
502
510
  return {
503
- envelope,
504
- events: lines.map((line) => eventFromWire(JSON.parse(line)))
511
+ host_id: source.host_id,
512
+ host: source.host,
513
+ local: source.local,
514
+ pane: pane.pane,
515
+ session: pane.session,
516
+ window: pane.window,
517
+ session_name: pane.session_name,
518
+ window_name: pane.window_name,
519
+ activity: agent?.activity ?? null,
520
+ attention: pane.attention.map((entry) => entry.kind),
521
+ freshness: source.freshness,
522
+ agent_id: agent?.agent_id ?? null,
523
+ agent_name: agent?.agent_name ?? null,
524
+ pi_session: agent?.pi_session ?? null,
525
+ workstream: agent?.workstream ?? null,
526
+ role: agent?.role ?? null,
527
+ cli: agent?.cli ?? null,
528
+ driver: agent?.driver ?? DEFAULT_DRIVER,
529
+ updated_at: agent?.updated_at ?? newestAttention(pane),
530
+ snapshot_at: source.snapshot_at,
531
+ fetched_at: source.fetched_at
505
532
  };
506
533
  }
507
- async function collect(store, channel, now = Date.now()) {
534
+ function paneViews(store, identity, now = Date.now()) {
535
+ const views = store.localPanes().map(
536
+ (pane) => paneView(pane, {
537
+ host_id: identity.host_id,
538
+ host: identity.display_name,
539
+ local: true,
540
+ // Local panes are always fresh: we are the node that authored them.
541
+ freshness: "fresh",
542
+ snapshot_at: null,
543
+ fetched_at: null
544
+ })
545
+ );
546
+ for (const peer of store.peers()) {
547
+ const snapshot = peer.snapshot;
548
+ if (!snapshot) continue;
549
+ const source = {
550
+ host_id: snapshot.host_id,
551
+ // The name the human typed, not the machine's self-reported hostname: a
552
+ // peer added as `linuxpc` can report a container id, which appears
553
+ // nowhere else in the tool and cannot be typed at `peer remove`.
554
+ host: peer.name,
555
+ local: false,
556
+ freshness: freshness(peer.fetched_at, now),
557
+ snapshot_at: peer.snapshot_at,
558
+ fetched_at: peer.fetched_at
559
+ };
560
+ for (const pane of snapshot.panes) views.push(paneView(pane, source));
561
+ }
562
+ return views;
563
+ }
564
+ var ORDER = new Map(RENDER_PRIORITY.map((state, index) => [state, index]));
565
+ function viewSort(views) {
566
+ return [...views].sort((left, right) => {
567
+ const byState = (ORDER.get(renderState(left)) ?? 99) - (ORDER.get(renderState(right)) ?? 99);
568
+ if (byState !== 0) return byState;
569
+ const byAge = (right.updated_at ?? 0) - (left.updated_at ?? 0);
570
+ if (byAge !== 0) return byAge;
571
+ const byHost = left.host.localeCompare(right.host);
572
+ return byHost !== 0 ? byHost : left.pane.localeCompare(right.pane);
573
+ });
574
+ }
575
+
576
+ // src/collector.ts
577
+ var MAX_CONCURRENT_PEERS = 8;
578
+ var COLLECT_DEADLINE_MS = 4e3;
579
+ async function mapSettled(items, limit, task, deadline) {
580
+ const results = new Array(items.length);
581
+ let cursor = 0;
582
+ let expired = false;
583
+ const stop = deadline?.then(() => {
584
+ expired = true;
585
+ });
586
+ const worker = async () => {
587
+ while (cursor < items.length && !expired) {
588
+ const index = cursor++;
589
+ try {
590
+ results[index] = { status: "fulfilled", value: await task(items[index]) };
591
+ } catch (reason) {
592
+ results[index] = { status: "rejected", reason };
593
+ }
594
+ }
595
+ };
596
+ const pool = Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
597
+ await (stop ? Promise.race([pool, stop]) : pool);
598
+ return results;
599
+ }
600
+ function isUnreachable(message) {
601
+ return /Host is down|No route to host|Connection refused|Connection timed out|Connection closed|Operation timed out|Network is unreachable|Name or service not known|Could not resolve hostname|timed out after/i.test(
602
+ message
603
+ ) || /\bssh:/.test(message);
604
+ }
605
+ function stripInvocation(message) {
606
+ const firstLine = message.indexOf("\n");
607
+ if (firstLine === -1 || !message.startsWith("Command failed:")) return message;
608
+ const rest = message.slice(firstLine + 1).trim();
609
+ return rest === "" ? message : rest;
610
+ }
611
+ function normalizeFailure(message) {
612
+ return stripInvocation(message).replace(/\s+/g, " ").trim();
613
+ }
614
+ async function collect(store, channel, now = Date.now(), deadline) {
508
615
  const results = [];
616
+ let timer;
509
617
  try {
510
- for (const peer of store.peers()) {
618
+ const peers = store.peers();
619
+ const bounded = deadline ?? new Promise((resolve) => {
620
+ timer = setTimeout(resolve, COLLECT_DEADLINE_MS);
621
+ timer.unref?.();
622
+ });
623
+ const fetches = await mapSettled(
624
+ peers,
625
+ MAX_CONCURRENT_PEERS,
626
+ async (peer) => parseSnapshot(await channel.exec(peer.target, ["murmur", "export"])),
627
+ bounded
628
+ );
629
+ for (const [index, peer] of peers.entries()) {
630
+ const fetch = fetches[index];
511
631
  try {
512
- const output = await channel.exec(peer.target, [
513
- "murmur",
514
- "export",
515
- "--since",
516
- String(peer.watermark)
517
- ]);
518
- const { envelope, events } = parseJsonl(output);
519
- const ingested = store.ingest(events);
520
- const watermark = events.filter((event) => event.host_id === envelope.host_id).reduce((highest, event) => Math.max(highest, event.seq), peer.watermark);
521
- store.upsertPeer({
522
- name: peer.name,
523
- target: peer.target,
524
- host_id: envelope.host_id,
525
- display_name: envelope.display_name,
526
- watermark,
527
- fetched_at: now,
528
- // New events mean the node is authoring again, so whatever a jump
529
- // observed about its tmux is out of date. Only clear on actual new
530
- // events: an export that returns nothing proves the binary ran, not
531
- // that tmux is back, which is the distinction that let a dead host
532
- // look healthy for three hours.
533
- tmux_down_at: ingested > 0 ? null : peer.tmux_down_at
534
- });
535
- store.prune();
536
- results.push({ peer: peer.name, ok: true, ingested });
632
+ if (!fetch) throw new Error("collect deadline passed before this peer answered");
633
+ if (fetch.status === "rejected") throw fetch.reason;
634
+ store.replacePeerSnapshot(peer.name, { ok: true, snapshot: fetch.value, at: now });
635
+ results.push({ peer: peer.name, ok: true, panes: fetch.value.panes.length });
537
636
  } catch (error) {
538
- const message = error instanceof Error ? error.message : String(error);
539
- process.stderr.write(`murmur: collect: peer ${peer.name}: ${message}
540
- `);
541
- results.push({ peer: peer.name, ok: false, ingested: 0, error: message });
637
+ const message = normalizeFailure(error instanceof Error ? error.message : String(error));
638
+ store.replacePeerSnapshot(peer.name, { ok: false, error: message, at: now });
639
+ results.push({
640
+ peer: peer.name,
641
+ ok: false,
642
+ panes: 0,
643
+ error: message,
644
+ // A peer that answered with a bad document is reachable but broken,
645
+ // and must be visibly so rather than silently stale.
646
+ unreachable: error instanceof SnapshotInvalidError ? false : isUnreachable(normalizeFailure(message))
647
+ });
542
648
  }
543
649
  }
544
650
  } catch (error) {
545
- process.stderr.write(
546
- `murmur: collect: ${error instanceof Error ? error.message : String(error)}
547
- `
548
- );
651
+ results.push({
652
+ peer: "",
653
+ ok: false,
654
+ panes: 0,
655
+ error: error instanceof Error ? error.message : String(error)
656
+ });
657
+ } finally {
658
+ clearTimeout(timer);
659
+ }
660
+ try {
661
+ store.reconcileLocal({ panes: tmux.livePanes(), now });
662
+ } catch {
549
663
  }
550
664
  return results;
551
665
  }
@@ -553,18 +667,8 @@ async function collect(store, channel, now = Date.now()) {
553
667
  // src/glance.ts
554
668
  import { execFileSync as execFileSync3 } from "child_process";
555
669
  var GLANCE_LINES = 40;
556
- var SSH_OPTIONS2 = [
557
- "-o",
558
- "BatchMode=yes",
559
- "-o",
560
- "ControlMaster=no",
561
- "-o",
562
- "ControlPath=~/.ssh/control/%r@%h:%p",
563
- "-o",
564
- "ConnectTimeout=2"
565
- ];
566
670
  function glance(store, agent, lines = GLANCE_LINES) {
567
- if (agent.host_id === loadIdentity()?.host_id) return tmux.capture(agent.pane, lines);
671
+ if (agent.local) return tmux.capture(agent.pane, lines);
568
672
  const peer = store.peers().find((candidate) => candidate.host_id === agent.host_id);
569
673
  const target = peer?.target ?? peer?.name;
570
674
  if (!target) return null;
@@ -572,7 +676,7 @@ function glance(store, agent, lines = GLANCE_LINES) {
572
676
  return execFileSync3(
573
677
  "ssh",
574
678
  [
575
- ...SSH_OPTIONS2,
679
+ ...SSH_OPTIONS,
576
680
  target,
577
681
  "tmux",
578
682
  "capture-pane",
@@ -589,329 +693,554 @@ function glance(store, agent, lines = GLANCE_LINES) {
589
693
  }
590
694
  }
591
695
 
696
+ // src/identity.ts
697
+ import { randomUUID } from "crypto";
698
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
699
+ import { hostname } from "os";
700
+ import { join as join2 } from "path";
701
+
702
+ // src/paths.ts
703
+ import { homedir } from "os";
704
+ import { join } from "path";
705
+ function stateDir() {
706
+ return process.env.MURMUR_STATE_DIR ?? join(process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state"), "murmur");
707
+ }
708
+ function configDir() {
709
+ return process.env.MURMUR_CONFIG_DIR ?? join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "murmur");
710
+ }
711
+ function dbPath() {
712
+ return join(stateDir(), "state.db");
713
+ }
714
+
715
+ // src/identity.ts
716
+ function identityPath() {
717
+ return join2(stateDir(), "identity.json");
718
+ }
719
+ var cache = null;
720
+ function loadIdentity() {
721
+ const path = identityPath();
722
+ if (cache?.path === path) return cache.identity;
723
+ const identity = existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) : null;
724
+ cache = { path, identity };
725
+ return identity;
726
+ }
727
+ function write(identity) {
728
+ mkdirSync(stateDir(), { recursive: true });
729
+ writeFileSync(identityPath(), `${JSON.stringify(identity, null, 2)}
730
+ `);
731
+ cache = { path: identityPath(), identity };
732
+ return identity;
733
+ }
734
+ function createIdentity(displayName = hostname()) {
735
+ if (loadIdentity()) throw new Error(`identity already exists: ${identityPath()}`);
736
+ return write({ host_id: randomUUID(), display_name: displayName });
737
+ }
738
+ function setDisplayName(displayName) {
739
+ const existing = loadIdentity();
740
+ return write(
741
+ existing ? { host_id: existing.host_id, display_name: displayName } : { host_id: randomUUID(), display_name: displayName }
742
+ );
743
+ }
744
+
592
745
  // src/status.ts
593
746
  function emptyCounts() {
594
- return { working: 0, blocked: 0, done: 0, crashed: 0, idle: 0 };
747
+ const counts = {};
748
+ for (const state of RENDER_PRIORITY) counts[state] = 0;
749
+ return counts;
595
750
  }
596
- function status(store, now = Date.now()) {
597
- const identity = loadIdentity();
598
- const peers = store.peers();
599
- const peersByHost = new Map(
600
- peers.flatMap((peer) => peer.host_id === null ? [] : [[peer.host_id, peer]])
601
- );
602
- const events = store.allEvents();
603
- const local = foldAll(
604
- events.filter((event) => event.host_id === identity?.host_id),
605
- pidAlive
606
- );
607
- const remote = foldAll(
608
- events.filter((event) => event.host_id !== identity?.host_id),
609
- () => true
610
- );
751
+ function tmuxStatus(view) {
752
+ const needsHuman = new Set(NEEDS_HUMAN);
753
+ const total = (state) => view.counts[state] + (needsHuman.has(state) ? view.orchestrated_counts[state] : 0);
754
+ return RENDER_PRIORITY.filter((state) => total(state) > 0).map((state) => `${state === "running" ? "working" : state} ${total(state)}
755
+ `).join("");
756
+ }
757
+ function status(store, identity, now = Date.now()) {
611
758
  const counts = emptyCounts();
612
759
  const orchestratedCounts = emptyCounts();
613
- const agents = attentionSort([...local, ...remote]).map((agent) => {
614
- const peer = peersByHost.get(agent.host_id);
615
- const fetchedAt = peer?.fetched_at ?? null;
616
- const state = agent.state === null || agent.state === "cleared" ? "idle" : agent.state;
617
- const target = agent.driver === "human" ? counts : orchestratedCounts;
618
- target[state] += 1;
619
- return {
620
- ...agent,
621
- fetched_at: fetchedAt,
622
- // Replica freshness: how long since we last reached the peer. Local rows
623
- // have no fetched_at and are never stale.
624
- stale: isStale(fetchedAt, now, STALENESS_MS),
625
- age_ms: fetchedAt === null ? null : now - fetchedAt,
626
- // Information age: how long since the agent itself said anything. This
627
- // is the number a human means by "how stale is that row". A successful
628
- // fetch of a three-hour-old event resets age_ms to zero but leaves this
629
- // at three hours, which is why they cannot be the same field.
630
- event_age_ms: agent.event === null ? null : Math.max(0, now - agent.event.ts),
631
- // A jump proved this host's tmux was down and nothing has authored since.
632
- // Stronger than staleness: the host answers, its agents are just gone.
633
- tmux_down: peer?.tmux_down_at != null,
634
- // The name the human typed, not the machine's self-reported hostname. A
635
- // peer added as `linuxpc` reported `18c04d69b860` (a container hostname)
636
- // and that is what the picker showed — a string that appears nowhere
637
- // else in the tool and cannot be typed at `peer remove` or searched for.
638
- // Only the local node, which has no peer row, falls back to its own
639
- // discovered display_name.
640
- host: peer?.name ?? (agent.host_id === identity?.host_id ? identity.display_name : agent.host_id)
641
- };
642
- });
760
+ const panes = viewSort(paneViews(store, identity, now));
761
+ for (const pane of panes) {
762
+ const target = pane.driver === "human" ? counts : orchestratedCounts;
763
+ target[renderState(pane)] += 1;
764
+ }
643
765
  return {
644
766
  counts,
645
767
  orchestrated_counts: orchestratedCounts,
646
- agents,
647
- peers: peers.map((peer) => ({
768
+ panes,
769
+ peers: store.peers().map((peer) => ({
648
770
  name: peer.name,
649
771
  display_name: peer.display_name,
650
772
  fetched_at: peer.fetched_at,
651
- // A peer we have never reached is stale, not fresh. `isStale` reads a
652
- // null `fetched_at` as "local, therefore never stale", which is right
653
- // for an agent row but backwards for a peer: null there means the very
654
- // first collect has not succeeded yet. Left to `isStale`, an
655
- // unreachable host you just added would render as up to date.
656
- stale: peer.fetched_at === null || isStale(peer.fetched_at, now, STALENESS_MS)
773
+ // Their clock and ours, separately: a peer polled a second ago can be
774
+ // serving a three-hour-old fact, and one number cannot say both.
775
+ snapshot_at: peer.snapshot_at,
776
+ last_error: peer.last_error,
777
+ // The view's verdict, not a second threshold spelled the same way. A
778
+ // peer we have never reached is stale rather than fresh -- null
779
+ // `fetched_at` means the first collect has not succeeded yet -- and
780
+ // `freshness` is the one place that decides, so this list and the panes
781
+ // the peer contributes cannot disagree about the same host.
782
+ stale: freshness(peer.fetched_at, now) === "stale"
657
783
  }))
658
784
  };
659
785
  }
786
+ async function statusWithCollect(store, identity, now = Date.now(), channel = ssh) {
787
+ try {
788
+ await collect(store, channel, now);
789
+ } catch {
790
+ }
791
+ return status(store, identity, now);
792
+ }
660
793
 
661
794
  // src/store.ts
662
- import { rmSync } from "fs";
795
+ import { randomUUID as randomUUID2 } from "crypto";
796
+ import { mkdirSync as mkdirSync2, rmSync } from "fs";
797
+ import { dirname } from "path";
663
798
  import Database from "better-sqlite3";
664
- var DEFAULT_RETENTION_MS = 7 * 864e5;
665
- var STORE_VERSION = 2;
666
- function resetIfStale(path) {
667
- let salvaged = [];
799
+
800
+ // src/version.ts
801
+ import { createRequire } from "module";
802
+ function readVersion() {
803
+ const require2 = createRequire(import.meta.url);
804
+ for (const candidate of ["../package.json", "../../package.json"]) {
805
+ try {
806
+ return require2(candidate).version;
807
+ } catch {
808
+ }
809
+ }
810
+ throw new Error("cannot locate package.json to read the murmur version");
811
+ }
812
+ var MURMUR_VERSION = readVersion();
813
+
814
+ // src/store.ts
815
+ var SCHEMA_USER_VERSION = 3;
816
+ var SCHEMA = `
817
+ CREATE TABLE agents (
818
+ agent_id TEXT NOT NULL PRIMARY KEY,
819
+ pane TEXT NOT NULL UNIQUE,
820
+ owner_pid INTEGER NOT NULL CHECK (owner_pid > 0),
821
+ activity TEXT NOT NULL CHECK (activity IN ('running', 'stopped')),
822
+ session TEXT NOT NULL,
823
+ window TEXT NOT NULL,
824
+ session_name TEXT,
825
+ window_name TEXT,
826
+ agent_name TEXT,
827
+ pi_session TEXT,
828
+ workstream TEXT,
829
+ role TEXT,
830
+ cli TEXT NOT NULL,
831
+ driver TEXT NOT NULL CHECK (driver IN ('human', 'orchestrated')),
832
+ claimed_at INTEGER NOT NULL,
833
+ updated_at INTEGER NOT NULL
834
+ ) STRICT;
835
+
836
+ CREATE TABLE attention (
837
+ pane TEXT NOT NULL,
838
+ kind TEXT NOT NULL CHECK (kind IN ('done', 'blocked', 'crashed')),
839
+ message TEXT NOT NULL,
840
+ source TEXT NOT NULL,
841
+ session TEXT NOT NULL,
842
+ window TEXT NOT NULL,
843
+ session_name TEXT,
844
+ window_name TEXT,
845
+ requested_at INTEGER NOT NULL,
846
+ PRIMARY KEY (pane, kind)
847
+ ) STRICT;
848
+
849
+ CREATE TABLE peers (
850
+ name TEXT NOT NULL PRIMARY KEY,
851
+ target TEXT NOT NULL,
852
+ host_id TEXT,
853
+ display_name TEXT,
854
+ snapshot TEXT,
855
+ snapshot_at INTEGER,
856
+ fetched_at INTEGER,
857
+ last_attempt_at INTEGER,
858
+ last_error TEXT,
859
+ murmur_version TEXT,
860
+ snapshot_version INTEGER
861
+ ) STRICT;
862
+ `;
863
+ function salvagePeers(path) {
668
864
  try {
669
865
  const existing = new Database(path, { fileMustExist: true });
670
- const version = existing.pragma("user_version", { simple: true }) ?? 0;
671
- if (version === STORE_VERSION) {
866
+ try {
867
+ const version = existing.pragma("user_version", { simple: true }) ?? 0;
868
+ if (version === SCHEMA_USER_VERSION) return [];
869
+ return existing.prepare("SELECT name, target FROM peers").all();
870
+ } catch {
871
+ return [];
872
+ } finally {
672
873
  existing.close();
673
- return salvaged;
674
874
  }
875
+ } catch {
876
+ return [];
877
+ }
878
+ }
879
+ function needsReset(path) {
880
+ try {
881
+ const existing = new Database(path, { fileMustExist: true });
675
882
  try {
676
- salvaged = existing.prepare("SELECT name, target, host_id, display_name FROM peers").all();
677
- } catch {
883
+ return (existing.pragma("user_version", { simple: true }) ?? 0) !== SCHEMA_USER_VERSION;
884
+ } finally {
885
+ existing.close();
678
886
  }
679
- existing.close();
680
887
  } catch {
681
- return salvaged;
682
- }
683
- for (const suffix of ["", "-wal", "-shm"]) rmSync(`${path}${suffix}`, { force: true });
684
- return salvaged;
685
- }
686
- function eventValues(event) {
687
- return [
688
- event.host_id,
689
- event.seq,
690
- event.ts,
691
- event.agent_id,
692
- event.session,
693
- event.window,
694
- event.pane,
695
- event.session_name,
696
- event.window_name,
697
- event.agent_name,
698
- event.pi_session,
699
- event.workstream,
700
- event.role,
701
- event.cli,
702
- event.driver,
703
- event.kind,
704
- event.state,
705
- event.message,
706
- event.pid,
707
- Number(event.synthetic),
708
- event.reason,
709
- JSON.stringify(event.extra)
710
- ];
711
- }
712
- function toEvent(row) {
888
+ return false;
889
+ }
890
+ }
891
+ function toAttention(row) {
892
+ return {
893
+ kind: row.kind,
894
+ message: row.message,
895
+ source: row.source,
896
+ requested_at: row.requested_at
897
+ };
898
+ }
899
+ function toAgent(row) {
713
900
  return {
714
- ...row,
901
+ agent_id: row.agent_id,
902
+ activity: row.activity,
903
+ agent_name: row.agent_name,
904
+ pi_session: row.pi_session,
905
+ workstream: row.workstream,
906
+ role: row.role,
907
+ cli: row.cli,
715
908
  driver: row.driver,
716
- synthetic: row.synthetic === 1,
717
- extra: JSON.parse(row.extra)
909
+ claimed_at: row.claimed_at,
910
+ updated_at: row.updated_at
718
911
  };
719
912
  }
913
+ var PRIORITY = new Map(RENDER_PRIORITY.map((kind, index) => [kind, index]));
914
+ function attentionOrder(left, right) {
915
+ return (PRIORITY.get(left.kind) ?? 99) - (PRIORITY.get(right.kind) ?? 99);
916
+ }
720
917
  function openStore() {
721
- const identity = ensureIdentity();
722
918
  const path = dbPath();
723
- const salvagedPeers = resetIfStale(path);
919
+ mkdirSync2(dirname(path), { recursive: true });
920
+ const salvaged = salvagePeers(path);
921
+ if (needsReset(path)) {
922
+ for (const suffix of ["", "-wal", "-shm"]) rmSync(`${path}${suffix}`, { force: true });
923
+ }
724
924
  const database = new Database(path);
725
925
  database.pragma("journal_mode = WAL");
726
- database.pragma(`user_version = ${STORE_VERSION}`);
727
- database.exec(`
728
- CREATE TABLE IF NOT EXISTS events (
729
- host_id TEXT NOT NULL,
730
- seq INTEGER NOT NULL,
731
- ts INTEGER NOT NULL,
732
- agent_id TEXT NOT NULL,
733
- session TEXT NOT NULL,
734
- window TEXT NOT NULL,
735
- pane TEXT NOT NULL,
736
- session_name TEXT,
737
- window_name TEXT,
738
- agent_name TEXT,
739
- pi_session TEXT,
740
- workstream TEXT,
741
- role TEXT,
742
- cli TEXT,
743
- driver TEXT,
744
- kind TEXT NOT NULL,
745
- state TEXT NOT NULL,
746
- message TEXT NOT NULL,
747
- pid INTEGER,
748
- synthetic INTEGER NOT NULL,
749
- reason TEXT NOT NULL,
750
- extra TEXT NOT NULL,
751
- PRIMARY KEY (host_id, seq)
752
- );
753
- CREATE INDEX IF NOT EXISTS events_agent_seq ON events (agent_id, seq);
754
- CREATE TABLE IF NOT EXISTS peers (
755
- name TEXT PRIMARY KEY,
756
- target TEXT NOT NULL,
757
- host_id TEXT,
758
- display_name TEXT,
759
- watermark INTEGER NOT NULL,
760
- fetched_at INTEGER,
761
- -- When a jump last proved this peer's tmux was not answering. Reader
762
- -- state, not an event: this node cannot author facts about another
763
- -- node's agents, and a jump is a local observation, not something the
764
- -- peer said. Cleared by the next successful collect.
765
- tmux_down_at INTEGER
766
- );
767
- `);
768
- try {
769
- database.exec("ALTER TABLE peers ADD COLUMN tmux_down_at INTEGER");
770
- } catch {
771
- }
772
- if (salvagedPeers.length > 0) {
773
- const restore = database.prepare(
774
- `INSERT OR IGNORE INTO peers (name, target, host_id, display_name, watermark, fetched_at)
775
- VALUES (?, ?, ?, ?, 0, NULL)`
776
- );
777
- for (const peer of salvagedPeers) {
778
- restore.run(peer.name, peer.target, peer.host_id ?? null, peer.display_name ?? null);
779
- }
926
+ database.pragma("busy_timeout = 5000");
927
+ const version = database.pragma("user_version", { simple: true }) ?? 0;
928
+ if (version !== SCHEMA_USER_VERSION) {
929
+ database.exec(SCHEMA);
930
+ database.pragma(`user_version = ${SCHEMA_USER_VERSION}`);
931
+ const restore = database.prepare("INSERT OR IGNORE INTO peers (name, target) VALUES (?, ?)");
932
+ for (const peer of salvaged) restore.run(peer.name, peer.target);
780
933
  }
781
- const eventColumns = `
782
- host_id, seq, ts, agent_id, session, window, pane,
783
- session_name, window_name, agent_name, pi_session,
784
- workstream, role, cli, driver, kind, state, message, pid,
785
- synthetic, reason, extra`;
786
- const eventPlaceholders = new Array(22).fill("?").join(", ");
787
- const insertEvent = database.prepare(
788
- `INSERT INTO events (${eventColumns}) VALUES (${eventPlaceholders})`
789
- );
790
- const ingestEvent = database.prepare(
791
- `INSERT OR IGNORE INTO events (${eventColumns}) VALUES (${eventPlaceholders})`
934
+ const selectAgentByPane = database.prepare("SELECT * FROM agents WHERE pane = ?");
935
+ const insertAgent = database.prepare(`
936
+ INSERT INTO agents (agent_id, pane, owner_pid, activity, session, window,
937
+ session_name, window_name, agent_name, pi_session,
938
+ workstream, role, cli, driver, claimed_at, updated_at)
939
+ VALUES (@agent_id, @pane, @owner_pid, @activity, @session, @window,
940
+ @session_name, @window_name, @agent_name, @pi_session,
941
+ @workstream, @role, @cli, @driver, @claimed_at, @updated_at)
942
+ `);
943
+ const retainAgent = database.prepare(`
944
+ UPDATE agents
945
+ SET session = @session, window = @window, session_name = @session_name,
946
+ window_name = @window_name, agent_name = @agent_name,
947
+ pi_session = @pi_session, workstream = @workstream, role = @role,
948
+ cli = @cli, driver = @driver, updated_at = @updated_at
949
+ WHERE agent_id = @agent_id
950
+ `);
951
+ const deleteAgentByPane = database.prepare("DELETE FROM agents WHERE pane = ?");
952
+ const deleteAttentionForPane = database.prepare("DELETE FROM attention WHERE pane = ?");
953
+ const updateActivity = database.prepare(`
954
+ UPDATE agents
955
+ SET activity = @activity, session = @session, window = @window,
956
+ session_name = @session_name, window_name = @window_name,
957
+ updated_at = @updated_at
958
+ WHERE agent_id = @agent_id AND owner_pid = @owner_pid
959
+ `);
960
+ const deleteAgentOwned = database.prepare(
961
+ "DELETE FROM agents WHERE agent_id = ? AND owner_pid = ?"
792
962
  );
793
- const selectMaxSeq = database.prepare(
794
- "SELECT COALESCE(MAX(seq), 0) AS seq FROM events WHERE host_id = ?"
963
+ const upsertAttention = database.prepare(`
964
+ INSERT INTO attention (pane, kind, message, source, session, window,
965
+ session_name, window_name, requested_at)
966
+ VALUES (@pane, @kind, @message, @source, @session, @window,
967
+ @session_name, @window_name, @requested_at)
968
+ ON CONFLICT (pane, kind) DO UPDATE SET
969
+ message = excluded.message,
970
+ source = excluded.source,
971
+ session = excluded.session,
972
+ window = excluded.window,
973
+ session_name = excluded.session_name,
974
+ window_name = excluded.window_name
975
+ `);
976
+ const selectAgents = database.prepare("SELECT * FROM agents");
977
+ const selectAttention = database.prepare("SELECT * FROM attention");
978
+ const setActivityByPane = database.prepare(
979
+ "UPDATE agents SET activity = ?, updated_at = ? WHERE pane = ?"
795
980
  );
796
- const append = database.transaction((event) => {
797
- const row = selectMaxSeq.get(identity.host_id);
798
- const stored = {
799
- ...event,
800
- host_id: identity.host_id,
801
- seq: row.seq + 1,
802
- ts: event.ts ?? Date.now(),
803
- session_name: event.session_name ?? null,
804
- window_name: event.window_name ?? null,
805
- agent_name: event.agent_name ?? null,
806
- pi_session: event.pi_session ?? null
981
+ const claimAgent = database.transaction((claim) => {
982
+ const now = claim.now ?? Date.now();
983
+ const isAlive = claim.isAlive ?? pidAlive;
984
+ const { location, meta, owner_pid } = claim;
985
+ const incumbent = selectAgentByPane.get(location.pane);
986
+ const values = {
987
+ pane: location.pane,
988
+ owner_pid,
989
+ session: location.session,
990
+ window: location.window,
991
+ session_name: location.session_name,
992
+ window_name: location.window_name,
993
+ agent_name: meta.agent_name,
994
+ pi_session: meta.pi_session,
995
+ workstream: meta.workstream,
996
+ role: meta.role,
997
+ cli: meta.cli,
998
+ driver: meta.driver,
999
+ updated_at: now
807
1000
  };
808
- insertEvent.run(...eventValues(stored));
809
- return stored;
810
- });
811
- const ingest = database.transaction((events) => {
812
- let inserted = 0;
813
- for (const event of events) inserted += ingestEvent.run(...eventValues(event)).changes;
814
- return inserted;
1001
+ if (!incumbent) {
1002
+ const agentId2 = randomUUID2();
1003
+ insertAgent.run({ ...values, agent_id: agentId2, activity: "stopped", claimed_at: now });
1004
+ return { outcome: "claimed", agent_id: agentId2 };
1005
+ }
1006
+ if (incumbent.owner_pid === owner_pid) {
1007
+ retainAgent.run({ ...values, agent_id: incumbent.agent_id });
1008
+ return { outcome: "retained", agent_id: incumbent.agent_id };
1009
+ }
1010
+ if (isAlive(incumbent.owner_pid)) {
1011
+ return { outcome: "refused", held_by_pid: incumbent.owner_pid };
1012
+ }
1013
+ deleteAgentByPane.run(location.pane);
1014
+ deleteAttentionForPane.run(location.pane);
1015
+ const agentId = randomUUID2();
1016
+ insertAgent.run({ ...values, agent_id: agentId, activity: "stopped", claimed_at: now });
1017
+ return { outcome: "replaced", agent_id: agentId, previous_agent_id: incumbent.agent_id };
1018
+ }).immediate;
1019
+ const reconcileLocal = database.transaction((world) => {
1020
+ const summary = { crashed: [], removed: [], attention_removed: [] };
1021
+ if (world.panes === null) return summary;
1022
+ const live = world.panes;
1023
+ const isAlive = world.isAlive ?? pidAlive;
1024
+ const now = world.now ?? Date.now();
1025
+ const alreadyCrashed = new Set(
1026
+ selectAttention.all().filter((row) => row.kind === "crashed").map((row) => row.pane)
1027
+ );
1028
+ for (const row of selectAgents.all()) {
1029
+ const pane = asPaneId(row.pane);
1030
+ if (!live.has(pane)) {
1031
+ deleteAgentByPane.run(row.pane);
1032
+ deleteAttentionForPane.run(row.pane);
1033
+ summary.removed.push(pane);
1034
+ continue;
1035
+ }
1036
+ if (isAlive(row.owner_pid)) continue;
1037
+ if (row.activity === "running") {
1038
+ setActivityByPane.run("stopped", now, row.pane);
1039
+ upsertAttention.run({
1040
+ pane: row.pane,
1041
+ kind: "crashed",
1042
+ message: "",
1043
+ source: "murmur",
1044
+ session: row.session,
1045
+ window: row.window,
1046
+ session_name: row.session_name,
1047
+ window_name: row.window_name,
1048
+ requested_at: now
1049
+ });
1050
+ summary.crashed.push(pane);
1051
+ } else if (!alreadyCrashed.has(row.pane)) {
1052
+ deleteAgentByPane.run(row.pane);
1053
+ summary.removed.push(pane);
1054
+ }
1055
+ }
1056
+ for (const row of selectAttention.all()) {
1057
+ const pane = asPaneId(row.pane);
1058
+ if (live.has(pane)) continue;
1059
+ deleteAttentionForPane.run(row.pane);
1060
+ if (!summary.attention_removed.includes(pane)) summary.attention_removed.push(pane);
1061
+ }
1062
+ return summary;
1063
+ }).immediate;
1064
+ const readLocalPanes = database.transaction(() => {
1065
+ const agents = selectAgents.all();
1066
+ const attention = selectAttention.all();
1067
+ const panes = /* @__PURE__ */ new Map();
1068
+ const locate = (row) => {
1069
+ const existing = panes.get(row.pane);
1070
+ if (existing) return existing;
1071
+ const created = {
1072
+ pane: asPaneId(row.pane),
1073
+ session: asSessionId(row.session),
1074
+ window: asWindowId(row.window),
1075
+ session_name: row.session_name,
1076
+ window_name: row.window_name,
1077
+ agent: null,
1078
+ attention: []
1079
+ };
1080
+ panes.set(row.pane, created);
1081
+ return created;
1082
+ };
1083
+ for (const row of agents) locate(row).agent = toAgent(row);
1084
+ for (const row of attention) locate(row).attention.push(toAttention(row));
1085
+ for (const pane of panes.values()) pane.attention.sort(attentionOrder);
1086
+ return [...panes.values()].sort((left, right) => left.pane.localeCompare(right.pane));
815
1087
  });
1088
+ function peerRecord(row) {
1089
+ let snapshot = null;
1090
+ if (row.snapshot !== null) {
1091
+ try {
1092
+ snapshot = JSON.parse(row.snapshot);
1093
+ } catch {
1094
+ snapshot = null;
1095
+ }
1096
+ }
1097
+ return {
1098
+ name: row.name,
1099
+ target: row.target,
1100
+ host_id: row.host_id,
1101
+ display_name: row.display_name,
1102
+ snapshot,
1103
+ snapshot_at: row.snapshot_at,
1104
+ fetched_at: row.fetched_at,
1105
+ last_attempt_at: row.last_attempt_at,
1106
+ last_error: row.last_error,
1107
+ murmur_version: row.murmur_version,
1108
+ snapshot_version: row.snapshot_version
1109
+ };
1110
+ }
816
1111
  return {
817
- append,
818
- ingest,
819
- eventsSince(hostId, seq) {
820
- const rows = database.prepare("SELECT * FROM events WHERE host_id = ? AND seq > ? ORDER BY seq").all(hostId, seq);
821
- return rows.map(toEvent);
822
- },
823
- allEvents() {
824
- const rows = database.prepare("SELECT * FROM events ORDER BY ts, host_id, seq").all();
825
- return rows.map(toEvent);
1112
+ claimAgent,
1113
+ reconcileLocal,
1114
+ setActivity(update) {
1115
+ return updateActivity.run({
1116
+ activity: update.activity,
1117
+ session: update.location.session,
1118
+ window: update.location.window,
1119
+ session_name: update.location.session_name,
1120
+ window_name: update.location.window_name,
1121
+ updated_at: update.now ?? Date.now(),
1122
+ agent_id: update.agent_id,
1123
+ owner_pid: update.owner_pid
1124
+ }).changes === 1;
826
1125
  },
827
- maxSeq(hostId) {
828
- return selectMaxSeq.get(hostId).seq;
1126
+ releaseAgent(release) {
1127
+ return deleteAgentOwned.run(release.agent_id, release.owner_pid).changes === 1;
829
1128
  },
830
- prune(horizonMs = Number(process.env.MURMUR_RETENTION_MS ?? DEFAULT_RETENTION_MS)) {
831
- return database.prepare(`
832
- DELETE FROM events
833
- WHERE ts < ?
834
- AND (host_id, seq) NOT IN (
835
- SELECT host_id, seq FROM (
836
- SELECT host_id, seq,
837
- ROW_NUMBER() OVER (PARTITION BY agent_id ORDER BY ts DESC, seq DESC) rn
838
- FROM events
839
- ) WHERE rn = 1
840
- )
841
- `).run(Date.now() - horizonMs).changes;
1129
+ requestAttention(request) {
1130
+ upsertAttention.run({
1131
+ pane: request.location.pane,
1132
+ kind: request.kind,
1133
+ message: request.message,
1134
+ source: request.source,
1135
+ session: request.location.session,
1136
+ window: request.location.window,
1137
+ session_name: request.location.session_name,
1138
+ window_name: request.location.window_name,
1139
+ requested_at: request.now ?? Date.now()
1140
+ });
842
1141
  },
843
- peers() {
844
- return database.prepare("SELECT * FROM peers ORDER BY name").all();
1142
+ acknowledgePane(pane) {
1143
+ return deleteAttentionForPane.run(pane).changes;
845
1144
  },
846
- forgetAgent(agentId) {
847
- return database.prepare("DELETE FROM events WHERE agent_id = ?").run(agentId).changes;
1145
+ localPanes() {
1146
+ return readLocalPanes();
848
1147
  },
849
- forgetHost(hostId) {
850
- return database.prepare("DELETE FROM events WHERE host_id = ?").run(hostId).changes;
1148
+ buildLocalSnapshot(identity, world) {
1149
+ reconcileLocal(world);
1150
+ return {
1151
+ murmur_snapshot: 1,
1152
+ host_id: identity.host_id,
1153
+ display_name: identity.display_name,
1154
+ murmur_version: MURMUR_VERSION,
1155
+ generated_at: world.now ?? Date.now(),
1156
+ // Rule 3: a pane with no agent and no attention must not be published.
1157
+ // A no-op against today's `readLocalPanes`, which builds a pane entry
1158
+ // only from a row and so cannot produce an empty one -- kept because the
1159
+ // rule belongs to the DOCUMENT, and the validator rejects such an entry
1160
+ // outright. Without it, one narrowing of the local read would make this
1161
+ // node reachable-but-broken on every peer that collects it, and the
1162
+ // symptom would show up on the other machines.
1163
+ panes: readLocalPanes().filter((pane) => pane.agent !== null || pane.attention.length > 0)
1164
+ };
851
1165
  },
852
- upsertPeer(peer) {
853
- const current = database.prepare("SELECT * FROM peers WHERE name = ?").get(peer.name);
854
- database.prepare(`
855
- INSERT INTO peers (name, target, host_id, display_name, watermark, fetched_at, tmux_down_at)
856
- VALUES (?, ?, ?, ?, ?, ?, ?)
857
- ON CONFLICT(name) DO UPDATE SET
858
- target = excluded.target,
859
- host_id = excluded.host_id,
860
- display_name = excluded.display_name,
861
- watermark = excluded.watermark,
862
- fetched_at = excluded.fetched_at,
863
- tmux_down_at = excluded.tmux_down_at
864
- `).run(
865
- peer.name,
866
- peer.target,
867
- peer.host_id !== void 0 ? peer.host_id : current?.host_id ?? null,
868
- peer.display_name !== void 0 ? peer.display_name : current?.display_name ?? null,
869
- peer.watermark !== void 0 ? peer.watermark : current?.watermark ?? 0,
870
- peer.fetched_at !== void 0 ? peer.fetched_at : current?.fetched_at ?? null,
871
- peer.tmux_down_at !== void 0 ? peer.tmux_down_at : current?.tmux_down_at ?? null
1166
+ peers() {
1167
+ return database.prepare("SELECT * FROM peers ORDER BY name").all().map(
1168
+ peerRecord
872
1169
  );
873
1170
  },
1171
+ addPeer(name, target) {
1172
+ database.prepare(
1173
+ `INSERT INTO peers (name, target) VALUES (?, ?)
1174
+ ON CONFLICT(name) DO UPDATE SET target = excluded.target`
1175
+ ).run(name, target);
1176
+ },
874
1177
  removePeer(name) {
875
1178
  return database.prepare("DELETE FROM peers WHERE name = ?").run(name).changes > 0;
876
1179
  },
1180
+ replacePeerSnapshot(name, fetch) {
1181
+ if (!fetch.ok) {
1182
+ database.prepare("UPDATE peers SET last_attempt_at = ?, last_error = ? WHERE name = ?").run(fetch.at, fetch.error, name);
1183
+ return;
1184
+ }
1185
+ database.prepare(
1186
+ `UPDATE peers
1187
+ SET snapshot = ?, snapshot_at = ?, fetched_at = ?, last_attempt_at = ?,
1188
+ last_error = NULL, host_id = ?, display_name = ?,
1189
+ murmur_version = ?, snapshot_version = ?
1190
+ WHERE name = ?`
1191
+ ).run(
1192
+ JSON.stringify(fetch.snapshot),
1193
+ fetch.snapshot.generated_at,
1194
+ fetch.at,
1195
+ fetch.at,
1196
+ fetch.snapshot.host_id,
1197
+ fetch.snapshot.display_name,
1198
+ fetch.snapshot.murmur_version,
1199
+ fetch.snapshot.murmur_snapshot,
1200
+ name
1201
+ );
1202
+ },
877
1203
  close() {
878
1204
  database.close();
879
1205
  }
880
1206
  };
881
1207
  }
882
-
883
- // src/index.ts
884
- var manifest = createRequire(import.meta.url)("../package.json");
885
- var VERSION = manifest.version;
886
1208
  export {
887
- COLLECT_INTERVAL_MS,
888
1209
  DEFAULT_DRIVER,
889
- SCHEMA_VERSION,
1210
+ MAX_CONCURRENT_PEERS,
1211
+ NEEDS_HUMAN,
1212
+ RENDER_PRIORITY,
890
1213
  STALENESS_MS,
891
- STORE_VERSION,
892
- VERSION,
1214
+ SnapshotInvalidError,
1215
+ MURMUR_VERSION as VERSION,
1216
+ age,
893
1217
  agentLabel,
894
1218
  agentLocation,
895
- attentionSort,
1219
+ asPaneId,
1220
+ asSessionId,
1221
+ asWindowId,
896
1222
  collect,
897
1223
  configDir,
1224
+ createIdentity,
898
1225
  dbPath,
899
- ensureIdentity,
900
- eventFromWire,
901
- exportJsonl,
902
- foldAgent,
903
- foldAll,
1226
+ freshness,
904
1227
  glance,
905
1228
  hasWarmSocket,
906
- isStale,
907
1229
  jumpToAgent,
908
1230
  loadIdentity,
909
1231
  openStore,
1232
+ paneViews,
1233
+ parseSnapshot,
910
1234
  pidAlive,
1235
+ renderState,
1236
+ setDisplayName,
911
1237
  shellQuote,
912
1238
  ssh,
913
1239
  stateDir,
914
1240
  status,
915
- tmux
1241
+ statusWithCollect,
1242
+ tmux,
1243
+ tmuxStatus,
1244
+ viewSort
916
1245
  };
917
1246
  //# sourceMappingURL=index.js.map