@martintrojer/murmur 0.1.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 ADDED
@@ -0,0 +1,894 @@
1
+ // src/agents.ts
2
+ import { spawnSync } from "child_process";
3
+
4
+ // src/identity.ts
5
+ import { randomUUID } from "crypto";
6
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
7
+ import { hostname } from "os";
8
+ import { join as join2 } from "path";
9
+
10
+ // src/paths.ts
11
+ import { homedir } from "os";
12
+ import { join } from "path";
13
+ function stateDir() {
14
+ return process.env.MURMUR_STATE_DIR ?? join(process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state"), "murmur");
15
+ }
16
+ function configDir() {
17
+ return process.env.MURMUR_CONFIG_DIR ?? join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "murmur");
18
+ }
19
+ function dbPath() {
20
+ return join(stateDir(), "events.db");
21
+ }
22
+
23
+ // src/identity.ts
24
+ function loadIdentity() {
25
+ const path = join2(stateDir(), "identity.json");
26
+ return existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) : null;
27
+ }
28
+ function ensureIdentity(displayName = hostname()) {
29
+ const existing = loadIdentity();
30
+ if (existing) return existing;
31
+ const identity = { host_id: randomUUID(), display_name: displayName };
32
+ mkdirSync(stateDir(), { recursive: true });
33
+ writeFileSync(join2(stateDir(), "identity.json"), `${JSON.stringify(identity, null, 2)}
34
+ `);
35
+ return identity;
36
+ }
37
+
38
+ // src/mux.ts
39
+ import { execFileSync } from "child_process";
40
+ function runTmux(args) {
41
+ try {
42
+ return execFileSync("tmux", args, {
43
+ encoding: "utf8",
44
+ timeout: 3e3,
45
+ stdio: ["ignore", "pipe", "ignore"]
46
+ }).trim();
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+ var tmux = {
52
+ currentWindow() {
53
+ const pane = process.env.TMUX_PANE ?? runTmux(["display-message", "-p", "#{pane_id}"]);
54
+ if (!pane) return null;
55
+ const fields = runTmux([
56
+ "display-message",
57
+ "-t",
58
+ pane,
59
+ "-p",
60
+ "#{session_id} #{window_id} #{session_name} #{window_name}"
61
+ ]);
62
+ const [session, window, sessionName, windowName] = fields?.split(" ") ?? [];
63
+ if (!session || !window) return null;
64
+ return {
65
+ session,
66
+ window,
67
+ pane,
68
+ session_name: sessionName || null,
69
+ window_name: windowName || null
70
+ };
71
+ },
72
+ // Which of this host's windows still exist. Only the authoring node can
73
+ // answer this, which is why the check runs on export rather than on the
74
+ // reader: a peer holding a `blocked` row for a window that died has nothing
75
+ // to supersede it, and the agent stays in every HUD forever.
76
+ //
77
+ // null means "could not tell" (no tmux server, tmux missing) and is
78
+ // deliberately distinct from an empty set, which means "tmux answered, and
79
+ // there are no windows". Treating the first as the second would clear every
80
+ // agent on the host the moment tmux was unreachable.
81
+ liveWindows() {
82
+ const out = runTmux(["list-windows", "-a", "-F", "#{window_id}"]);
83
+ if (out === null) return null;
84
+ return new Set(out.split("\n").filter(Boolean));
85
+ },
86
+ setState(window, state) {
87
+ if (state === null) {
88
+ runTmux(["set-window-option", "-qu", "-t", window, "@agent_state"]);
89
+ } else {
90
+ runTmux(["set-window-option", "-q", "-t", window, "@agent_state", state]);
91
+ runTmux(["set-window-option", "-q", "-t", window, "@pane_agent", "1"]);
92
+ }
93
+ runTmux(["refresh-client", "-S"]);
94
+ },
95
+ attach(session, window) {
96
+ runTmux(["switch-client", "-t", session]);
97
+ runTmux(["select-window", "-t", window]);
98
+ },
99
+ // Window ids are what the log stores, because they are stable; names are
100
+ // what a human recognises in a picker. Names are live tmux state, not
101
+ // history, so they are resolved at render time rather than recorded.
102
+ windowNames() {
103
+ const out = runTmux(["list-windows", "-a", "-F", "#{window_id} #{window_name}"]);
104
+ const names = /* @__PURE__ */ new Map();
105
+ for (const line of out?.split("\n") ?? []) {
106
+ const [id, name] = line.split(" ");
107
+ if (id && name) names.set(id, name);
108
+ }
109
+ return names;
110
+ },
111
+ // First window carrying this exact name, or null. Used to reuse a per-host
112
+ // ssh window instead of opening another one.
113
+ windowNamed(name) {
114
+ const out = runTmux(["list-windows", "-a", "-F", "#{window_id} #{window_name}"]);
115
+ for (const line of out?.split("\n") ?? []) {
116
+ const [id, windowName] = line.split(" ");
117
+ if (id && windowName === name) return id;
118
+ }
119
+ return null;
120
+ },
121
+ selectWindow(window) {
122
+ runTmux(["select-window", "-t", window]);
123
+ },
124
+ // The window a pane belongs to, for a pane murmur has no event for. Clearing
125
+ // a badge is a tmux operation and does not require murmur to own the pane.
126
+ windowForPane(pane) {
127
+ return runTmux(["display-message", "-t", pane, "-p", "#{window_id}"]) || null;
128
+ },
129
+ capture(pane, lines) {
130
+ const args = ["capture-pane", "-p", "-t", pane];
131
+ if (lines !== void 0) args.push("-S", `-${lines}`);
132
+ return runTmux(args);
133
+ }
134
+ };
135
+ function pidAlive(pid) {
136
+ try {
137
+ process.kill(pid, 0);
138
+ return true;
139
+ } catch (error) {
140
+ return error.code !== "ESRCH";
141
+ }
142
+ }
143
+
144
+ // src/agents.ts
145
+ function agentLabel(agent) {
146
+ const name = agent.agent_name ?? agent.pi_session ?? agent.window_name ?? agent.session_name;
147
+ return terminalText(name ?? agent.window);
148
+ }
149
+ function agentLocation(agent) {
150
+ const session = agent.session_name ?? agent.session;
151
+ const window = agent.window_name ?? agent.window;
152
+ return terminalText(session === window ? session : `${session}:${window}`);
153
+ }
154
+ function terminalText(value) {
155
+ return [...value].map((character) => {
156
+ const code = character.charCodeAt(0);
157
+ return code < 32 || code === 127 || code >= 128 && code <= 159 ? "\uFFFD" : character;
158
+ }).join("");
159
+ }
160
+ function shellQuote(value) {
161
+ return `'${value.replaceAll("'", `'\\''`)}'`;
162
+ }
163
+ function forgetHostReplica(store, hostId) {
164
+ try {
165
+ const peer = store.peers().find((candidate) => candidate.host_id === hostId);
166
+ store.forgetHost(hostId);
167
+ if (peer) {
168
+ store.upsertPeer({
169
+ name: peer.name,
170
+ target: peer.target,
171
+ tmux_down_at: Date.now()
172
+ });
173
+ }
174
+ } catch {
175
+ }
176
+ }
177
+ function forgetReplica(store, agentId, hostId) {
178
+ try {
179
+ store.forgetAgent(agentId);
180
+ const peer = store.peers().find((candidate) => candidate.host_id === hostId);
181
+ if (peer) store.upsertPeer({ name: peer.name, target: peer.target, watermark: 0 });
182
+ } catch {
183
+ }
184
+ }
185
+ function jumpToAgent(store, agent) {
186
+ const identity = loadIdentity();
187
+ if (agent.host_id === identity?.host_id) {
188
+ const live = tmux.liveWindows();
189
+ if (live && !live.has(agent.window)) {
190
+ forgetReplica(store, agent.agent_id, agent.host_id);
191
+ return {
192
+ ok: false,
193
+ reason: "window_gone",
194
+ message: `${agentLabel(agent)} is gone -- its window no longer exists. Cleared.`
195
+ };
196
+ }
197
+ tmux.attach(agent.session, agent.window);
198
+ return { ok: true };
199
+ }
200
+ const peer = store.peers().find((candidate) => candidate.host_id === agent.host_id);
201
+ const target = peer?.target ?? peer?.name;
202
+ if (!target) {
203
+ return {
204
+ ok: false,
205
+ reason: "no_peer",
206
+ message: `No peer configured for host ${agent.host_id.slice(0, 8)}. Try: murmur peer add <target>`
207
+ };
208
+ }
209
+ const probe = spawnSync(
210
+ "ssh",
211
+ ["-o", "BatchMode=yes", target, `tmux list-windows -a -F ${shellQuote("#{window_id}")}`],
212
+ { encoding: "utf8", timeout: 1e4 }
213
+ );
214
+ if (probe.status !== 0) {
215
+ const sshFailed = probe.status === 255 || probe.error !== void 0;
216
+ if (sshFailed) {
217
+ return {
218
+ ok: false,
219
+ reason: "unreachable",
220
+ message: `cannot reach ${target} over ssh. The collector never prompts for auth, so connect once by hand to warm the connection, then retry.`
221
+ };
222
+ }
223
+ forgetHostReplica(store, agent.host_id);
224
+ return {
225
+ ok: false,
226
+ reason: "no_tmux",
227
+ message: `${target} has no tmux server running, so its agents are gone. Removed them; they will come back when it reports again.`
228
+ };
229
+ }
230
+ const remoteWindows = new Set((probe.stdout ?? "").split("\n").filter(Boolean));
231
+ if (!remoteWindows.has(agent.window)) {
232
+ forgetReplica(store, agent.agent_id, agent.host_id);
233
+ return {
234
+ ok: false,
235
+ reason: "window_gone",
236
+ message: `${agentLabel(agent)} is gone -- ${target} no longer has that window. Cleared.`
237
+ };
238
+ }
239
+ const attachTarget = shellQuote(`${agent.session}:${agent.window}`);
240
+ if (process.env.TMUX) {
241
+ const command = `ssh -t ${shellQuote(target)} tmux attach -t ${shellQuote(attachTarget)}`;
242
+ const name = `@${peer?.display_name ?? target}`;
243
+ const existing = tmux.windowNamed(name);
244
+ if (existing) {
245
+ tmux.selectWindow(existing);
246
+ return { ok: true };
247
+ }
248
+ spawnSync("tmux", ["new-window", "-n", name, command], { stdio: "ignore" });
249
+ return { ok: true };
250
+ }
251
+ spawnSync("ssh", ["-t", target, "tmux", "attach", "-t", attachTarget], { stdio: "inherit" });
252
+ return { ok: true };
253
+ }
254
+
255
+ // src/channel.ts
256
+ import { execFile, execFileSync as execFileSync2 } from "child_process";
257
+ import { promisify } from "util";
258
+ var execFileAsync = promisify(execFile);
259
+ var CONTROL_PATH = "~/.ssh/control/%r@%h:%p";
260
+ var CONNECT_TIMEOUT_S = 2;
261
+ var EXEC_TIMEOUT_MS = 1e4;
262
+ var SSH_OPTIONS = [
263
+ "-o",
264
+ "BatchMode=yes",
265
+ "-o",
266
+ "ControlMaster=no",
267
+ "-o",
268
+ `ControlPath=${CONTROL_PATH}`,
269
+ "-o",
270
+ `ConnectTimeout=${CONNECT_TIMEOUT_S}`
271
+ ];
272
+ var ssh = {
273
+ async exec(target, argv) {
274
+ const { stdout } = await execFileAsync("ssh", [...SSH_OPTIONS, target, ...argv], {
275
+ encoding: "utf8",
276
+ timeout: EXEC_TIMEOUT_MS
277
+ });
278
+ return stdout;
279
+ }
280
+ };
281
+ function hasWarmSocket(target) {
282
+ try {
283
+ execFileSync2("ssh", [...SSH_OPTIONS, "-O", "check", target], { stdio: "ignore" });
284
+ return true;
285
+ } catch {
286
+ return false;
287
+ }
288
+ }
289
+
290
+ // src/types.ts
291
+ var DEFAULT_DRIVER = "human";
292
+
293
+ // src/fold.ts
294
+ function foldAgent(events, isAlive) {
295
+ for (let index = events.length - 1; index >= 0; index -= 1) {
296
+ const event = events[index];
297
+ if (!event) continue;
298
+ switch (event.state) {
299
+ case "blocked":
300
+ case "done":
301
+ case "crashed":
302
+ return { state: event.state, event };
303
+ case "cleared":
304
+ return { state: null, event: null };
305
+ case "working":
306
+ return {
307
+ state: event.pid !== null && event.pid > 0 && isAlive(event.pid) ? "working" : "crashed",
308
+ event
309
+ };
310
+ }
311
+ }
312
+ return { state: null, event: null };
313
+ }
314
+ function foldAll(events, isAlive) {
315
+ const byAgent = /* @__PURE__ */ new Map();
316
+ for (const event of events) {
317
+ const agentEvents = byAgent.get(event.agent_id);
318
+ if (agentEvents) agentEvents.push(event);
319
+ else byAgent.set(event.agent_id, [event]);
320
+ }
321
+ return [...byAgent.values()].map((agentEvents) => {
322
+ const folded = foldAgent(agentEvents, isAlive);
323
+ const source = folded.event ?? agentEvents[agentEvents.length - 1];
324
+ if (!source) throw new Error("agent event group cannot be empty");
325
+ return {
326
+ agent_id: source.agent_id,
327
+ host_id: source.host_id,
328
+ state: folded.state,
329
+ event: folded.event,
330
+ workstream: source.workstream,
331
+ role: source.role,
332
+ cli: source.cli,
333
+ driver: source.driver ?? DEFAULT_DRIVER,
334
+ session: source.session,
335
+ window: source.window,
336
+ pane: source.pane,
337
+ session_name: source.session_name,
338
+ window_name: source.window_name,
339
+ agent_name: source.agent_name,
340
+ pi_session: source.pi_session,
341
+ fetched_at: null
342
+ };
343
+ });
344
+ }
345
+ var ATTENTION_ORDER = {
346
+ blocked: 0,
347
+ done: 1,
348
+ crashed: 2,
349
+ working: 3,
350
+ cleared: 4
351
+ };
352
+ function attentionSort(views) {
353
+ return [...views].sort((left, right) => {
354
+ const stateOrder = (left.state === null ? 4 : ATTENTION_ORDER[left.state]) - (right.state === null ? 4 : ATTENTION_ORDER[right.state]);
355
+ if (stateOrder !== 0) return stateOrder;
356
+ return (right.event?.ts ?? 0) - (left.event?.ts ?? 0);
357
+ });
358
+ }
359
+ function isStale(fetchedAt, now, thresholdMs = 6e4) {
360
+ return fetchedAt !== null && now - fetchedAt > thresholdMs;
361
+ }
362
+
363
+ // src/export.ts
364
+ var SCHEMA_VERSION = 2;
365
+ var EVENT_FIELDS = /* @__PURE__ */ new Set([
366
+ "host_id",
367
+ "seq",
368
+ "ts",
369
+ "agent_id",
370
+ "session",
371
+ "window",
372
+ "pane",
373
+ "session_name",
374
+ "window_name",
375
+ "agent_name",
376
+ "pi_session",
377
+ "workstream",
378
+ "role",
379
+ "cli",
380
+ "driver",
381
+ "kind",
382
+ "state",
383
+ "message",
384
+ "pid",
385
+ "synthetic",
386
+ "reason"
387
+ ]);
388
+ function eventToWire(event) {
389
+ const { extra, ...known } = event;
390
+ return { ...extra, ...known };
391
+ }
392
+ function eventFromWire(wire) {
393
+ const extra = Object.fromEntries(Object.entries(wire).filter(([key]) => !EVENT_FIELDS.has(key)));
394
+ return {
395
+ host_id: wire.host_id,
396
+ seq: wire.seq,
397
+ ts: wire.ts,
398
+ agent_id: wire.agent_id,
399
+ session: wire.session,
400
+ window: wire.window,
401
+ pane: wire.pane,
402
+ session_name: wire.session_name ?? null,
403
+ window_name: wire.window_name ?? null,
404
+ agent_name: wire.agent_name ?? null,
405
+ pi_session: wire.pi_session ?? null,
406
+ workstream: wire.workstream ?? null,
407
+ role: wire.role ?? null,
408
+ cli: wire.cli ?? null,
409
+ driver: wire.driver ?? null,
410
+ kind: wire.kind,
411
+ state: wire.state,
412
+ message: wire.message,
413
+ pid: wire.pid ?? null,
414
+ synthetic: wire.synthetic,
415
+ reason: wire.reason,
416
+ extra
417
+ };
418
+ }
419
+ function synthesizeCrashes(store, hostId, isAlive) {
420
+ const byAgent = /* @__PURE__ */ new Map();
421
+ for (const event of store.allEvents()) {
422
+ if (event.host_id !== hostId) continue;
423
+ const events = byAgent.get(event.agent_id);
424
+ if (events) events.push(event);
425
+ else byAgent.set(event.agent_id, [event]);
426
+ }
427
+ for (const events of byAgent.values()) {
428
+ events.sort((left, right) => left.seq - right.seq);
429
+ const newest = events.at(-1);
430
+ if (newest && newest.state === "working" && !newest.synthetic && foldAgent(events, isAlive).state === "crashed") {
431
+ const { host_id: _hostId, seq: _seq, ts: _ts, ...event } = newest;
432
+ store.append({ ...event, state: "crashed", synthetic: true, reason: "pid_gone" });
433
+ }
434
+ }
435
+ }
436
+ function clearDeadWindows(store, hostId, live) {
437
+ if (live === null) return;
438
+ const newest = /* @__PURE__ */ new Map();
439
+ for (const event of store.allEvents()) {
440
+ if (event.host_id !== hostId) continue;
441
+ const previous = newest.get(event.agent_id);
442
+ if (!previous || event.seq > previous.seq) newest.set(event.agent_id, event);
443
+ }
444
+ for (const event of newest.values()) {
445
+ if (event.state === "cleared") continue;
446
+ if (live.has(event.window)) continue;
447
+ const { host_id: _hostId, seq: _seq, ts: _ts, ...rest } = event;
448
+ store.append({
449
+ ...rest,
450
+ state: "cleared",
451
+ synthetic: true,
452
+ reason: "window_gone",
453
+ message: ""
454
+ });
455
+ }
456
+ }
457
+ function exportJsonl(store, since, isAlive, live) {
458
+ const identity = ensureIdentity();
459
+ synthesizeCrashes(store, identity.host_id, isAlive);
460
+ if (live !== void 0) clearDeadWindows(store, identity.host_id, live);
461
+ const envelope = {
462
+ schema_version: SCHEMA_VERSION,
463
+ host_id: identity.host_id,
464
+ display_name: identity.display_name,
465
+ exported_at: Date.now()
466
+ };
467
+ const lines = [
468
+ JSON.stringify(envelope),
469
+ ...store.eventsSince(identity.host_id, since).map((event) => JSON.stringify(eventToWire(event)))
470
+ ];
471
+ return `${lines.join("\n")}
472
+ `;
473
+ }
474
+
475
+ // src/collector.ts
476
+ var COLLECT_INTERVAL_MS = 3e4;
477
+ var STALENESS_MS = 2 * COLLECT_INTERVAL_MS;
478
+ function parseJsonl(output) {
479
+ const lines = output.trim().split("\n");
480
+ const envelope = JSON.parse(lines.shift() ?? "");
481
+ if (envelope.schema_version > SCHEMA_VERSION) {
482
+ throw new Error(
483
+ `unsupported schema version ${envelope.schema_version} (supports ${SCHEMA_VERSION})`
484
+ );
485
+ }
486
+ return {
487
+ envelope,
488
+ events: lines.map((line) => eventFromWire(JSON.parse(line)))
489
+ };
490
+ }
491
+ async function collect(store, channel, now = Date.now()) {
492
+ const results = [];
493
+ try {
494
+ for (const peer of store.peers()) {
495
+ try {
496
+ const output = await channel.exec(peer.target, [
497
+ "murmur",
498
+ "export",
499
+ "--since",
500
+ String(peer.watermark)
501
+ ]);
502
+ const { envelope, events } = parseJsonl(output);
503
+ const ingested = store.ingest(events);
504
+ const watermark = events.filter((event) => event.host_id === envelope.host_id).reduce((highest, event) => Math.max(highest, event.seq), peer.watermark);
505
+ store.upsertPeer({
506
+ name: peer.name,
507
+ target: peer.target,
508
+ host_id: envelope.host_id,
509
+ display_name: envelope.display_name,
510
+ watermark,
511
+ fetched_at: now,
512
+ // New events mean the node is authoring again, so whatever a jump
513
+ // observed about its tmux is out of date. Only clear on actual new
514
+ // events: an export that returns nothing proves the binary ran, not
515
+ // that tmux is back, which is the distinction that let a dead host
516
+ // look healthy for three hours.
517
+ tmux_down_at: ingested > 0 ? null : peer.tmux_down_at
518
+ });
519
+ store.prune();
520
+ results.push({ peer: peer.name, ok: true, ingested });
521
+ } catch (error) {
522
+ const message = error instanceof Error ? error.message : String(error);
523
+ process.stderr.write(`murmur: collect: peer ${peer.name}: ${message}
524
+ `);
525
+ results.push({ peer: peer.name, ok: false, ingested: 0, error: message });
526
+ }
527
+ }
528
+ } catch (error) {
529
+ process.stderr.write(
530
+ `murmur: collect: ${error instanceof Error ? error.message : String(error)}
531
+ `
532
+ );
533
+ }
534
+ return results;
535
+ }
536
+
537
+ // src/glance.ts
538
+ import { execFileSync as execFileSync3 } from "child_process";
539
+ var GLANCE_LINES = 40;
540
+ var SSH_OPTIONS2 = [
541
+ "-o",
542
+ "BatchMode=yes",
543
+ "-o",
544
+ "ControlMaster=no",
545
+ "-o",
546
+ "ControlPath=~/.ssh/control/%r@%h:%p",
547
+ "-o",
548
+ "ConnectTimeout=2"
549
+ ];
550
+ function glance(store, agent, lines = GLANCE_LINES) {
551
+ if (agent.host_id === loadIdentity()?.host_id) return tmux.capture(agent.pane, lines);
552
+ const peer = store.peers().find((candidate) => candidate.host_id === agent.host_id);
553
+ const target = peer?.target ?? peer?.name;
554
+ if (!target) return null;
555
+ try {
556
+ return execFileSync3(
557
+ "ssh",
558
+ [
559
+ ...SSH_OPTIONS2,
560
+ target,
561
+ "tmux",
562
+ "capture-pane",
563
+ "-p",
564
+ "-t",
565
+ `'${agent.pane}'`,
566
+ "-S",
567
+ `-${lines}`
568
+ ],
569
+ { encoding: "utf8", timeout: 3e3, stdio: ["ignore", "pipe", "ignore"] }
570
+ );
571
+ } catch {
572
+ return null;
573
+ }
574
+ }
575
+
576
+ // src/status.ts
577
+ function emptyCounts() {
578
+ return { working: 0, blocked: 0, done: 0, crashed: 0, idle: 0 };
579
+ }
580
+ function status(store, now = Date.now()) {
581
+ const identity = loadIdentity();
582
+ const peers = store.peers();
583
+ const peersByHost = new Map(
584
+ peers.flatMap((peer) => peer.host_id === null ? [] : [[peer.host_id, peer]])
585
+ );
586
+ const events = store.allEvents();
587
+ const local = foldAll(
588
+ events.filter((event) => event.host_id === identity?.host_id),
589
+ pidAlive
590
+ );
591
+ const remote = foldAll(
592
+ events.filter((event) => event.host_id !== identity?.host_id),
593
+ () => true
594
+ );
595
+ const counts = emptyCounts();
596
+ const orchestratedCounts = emptyCounts();
597
+ const agents = attentionSort([...local, ...remote]).map((agent) => {
598
+ const peer = peersByHost.get(agent.host_id);
599
+ const fetchedAt = peer?.fetched_at ?? null;
600
+ const state = agent.state === null || agent.state === "cleared" ? "idle" : agent.state;
601
+ const target = agent.driver === "human" ? counts : orchestratedCounts;
602
+ target[state] += 1;
603
+ return {
604
+ ...agent,
605
+ fetched_at: fetchedAt,
606
+ // Replica freshness: how long since we last reached the peer. Local rows
607
+ // have no fetched_at and are never stale.
608
+ stale: isStale(fetchedAt, now, STALENESS_MS),
609
+ age_ms: fetchedAt === null ? null : now - fetchedAt,
610
+ // Information age: how long since the agent itself said anything. This
611
+ // is the number a human means by "how stale is that row". A successful
612
+ // fetch of a three-hour-old event resets age_ms to zero but leaves this
613
+ // at three hours, which is why they cannot be the same field.
614
+ event_age_ms: agent.event === null ? null : Math.max(0, now - agent.event.ts),
615
+ // A jump proved this host's tmux was down and nothing has authored since.
616
+ // Stronger than staleness: the host answers, its agents are just gone.
617
+ tmux_down: peer?.tmux_down_at != null,
618
+ host: peer?.display_name ?? peer?.name ?? (agent.host_id === identity?.host_id ? identity.display_name : agent.host_id)
619
+ };
620
+ });
621
+ return {
622
+ counts,
623
+ orchestrated_counts: orchestratedCounts,
624
+ agents,
625
+ peers: peers.map((peer) => ({
626
+ name: peer.name,
627
+ display_name: peer.display_name,
628
+ fetched_at: peer.fetched_at,
629
+ // A peer we have never reached is stale, not fresh. `isStale` reads a
630
+ // null `fetched_at` as "local, therefore never stale", which is right
631
+ // for an agent row but backwards for a peer: null there means the very
632
+ // first collect has not succeeded yet. Left to `isStale`, an
633
+ // unreachable host you just added would render as up to date.
634
+ stale: peer.fetched_at === null || isStale(peer.fetched_at, now, STALENESS_MS)
635
+ }))
636
+ };
637
+ }
638
+
639
+ // src/store.ts
640
+ import { rmSync } from "fs";
641
+ import Database from "better-sqlite3";
642
+ var DEFAULT_RETENTION_MS = 7 * 864e5;
643
+ var STORE_VERSION = 2;
644
+ function resetIfStale(path) {
645
+ let salvaged = [];
646
+ try {
647
+ const existing = new Database(path, { fileMustExist: true });
648
+ const version = existing.pragma("user_version", { simple: true }) ?? 0;
649
+ if (version === STORE_VERSION) {
650
+ existing.close();
651
+ return salvaged;
652
+ }
653
+ try {
654
+ salvaged = existing.prepare("SELECT name, target, host_id, display_name FROM peers").all();
655
+ } catch {
656
+ }
657
+ existing.close();
658
+ } catch {
659
+ return salvaged;
660
+ }
661
+ for (const suffix of ["", "-wal", "-shm"]) rmSync(`${path}${suffix}`, { force: true });
662
+ return salvaged;
663
+ }
664
+ function eventValues(event) {
665
+ return [
666
+ event.host_id,
667
+ event.seq,
668
+ event.ts,
669
+ event.agent_id,
670
+ event.session,
671
+ event.window,
672
+ event.pane,
673
+ event.session_name,
674
+ event.window_name,
675
+ event.agent_name,
676
+ event.pi_session,
677
+ event.workstream,
678
+ event.role,
679
+ event.cli,
680
+ event.driver,
681
+ event.kind,
682
+ event.state,
683
+ event.message,
684
+ event.pid,
685
+ Number(event.synthetic),
686
+ event.reason,
687
+ JSON.stringify(event.extra)
688
+ ];
689
+ }
690
+ function toEvent(row) {
691
+ return {
692
+ ...row,
693
+ driver: row.driver,
694
+ synthetic: row.synthetic === 1,
695
+ extra: JSON.parse(row.extra)
696
+ };
697
+ }
698
+ function openStore() {
699
+ const identity = ensureIdentity();
700
+ const path = dbPath();
701
+ const salvagedPeers = resetIfStale(path);
702
+ const database = new Database(path);
703
+ database.pragma("journal_mode = WAL");
704
+ database.pragma(`user_version = ${STORE_VERSION}`);
705
+ database.exec(`
706
+ CREATE TABLE IF NOT EXISTS events (
707
+ host_id TEXT NOT NULL,
708
+ seq INTEGER NOT NULL,
709
+ ts INTEGER NOT NULL,
710
+ agent_id TEXT NOT NULL,
711
+ session TEXT NOT NULL,
712
+ window TEXT NOT NULL,
713
+ pane TEXT NOT NULL,
714
+ session_name TEXT,
715
+ window_name TEXT,
716
+ agent_name TEXT,
717
+ pi_session TEXT,
718
+ workstream TEXT,
719
+ role TEXT,
720
+ cli TEXT,
721
+ driver TEXT,
722
+ kind TEXT NOT NULL,
723
+ state TEXT NOT NULL,
724
+ message TEXT NOT NULL,
725
+ pid INTEGER,
726
+ synthetic INTEGER NOT NULL,
727
+ reason TEXT NOT NULL,
728
+ extra TEXT NOT NULL,
729
+ PRIMARY KEY (host_id, seq)
730
+ );
731
+ CREATE INDEX IF NOT EXISTS events_agent_seq ON events (agent_id, seq);
732
+ CREATE TABLE IF NOT EXISTS peers (
733
+ name TEXT PRIMARY KEY,
734
+ target TEXT NOT NULL,
735
+ host_id TEXT,
736
+ display_name TEXT,
737
+ watermark INTEGER NOT NULL,
738
+ fetched_at INTEGER,
739
+ -- When a jump last proved this peer's tmux was not answering. Reader
740
+ -- state, not an event: this node cannot author facts about another
741
+ -- node's agents, and a jump is a local observation, not something the
742
+ -- peer said. Cleared by the next successful collect.
743
+ tmux_down_at INTEGER
744
+ );
745
+ `);
746
+ try {
747
+ database.exec("ALTER TABLE peers ADD COLUMN tmux_down_at INTEGER");
748
+ } catch {
749
+ }
750
+ if (salvagedPeers.length > 0) {
751
+ const restore = database.prepare(
752
+ `INSERT OR IGNORE INTO peers (name, target, host_id, display_name, watermark, fetched_at)
753
+ VALUES (?, ?, ?, ?, 0, NULL)`
754
+ );
755
+ for (const peer of salvagedPeers) {
756
+ restore.run(peer.name, peer.target, peer.host_id ?? null, peer.display_name ?? null);
757
+ }
758
+ }
759
+ const eventColumns = `
760
+ host_id, seq, ts, agent_id, session, window, pane,
761
+ session_name, window_name, agent_name, pi_session,
762
+ workstream, role, cli, driver, kind, state, message, pid,
763
+ synthetic, reason, extra`;
764
+ const eventPlaceholders = new Array(22).fill("?").join(", ");
765
+ const insertEvent = database.prepare(
766
+ `INSERT INTO events (${eventColumns}) VALUES (${eventPlaceholders})`
767
+ );
768
+ const ingestEvent = database.prepare(
769
+ `INSERT OR IGNORE INTO events (${eventColumns}) VALUES (${eventPlaceholders})`
770
+ );
771
+ const selectMaxSeq = database.prepare(
772
+ "SELECT COALESCE(MAX(seq), 0) AS seq FROM events WHERE host_id = ?"
773
+ );
774
+ const append = database.transaction((event) => {
775
+ const row = selectMaxSeq.get(identity.host_id);
776
+ const stored = {
777
+ ...event,
778
+ host_id: identity.host_id,
779
+ seq: row.seq + 1,
780
+ ts: event.ts ?? Date.now(),
781
+ session_name: event.session_name ?? null,
782
+ window_name: event.window_name ?? null,
783
+ agent_name: event.agent_name ?? null,
784
+ pi_session: event.pi_session ?? null
785
+ };
786
+ insertEvent.run(...eventValues(stored));
787
+ return stored;
788
+ });
789
+ const ingest = database.transaction((events) => {
790
+ let inserted = 0;
791
+ for (const event of events) inserted += ingestEvent.run(...eventValues(event)).changes;
792
+ return inserted;
793
+ });
794
+ return {
795
+ append,
796
+ ingest,
797
+ eventsSince(hostId, seq) {
798
+ const rows = database.prepare("SELECT * FROM events WHERE host_id = ? AND seq > ? ORDER BY seq").all(hostId, seq);
799
+ return rows.map(toEvent);
800
+ },
801
+ allEvents() {
802
+ const rows = database.prepare("SELECT * FROM events ORDER BY ts, host_id, seq").all();
803
+ return rows.map(toEvent);
804
+ },
805
+ maxSeq(hostId) {
806
+ return selectMaxSeq.get(hostId).seq;
807
+ },
808
+ prune(horizonMs = Number(process.env.MURMUR_RETENTION_MS ?? DEFAULT_RETENTION_MS)) {
809
+ return database.prepare(`
810
+ DELETE FROM events
811
+ WHERE ts < ?
812
+ AND (host_id, seq) NOT IN (
813
+ SELECT host_id, seq FROM (
814
+ SELECT host_id, seq,
815
+ ROW_NUMBER() OVER (PARTITION BY agent_id ORDER BY ts DESC, seq DESC) rn
816
+ FROM events
817
+ ) WHERE rn = 1
818
+ )
819
+ `).run(Date.now() - horizonMs).changes;
820
+ },
821
+ peers() {
822
+ return database.prepare("SELECT * FROM peers ORDER BY name").all();
823
+ },
824
+ forgetAgent(agentId) {
825
+ return database.prepare("DELETE FROM events WHERE agent_id = ?").run(agentId).changes;
826
+ },
827
+ forgetHost(hostId) {
828
+ return database.prepare("DELETE FROM events WHERE host_id = ?").run(hostId).changes;
829
+ },
830
+ upsertPeer(peer) {
831
+ const current = database.prepare("SELECT * FROM peers WHERE name = ?").get(peer.name);
832
+ database.prepare(`
833
+ INSERT INTO peers (name, target, host_id, display_name, watermark, fetched_at, tmux_down_at)
834
+ VALUES (?, ?, ?, ?, ?, ?, ?)
835
+ ON CONFLICT(name) DO UPDATE SET
836
+ target = excluded.target,
837
+ host_id = excluded.host_id,
838
+ display_name = excluded.display_name,
839
+ watermark = excluded.watermark,
840
+ fetched_at = excluded.fetched_at,
841
+ tmux_down_at = excluded.tmux_down_at
842
+ `).run(
843
+ peer.name,
844
+ peer.target,
845
+ peer.host_id !== void 0 ? peer.host_id : current?.host_id ?? null,
846
+ peer.display_name !== void 0 ? peer.display_name : current?.display_name ?? null,
847
+ peer.watermark !== void 0 ? peer.watermark : current?.watermark ?? 0,
848
+ peer.fetched_at !== void 0 ? peer.fetched_at : current?.fetched_at ?? null,
849
+ peer.tmux_down_at !== void 0 ? peer.tmux_down_at : current?.tmux_down_at ?? null
850
+ );
851
+ },
852
+ removePeer(name) {
853
+ return database.prepare("DELETE FROM peers WHERE name = ?").run(name).changes > 0;
854
+ },
855
+ close() {
856
+ database.close();
857
+ }
858
+ };
859
+ }
860
+
861
+ // src/index.ts
862
+ var VERSION = "0.1.0";
863
+ export {
864
+ COLLECT_INTERVAL_MS,
865
+ DEFAULT_DRIVER,
866
+ SCHEMA_VERSION,
867
+ STALENESS_MS,
868
+ STORE_VERSION,
869
+ VERSION,
870
+ agentLabel,
871
+ agentLocation,
872
+ attentionSort,
873
+ collect,
874
+ configDir,
875
+ dbPath,
876
+ ensureIdentity,
877
+ eventFromWire,
878
+ exportJsonl,
879
+ foldAgent,
880
+ foldAll,
881
+ glance,
882
+ hasWarmSocket,
883
+ isStale,
884
+ jumpToAgent,
885
+ loadIdentity,
886
+ openStore,
887
+ pidAlive,
888
+ shellQuote,
889
+ ssh,
890
+ stateDir,
891
+ status,
892
+ tmux
893
+ };
894
+ //# sourceMappingURL=index.js.map