@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/cli.js ADDED
@@ -0,0 +1,1426 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { Command } from "commander";
5
+
6
+ // src/cli/clear.ts
7
+ import Database2 from "better-sqlite3";
8
+
9
+ // src/identity.ts
10
+ import { randomUUID } from "crypto";
11
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
12
+ import { hostname } from "os";
13
+ import { join as join2 } from "path";
14
+
15
+ // src/paths.ts
16
+ import { homedir } from "os";
17
+ import { join } from "path";
18
+ function stateDir() {
19
+ return process.env.MURMUR_STATE_DIR ?? join(process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state"), "murmur");
20
+ }
21
+ function dbPath() {
22
+ return join(stateDir(), "events.db");
23
+ }
24
+
25
+ // src/identity.ts
26
+ function loadIdentity() {
27
+ const path = join2(stateDir(), "identity.json");
28
+ return existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) : null;
29
+ }
30
+ function ensureIdentity(displayName = hostname()) {
31
+ const existing = loadIdentity();
32
+ if (existing) return existing;
33
+ const identity = { host_id: randomUUID(), display_name: displayName };
34
+ mkdirSync(stateDir(), { recursive: true });
35
+ writeFileSync(join2(stateDir(), "identity.json"), `${JSON.stringify(identity, null, 2)}
36
+ `);
37
+ return identity;
38
+ }
39
+
40
+ // src/mux.ts
41
+ import { execFileSync } from "child_process";
42
+ function runTmux(args) {
43
+ try {
44
+ return execFileSync("tmux", args, {
45
+ encoding: "utf8",
46
+ timeout: 3e3,
47
+ stdio: ["ignore", "pipe", "ignore"]
48
+ }).trim();
49
+ } catch {
50
+ return null;
51
+ }
52
+ }
53
+ var tmux = {
54
+ currentWindow() {
55
+ const pane = process.env.TMUX_PANE ?? runTmux(["display-message", "-p", "#{pane_id}"]);
56
+ if (!pane) return null;
57
+ const fields = runTmux([
58
+ "display-message",
59
+ "-t",
60
+ pane,
61
+ "-p",
62
+ "#{session_id} #{window_id} #{session_name} #{window_name}"
63
+ ]);
64
+ const [session, window, sessionName, windowName] = fields?.split(" ") ?? [];
65
+ if (!session || !window) return null;
66
+ return {
67
+ session,
68
+ window,
69
+ pane,
70
+ session_name: sessionName || null,
71
+ window_name: windowName || null
72
+ };
73
+ },
74
+ // Which of this host's windows still exist. Only the authoring node can
75
+ // answer this, which is why the check runs on export rather than on the
76
+ // reader: a peer holding a `blocked` row for a window that died has nothing
77
+ // to supersede it, and the agent stays in every HUD forever.
78
+ //
79
+ // null means "could not tell" (no tmux server, tmux missing) and is
80
+ // deliberately distinct from an empty set, which means "tmux answered, and
81
+ // there are no windows". Treating the first as the second would clear every
82
+ // agent on the host the moment tmux was unreachable.
83
+ liveWindows() {
84
+ const out = runTmux(["list-windows", "-a", "-F", "#{window_id}"]);
85
+ if (out === null) return null;
86
+ return new Set(out.split("\n").filter(Boolean));
87
+ },
88
+ setState(window, state) {
89
+ if (state === null) {
90
+ runTmux(["set-window-option", "-qu", "-t", window, "@agent_state"]);
91
+ } else {
92
+ runTmux(["set-window-option", "-q", "-t", window, "@agent_state", state]);
93
+ runTmux(["set-window-option", "-q", "-t", window, "@pane_agent", "1"]);
94
+ }
95
+ runTmux(["refresh-client", "-S"]);
96
+ },
97
+ attach(session, window) {
98
+ runTmux(["switch-client", "-t", session]);
99
+ runTmux(["select-window", "-t", window]);
100
+ },
101
+ // Window ids are what the log stores, because they are stable; names are
102
+ // what a human recognises in a picker. Names are live tmux state, not
103
+ // history, so they are resolved at render time rather than recorded.
104
+ windowNames() {
105
+ const out = runTmux(["list-windows", "-a", "-F", "#{window_id} #{window_name}"]);
106
+ const names = /* @__PURE__ */ new Map();
107
+ for (const line of out?.split("\n") ?? []) {
108
+ const [id, name] = line.split(" ");
109
+ if (id && name) names.set(id, name);
110
+ }
111
+ return names;
112
+ },
113
+ // First window carrying this exact name, or null. Used to reuse a per-host
114
+ // ssh window instead of opening another one.
115
+ windowNamed(name) {
116
+ const out = runTmux(["list-windows", "-a", "-F", "#{window_id} #{window_name}"]);
117
+ for (const line of out?.split("\n") ?? []) {
118
+ const [id, windowName] = line.split(" ");
119
+ if (id && windowName === name) return id;
120
+ }
121
+ return null;
122
+ },
123
+ selectWindow(window) {
124
+ runTmux(["select-window", "-t", window]);
125
+ },
126
+ // The window a pane belongs to, for a pane murmur has no event for. Clearing
127
+ // a badge is a tmux operation and does not require murmur to own the pane.
128
+ windowForPane(pane) {
129
+ return runTmux(["display-message", "-t", pane, "-p", "#{window_id}"]) || null;
130
+ },
131
+ capture(pane, lines) {
132
+ const args = ["capture-pane", "-p", "-t", pane];
133
+ if (lines !== void 0) args.push("-S", `-${lines}`);
134
+ return runTmux(args);
135
+ }
136
+ };
137
+ function pidAlive(pid) {
138
+ try {
139
+ process.kill(pid, 0);
140
+ return true;
141
+ } catch (error) {
142
+ return error.code !== "ESRCH";
143
+ }
144
+ }
145
+
146
+ // src/store.ts
147
+ import { rmSync } from "fs";
148
+ import Database from "better-sqlite3";
149
+ var DEFAULT_RETENTION_MS = 7 * 864e5;
150
+ var STORE_VERSION = 2;
151
+ function resetIfStale(path) {
152
+ let salvaged = [];
153
+ try {
154
+ const existing = new Database(path, { fileMustExist: true });
155
+ const version = existing.pragma("user_version", { simple: true }) ?? 0;
156
+ if (version === STORE_VERSION) {
157
+ existing.close();
158
+ return salvaged;
159
+ }
160
+ try {
161
+ salvaged = existing.prepare("SELECT name, target, host_id, display_name FROM peers").all();
162
+ } catch {
163
+ }
164
+ existing.close();
165
+ } catch {
166
+ return salvaged;
167
+ }
168
+ for (const suffix of ["", "-wal", "-shm"]) rmSync(`${path}${suffix}`, { force: true });
169
+ return salvaged;
170
+ }
171
+ function eventValues(event) {
172
+ return [
173
+ event.host_id,
174
+ event.seq,
175
+ event.ts,
176
+ event.agent_id,
177
+ event.session,
178
+ event.window,
179
+ event.pane,
180
+ event.session_name,
181
+ event.window_name,
182
+ event.agent_name,
183
+ event.pi_session,
184
+ event.workstream,
185
+ event.role,
186
+ event.cli,
187
+ event.driver,
188
+ event.kind,
189
+ event.state,
190
+ event.message,
191
+ event.pid,
192
+ Number(event.synthetic),
193
+ event.reason,
194
+ JSON.stringify(event.extra)
195
+ ];
196
+ }
197
+ function toEvent(row) {
198
+ return {
199
+ ...row,
200
+ driver: row.driver,
201
+ synthetic: row.synthetic === 1,
202
+ extra: JSON.parse(row.extra)
203
+ };
204
+ }
205
+ function openStore() {
206
+ const identity = ensureIdentity();
207
+ const path = dbPath();
208
+ const salvagedPeers = resetIfStale(path);
209
+ const database = new Database(path);
210
+ database.pragma("journal_mode = WAL");
211
+ database.pragma(`user_version = ${STORE_VERSION}`);
212
+ database.exec(`
213
+ CREATE TABLE IF NOT EXISTS events (
214
+ host_id TEXT NOT NULL,
215
+ seq INTEGER NOT NULL,
216
+ ts INTEGER NOT NULL,
217
+ agent_id TEXT NOT NULL,
218
+ session TEXT NOT NULL,
219
+ window TEXT NOT NULL,
220
+ pane TEXT NOT NULL,
221
+ session_name TEXT,
222
+ window_name TEXT,
223
+ agent_name TEXT,
224
+ pi_session TEXT,
225
+ workstream TEXT,
226
+ role TEXT,
227
+ cli TEXT,
228
+ driver TEXT,
229
+ kind TEXT NOT NULL,
230
+ state TEXT NOT NULL,
231
+ message TEXT NOT NULL,
232
+ pid INTEGER,
233
+ synthetic INTEGER NOT NULL,
234
+ reason TEXT NOT NULL,
235
+ extra TEXT NOT NULL,
236
+ PRIMARY KEY (host_id, seq)
237
+ );
238
+ CREATE INDEX IF NOT EXISTS events_agent_seq ON events (agent_id, seq);
239
+ CREATE TABLE IF NOT EXISTS peers (
240
+ name TEXT PRIMARY KEY,
241
+ target TEXT NOT NULL,
242
+ host_id TEXT,
243
+ display_name TEXT,
244
+ watermark INTEGER NOT NULL,
245
+ fetched_at INTEGER,
246
+ -- When a jump last proved this peer's tmux was not answering. Reader
247
+ -- state, not an event: this node cannot author facts about another
248
+ -- node's agents, and a jump is a local observation, not something the
249
+ -- peer said. Cleared by the next successful collect.
250
+ tmux_down_at INTEGER
251
+ );
252
+ `);
253
+ try {
254
+ database.exec("ALTER TABLE peers ADD COLUMN tmux_down_at INTEGER");
255
+ } catch {
256
+ }
257
+ if (salvagedPeers.length > 0) {
258
+ const restore = database.prepare(
259
+ `INSERT OR IGNORE INTO peers (name, target, host_id, display_name, watermark, fetched_at)
260
+ VALUES (?, ?, ?, ?, 0, NULL)`
261
+ );
262
+ for (const peer of salvagedPeers) {
263
+ restore.run(peer.name, peer.target, peer.host_id ?? null, peer.display_name ?? null);
264
+ }
265
+ }
266
+ const eventColumns = `
267
+ host_id, seq, ts, agent_id, session, window, pane,
268
+ session_name, window_name, agent_name, pi_session,
269
+ workstream, role, cli, driver, kind, state, message, pid,
270
+ synthetic, reason, extra`;
271
+ const eventPlaceholders = new Array(22).fill("?").join(", ");
272
+ const insertEvent = database.prepare(
273
+ `INSERT INTO events (${eventColumns}) VALUES (${eventPlaceholders})`
274
+ );
275
+ const ingestEvent = database.prepare(
276
+ `INSERT OR IGNORE INTO events (${eventColumns}) VALUES (${eventPlaceholders})`
277
+ );
278
+ const selectMaxSeq = database.prepare(
279
+ "SELECT COALESCE(MAX(seq), 0) AS seq FROM events WHERE host_id = ?"
280
+ );
281
+ const append = database.transaction((event) => {
282
+ const row = selectMaxSeq.get(identity.host_id);
283
+ const stored = {
284
+ ...event,
285
+ host_id: identity.host_id,
286
+ seq: row.seq + 1,
287
+ ts: event.ts ?? Date.now(),
288
+ session_name: event.session_name ?? null,
289
+ window_name: event.window_name ?? null,
290
+ agent_name: event.agent_name ?? null,
291
+ pi_session: event.pi_session ?? null
292
+ };
293
+ insertEvent.run(...eventValues(stored));
294
+ return stored;
295
+ });
296
+ const ingest = database.transaction((events) => {
297
+ let inserted = 0;
298
+ for (const event of events) inserted += ingestEvent.run(...eventValues(event)).changes;
299
+ return inserted;
300
+ });
301
+ return {
302
+ append,
303
+ ingest,
304
+ eventsSince(hostId, seq) {
305
+ const rows = database.prepare("SELECT * FROM events WHERE host_id = ? AND seq > ? ORDER BY seq").all(hostId, seq);
306
+ return rows.map(toEvent);
307
+ },
308
+ allEvents() {
309
+ const rows = database.prepare("SELECT * FROM events ORDER BY ts, host_id, seq").all();
310
+ return rows.map(toEvent);
311
+ },
312
+ maxSeq(hostId) {
313
+ return selectMaxSeq.get(hostId).seq;
314
+ },
315
+ prune(horizonMs = Number(process.env.MURMUR_RETENTION_MS ?? DEFAULT_RETENTION_MS)) {
316
+ return database.prepare(`
317
+ DELETE FROM events
318
+ WHERE ts < ?
319
+ AND (host_id, seq) NOT IN (
320
+ SELECT host_id, seq FROM (
321
+ SELECT host_id, seq,
322
+ ROW_NUMBER() OVER (PARTITION BY agent_id ORDER BY ts DESC, seq DESC) rn
323
+ FROM events
324
+ ) WHERE rn = 1
325
+ )
326
+ `).run(Date.now() - horizonMs).changes;
327
+ },
328
+ peers() {
329
+ return database.prepare("SELECT * FROM peers ORDER BY name").all();
330
+ },
331
+ forgetAgent(agentId) {
332
+ return database.prepare("DELETE FROM events WHERE agent_id = ?").run(agentId).changes;
333
+ },
334
+ forgetHost(hostId) {
335
+ return database.prepare("DELETE FROM events WHERE host_id = ?").run(hostId).changes;
336
+ },
337
+ upsertPeer(peer) {
338
+ const current = database.prepare("SELECT * FROM peers WHERE name = ?").get(peer.name);
339
+ database.prepare(`
340
+ INSERT INTO peers (name, target, host_id, display_name, watermark, fetched_at, tmux_down_at)
341
+ VALUES (?, ?, ?, ?, ?, ?, ?)
342
+ ON CONFLICT(name) DO UPDATE SET
343
+ target = excluded.target,
344
+ host_id = excluded.host_id,
345
+ display_name = excluded.display_name,
346
+ watermark = excluded.watermark,
347
+ fetched_at = excluded.fetched_at,
348
+ tmux_down_at = excluded.tmux_down_at
349
+ `).run(
350
+ peer.name,
351
+ peer.target,
352
+ peer.host_id !== void 0 ? peer.host_id : current?.host_id ?? null,
353
+ peer.display_name !== void 0 ? peer.display_name : current?.display_name ?? null,
354
+ peer.watermark !== void 0 ? peer.watermark : current?.watermark ?? 0,
355
+ peer.fetched_at !== void 0 ? peer.fetched_at : current?.fetched_at ?? null,
356
+ peer.tmux_down_at !== void 0 ? peer.tmux_down_at : current?.tmux_down_at ?? null
357
+ );
358
+ },
359
+ removePeer(name) {
360
+ return database.prepare("DELETE FROM peers WHERE name = ?").run(name).changes > 0;
361
+ },
362
+ close() {
363
+ database.close();
364
+ }
365
+ };
366
+ }
367
+
368
+ // src/cli/clear.ts
369
+ function clearPane(pane, mux = tmux) {
370
+ try {
371
+ if (!pane) return;
372
+ const window = mux.windowForPane(pane);
373
+ const identity = loadIdentity();
374
+ let owner;
375
+ if (identity) {
376
+ try {
377
+ const database = new Database2(dbPath(), { readonly: true, fileMustExist: true });
378
+ try {
379
+ owner = database.prepare(
380
+ `SELECT agent_id, session, window, pane, session_name, window_name,
381
+ agent_name, pi_session, workstream, role, cli, driver, state
382
+ FROM events
383
+ WHERE host_id = ? AND agent_id = ?
384
+ ORDER BY seq DESC
385
+ LIMIT 1`
386
+ ).get(identity.host_id, `${identity.host_id}:${pane}`);
387
+ } finally {
388
+ database.close();
389
+ }
390
+ } catch {
391
+ }
392
+ }
393
+ if (!owner) {
394
+ if (window) mux.setState(window, null);
395
+ return;
396
+ }
397
+ if (owner.state === "cleared") return;
398
+ const store = openStore();
399
+ try {
400
+ store.append({
401
+ agent_id: owner.agent_id,
402
+ session: owner.session,
403
+ window: owner.window,
404
+ pane: owner.pane,
405
+ // Carry the names forward: a `cleared` row that drops them makes the
406
+ // agent's last event nameless, which is what left "@75" in the picker.
407
+ session_name: owner.session_name,
408
+ window_name: owner.window_name,
409
+ agent_name: owner.agent_name,
410
+ pi_session: owner.pi_session,
411
+ workstream: owner.workstream,
412
+ role: owner.role,
413
+ cli: owner.cli,
414
+ driver: owner.driver,
415
+ kind: "state",
416
+ state: "cleared",
417
+ message: "",
418
+ pid: null,
419
+ synthetic: false,
420
+ reason: "",
421
+ extra: {}
422
+ });
423
+ } finally {
424
+ store.close();
425
+ }
426
+ mux.setState(owner.window, null);
427
+ } catch {
428
+ }
429
+ }
430
+ function registerClear(program2) {
431
+ program2.command("clear").description("Clear attention for the agent in a pane").option("--pane <pane-id>", "focused tmux pane id").action((options) => clearPane(options.pane ?? ""));
432
+ }
433
+
434
+ // src/channel.ts
435
+ import { execFile, execFileSync as execFileSync2 } from "child_process";
436
+ import { promisify } from "util";
437
+ var execFileAsync = promisify(execFile);
438
+ var CONTROL_PATH = "~/.ssh/control/%r@%h:%p";
439
+ var CONNECT_TIMEOUT_S = 2;
440
+ var EXEC_TIMEOUT_MS = 1e4;
441
+ var SSH_OPTIONS = [
442
+ "-o",
443
+ "BatchMode=yes",
444
+ "-o",
445
+ "ControlMaster=no",
446
+ "-o",
447
+ `ControlPath=${CONTROL_PATH}`,
448
+ "-o",
449
+ `ConnectTimeout=${CONNECT_TIMEOUT_S}`
450
+ ];
451
+ var ssh = {
452
+ async exec(target, argv) {
453
+ const { stdout } = await execFileAsync("ssh", [...SSH_OPTIONS, target, ...argv], {
454
+ encoding: "utf8",
455
+ timeout: EXEC_TIMEOUT_MS
456
+ });
457
+ return stdout;
458
+ }
459
+ };
460
+ function hasWarmSocket(target) {
461
+ try {
462
+ execFileSync2("ssh", [...SSH_OPTIONS, "-O", "check", target], { stdio: "ignore" });
463
+ return true;
464
+ } catch {
465
+ return false;
466
+ }
467
+ }
468
+
469
+ // src/types.ts
470
+ var DEFAULT_DRIVER = "human";
471
+
472
+ // src/fold.ts
473
+ function foldAgent(events, isAlive) {
474
+ for (let index = events.length - 1; index >= 0; index -= 1) {
475
+ const event = events[index];
476
+ if (!event) continue;
477
+ switch (event.state) {
478
+ case "blocked":
479
+ case "done":
480
+ case "crashed":
481
+ return { state: event.state, event };
482
+ case "cleared":
483
+ return { state: null, event: null };
484
+ case "working":
485
+ return {
486
+ state: event.pid !== null && event.pid > 0 && isAlive(event.pid) ? "working" : "crashed",
487
+ event
488
+ };
489
+ }
490
+ }
491
+ return { state: null, event: null };
492
+ }
493
+ function foldAll(events, isAlive) {
494
+ const byAgent = /* @__PURE__ */ new Map();
495
+ for (const event of events) {
496
+ const agentEvents = byAgent.get(event.agent_id);
497
+ if (agentEvents) agentEvents.push(event);
498
+ else byAgent.set(event.agent_id, [event]);
499
+ }
500
+ return [...byAgent.values()].map((agentEvents) => {
501
+ const folded = foldAgent(agentEvents, isAlive);
502
+ const source = folded.event ?? agentEvents[agentEvents.length - 1];
503
+ if (!source) throw new Error("agent event group cannot be empty");
504
+ return {
505
+ agent_id: source.agent_id,
506
+ host_id: source.host_id,
507
+ state: folded.state,
508
+ event: folded.event,
509
+ workstream: source.workstream,
510
+ role: source.role,
511
+ cli: source.cli,
512
+ driver: source.driver ?? DEFAULT_DRIVER,
513
+ session: source.session,
514
+ window: source.window,
515
+ pane: source.pane,
516
+ session_name: source.session_name,
517
+ window_name: source.window_name,
518
+ agent_name: source.agent_name,
519
+ pi_session: source.pi_session,
520
+ fetched_at: null
521
+ };
522
+ });
523
+ }
524
+ var ATTENTION_ORDER = {
525
+ blocked: 0,
526
+ done: 1,
527
+ crashed: 2,
528
+ working: 3,
529
+ cleared: 4
530
+ };
531
+ function attentionSort(views) {
532
+ return [...views].sort((left, right) => {
533
+ const stateOrder = (left.state === null ? 4 : ATTENTION_ORDER[left.state]) - (right.state === null ? 4 : ATTENTION_ORDER[right.state]);
534
+ if (stateOrder !== 0) return stateOrder;
535
+ return (right.event?.ts ?? 0) - (left.event?.ts ?? 0);
536
+ });
537
+ }
538
+ function isStale(fetchedAt, now, thresholdMs = 6e4) {
539
+ return fetchedAt !== null && now - fetchedAt > thresholdMs;
540
+ }
541
+
542
+ // src/export.ts
543
+ var SCHEMA_VERSION = 2;
544
+ var EVENT_FIELDS = /* @__PURE__ */ new Set([
545
+ "host_id",
546
+ "seq",
547
+ "ts",
548
+ "agent_id",
549
+ "session",
550
+ "window",
551
+ "pane",
552
+ "session_name",
553
+ "window_name",
554
+ "agent_name",
555
+ "pi_session",
556
+ "workstream",
557
+ "role",
558
+ "cli",
559
+ "driver",
560
+ "kind",
561
+ "state",
562
+ "message",
563
+ "pid",
564
+ "synthetic",
565
+ "reason"
566
+ ]);
567
+ function eventToWire(event) {
568
+ const { extra, ...known } = event;
569
+ return { ...extra, ...known };
570
+ }
571
+ function eventFromWire(wire) {
572
+ const extra = Object.fromEntries(Object.entries(wire).filter(([key]) => !EVENT_FIELDS.has(key)));
573
+ return {
574
+ host_id: wire.host_id,
575
+ seq: wire.seq,
576
+ ts: wire.ts,
577
+ agent_id: wire.agent_id,
578
+ session: wire.session,
579
+ window: wire.window,
580
+ pane: wire.pane,
581
+ session_name: wire.session_name ?? null,
582
+ window_name: wire.window_name ?? null,
583
+ agent_name: wire.agent_name ?? null,
584
+ pi_session: wire.pi_session ?? null,
585
+ workstream: wire.workstream ?? null,
586
+ role: wire.role ?? null,
587
+ cli: wire.cli ?? null,
588
+ driver: wire.driver ?? null,
589
+ kind: wire.kind,
590
+ state: wire.state,
591
+ message: wire.message,
592
+ pid: wire.pid ?? null,
593
+ synthetic: wire.synthetic,
594
+ reason: wire.reason,
595
+ extra
596
+ };
597
+ }
598
+ function synthesizeCrashes(store, hostId, isAlive) {
599
+ const byAgent = /* @__PURE__ */ new Map();
600
+ for (const event of store.allEvents()) {
601
+ if (event.host_id !== hostId) continue;
602
+ const events = byAgent.get(event.agent_id);
603
+ if (events) events.push(event);
604
+ else byAgent.set(event.agent_id, [event]);
605
+ }
606
+ for (const events of byAgent.values()) {
607
+ events.sort((left, right) => left.seq - right.seq);
608
+ const newest = events.at(-1);
609
+ if (newest && newest.state === "working" && !newest.synthetic && foldAgent(events, isAlive).state === "crashed") {
610
+ const { host_id: _hostId, seq: _seq, ts: _ts, ...event } = newest;
611
+ store.append({ ...event, state: "crashed", synthetic: true, reason: "pid_gone" });
612
+ }
613
+ }
614
+ }
615
+ function clearDeadWindows(store, hostId, live) {
616
+ if (live === null) return;
617
+ const newest = /* @__PURE__ */ new Map();
618
+ for (const event of store.allEvents()) {
619
+ if (event.host_id !== hostId) continue;
620
+ const previous = newest.get(event.agent_id);
621
+ if (!previous || event.seq > previous.seq) newest.set(event.agent_id, event);
622
+ }
623
+ for (const event of newest.values()) {
624
+ if (event.state === "cleared") continue;
625
+ if (live.has(event.window)) continue;
626
+ const { host_id: _hostId, seq: _seq, ts: _ts, ...rest } = event;
627
+ store.append({
628
+ ...rest,
629
+ state: "cleared",
630
+ synthetic: true,
631
+ reason: "window_gone",
632
+ message: ""
633
+ });
634
+ }
635
+ }
636
+ function exportJsonl(store, since, isAlive, live) {
637
+ const identity = ensureIdentity();
638
+ synthesizeCrashes(store, identity.host_id, isAlive);
639
+ if (live !== void 0) clearDeadWindows(store, identity.host_id, live);
640
+ const envelope = {
641
+ schema_version: SCHEMA_VERSION,
642
+ host_id: identity.host_id,
643
+ display_name: identity.display_name,
644
+ exported_at: Date.now()
645
+ };
646
+ const lines = [
647
+ JSON.stringify(envelope),
648
+ ...store.eventsSince(identity.host_id, since).map((event) => JSON.stringify(eventToWire(event)))
649
+ ];
650
+ return `${lines.join("\n")}
651
+ `;
652
+ }
653
+
654
+ // src/collector.ts
655
+ var COLLECT_INTERVAL_MS = 3e4;
656
+ var STALENESS_MS = 2 * COLLECT_INTERVAL_MS;
657
+ function parseJsonl(output) {
658
+ const lines = output.trim().split("\n");
659
+ const envelope = JSON.parse(lines.shift() ?? "");
660
+ if (envelope.schema_version > SCHEMA_VERSION) {
661
+ throw new Error(
662
+ `unsupported schema version ${envelope.schema_version} (supports ${SCHEMA_VERSION})`
663
+ );
664
+ }
665
+ return {
666
+ envelope,
667
+ events: lines.map((line) => eventFromWire(JSON.parse(line)))
668
+ };
669
+ }
670
+ async function collect(store, channel, now = Date.now()) {
671
+ const results = [];
672
+ try {
673
+ for (const peer of store.peers()) {
674
+ try {
675
+ const output = await channel.exec(peer.target, [
676
+ "murmur",
677
+ "export",
678
+ "--since",
679
+ String(peer.watermark)
680
+ ]);
681
+ const { envelope, events } = parseJsonl(output);
682
+ const ingested = store.ingest(events);
683
+ const watermark = events.filter((event) => event.host_id === envelope.host_id).reduce((highest, event) => Math.max(highest, event.seq), peer.watermark);
684
+ store.upsertPeer({
685
+ name: peer.name,
686
+ target: peer.target,
687
+ host_id: envelope.host_id,
688
+ display_name: envelope.display_name,
689
+ watermark,
690
+ fetched_at: now,
691
+ // New events mean the node is authoring again, so whatever a jump
692
+ // observed about its tmux is out of date. Only clear on actual new
693
+ // events: an export that returns nothing proves the binary ran, not
694
+ // that tmux is back, which is the distinction that let a dead host
695
+ // look healthy for three hours.
696
+ tmux_down_at: ingested > 0 ? null : peer.tmux_down_at
697
+ });
698
+ store.prune();
699
+ results.push({ peer: peer.name, ok: true, ingested });
700
+ } catch (error) {
701
+ const message = error instanceof Error ? error.message : String(error);
702
+ process.stderr.write(`murmur: collect: peer ${peer.name}: ${message}
703
+ `);
704
+ results.push({ peer: peer.name, ok: false, ingested: 0, error: message });
705
+ }
706
+ }
707
+ } catch (error) {
708
+ process.stderr.write(
709
+ `murmur: collect: ${error instanceof Error ? error.message : String(error)}
710
+ `
711
+ );
712
+ }
713
+ return results;
714
+ }
715
+
716
+ // src/cli/collect.ts
717
+ function registerCollect(program2) {
718
+ program2.command("collect").description("Collect events from configured peers").action(async () => {
719
+ const store = openStore();
720
+ try {
721
+ await collect(store, ssh);
722
+ } finally {
723
+ store.close();
724
+ }
725
+ });
726
+ }
727
+
728
+ // src/cli/export.ts
729
+ function registerExport(program2) {
730
+ program2.command("export").description("Export local events as JSONL").requiredOption("--since <seq>", "export events after this sequence", Number).action((options) => {
731
+ const store = openStore();
732
+ try {
733
+ process.stdout.write(exportJsonl(store, options.since, pidAlive, tmux.liveWindows()));
734
+ } finally {
735
+ store.close();
736
+ }
737
+ });
738
+ }
739
+
740
+ // src/cli/init.ts
741
+ function registerInit(program2) {
742
+ program2.command("init").description("Initialize this node's identity").option("--name <name>", "display name").action((opts) => {
743
+ const identity = ensureIdentity(opts.name);
744
+ console.log(`host_id: ${identity.host_id}`);
745
+ console.log(`display_name: ${identity.display_name}`);
746
+ });
747
+ }
748
+
749
+ // src/cli/link.ts
750
+ import { mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
751
+ import { homedir as homedir2 } from "os";
752
+ import { dirname, join as join3 } from "path";
753
+ import { fileURLToPath } from "url";
754
+ function registerLink(program2) {
755
+ program2.command("link").description("Install a murmur integration").argument("<target>", "integration to install").action((target) => {
756
+ if (target !== "pi") throw new Error(`unsupported link target: ${target}`);
757
+ const destination = join3(
758
+ process.env.MURMUR_PI_HOME ?? homedir2(),
759
+ ".pi",
760
+ "agent",
761
+ "extensions",
762
+ "murmur.ts"
763
+ );
764
+ mkdirSync2(dirname(destination), { recursive: true });
765
+ const source = readFileSync2(
766
+ fileURLToPath(new URL("./extension/murmur-pi.js", import.meta.url)),
767
+ "utf8"
768
+ );
769
+ const storePath = fileURLToPath(new URL("./extension/store.js", import.meta.url));
770
+ const pinned = source.replace(
771
+ /"@martintrojer\/murmur\/extension-store"/,
772
+ JSON.stringify(storePath)
773
+ );
774
+ if (pinned === source) {
775
+ throw new Error("link pi: could not pin the store import; extension build changed");
776
+ }
777
+ writeFileSync2(destination, pinned);
778
+ console.log(destination);
779
+ });
780
+ }
781
+
782
+ // src/cli/peer.ts
783
+ import { readFileSync as readFileSync3 } from "fs";
784
+ import { homedir as homedir3 } from "os";
785
+ import { join as join4 } from "path";
786
+ function parseSshHosts(config) {
787
+ const hosts = [];
788
+ for (const line of config.split("\n")) {
789
+ const tokens = line.replace(/#.*$/, "").trim().split(/\s+/);
790
+ if (tokens[0]?.toLowerCase() !== "host") continue;
791
+ for (const host of tokens.slice(1)) {
792
+ if (!/[*?!]/.test(host)) hosts.push(host);
793
+ }
794
+ }
795
+ return hosts;
796
+ }
797
+ function sshHosts() {
798
+ try {
799
+ return parseSshHosts(readFileSync3(join4(homedir3(), ".ssh", "config"), "utf8"));
800
+ } catch {
801
+ return [];
802
+ }
803
+ }
804
+ function registerPeer(program2) {
805
+ const peer = program2.command("peer").description("Manage peers");
806
+ peer.command("add").description("Add a peer and discover its identity").argument("<name>").argument("[target]").action(async (name, target = name) => {
807
+ const store = openStore();
808
+ try {
809
+ let envelope = null;
810
+ try {
811
+ const output = await ssh.exec(target, ["murmur", "export", "--since", "0"]);
812
+ envelope = JSON.parse(output.trim().split("\n")[0] ?? "");
813
+ } catch {
814
+ envelope = null;
815
+ }
816
+ if (envelope) {
817
+ if (envelope.host_id === loadIdentity()?.host_id) {
818
+ process.stderr.write(`${target} is this node; not adding it as a peer
819
+ `);
820
+ process.exitCode = 1;
821
+ return;
822
+ }
823
+ const existing = store.peers().find((candidate) => candidate.host_id === envelope.host_id && candidate.name !== name);
824
+ if (existing) {
825
+ process.stderr.write(
826
+ `${target} is already configured as peer "${existing.name}" (${envelope.display_name}); remove it first to rename
827
+ `
828
+ );
829
+ process.exitCode = 1;
830
+ return;
831
+ }
832
+ }
833
+ store.upsertPeer({
834
+ name,
835
+ target,
836
+ host_id: envelope?.host_id ?? null,
837
+ display_name: envelope?.display_name ?? null
838
+ });
839
+ process.stdout.write(
840
+ envelope ? `Added ${name} (${envelope.display_name})
841
+ ` : `Added ${name} (identity pending)
842
+ `
843
+ );
844
+ } finally {
845
+ store.close();
846
+ }
847
+ });
848
+ peer.command("remove").description("Remove a peer").argument("<name>", "peer to remove").action((name) => {
849
+ const store = openStore();
850
+ try {
851
+ if (store.removePeer(name)) process.stdout.write(`Removed ${name}
852
+ `);
853
+ else {
854
+ process.stderr.write(`no such peer: ${name}
855
+ `);
856
+ process.exitCode = 1;
857
+ }
858
+ } finally {
859
+ store.close();
860
+ }
861
+ });
862
+ peer.command("list").description("List configured peers").option("--json", "print JSON").action((options) => {
863
+ const store = openStore();
864
+ try {
865
+ const peers = store.peers();
866
+ if (options.json) process.stdout.write(`${JSON.stringify(peers)}
867
+ `);
868
+ else {
869
+ for (const configured of peers) {
870
+ process.stdout.write(
871
+ `${configured.name} ${configured.target} ${configured.display_name ?? "unknown"}
872
+ `
873
+ );
874
+ }
875
+ }
876
+ } finally {
877
+ store.close();
878
+ }
879
+ });
880
+ peer.command("discover").description("Check SSH hosts for warm control sockets").action(() => {
881
+ for (const host of sshHosts()) {
882
+ process.stdout.write(`${hasWarmSocket(host) ? "[x]" : "[ ]"} ${host}
883
+ `);
884
+ }
885
+ });
886
+ }
887
+
888
+ // src/cli/pick.ts
889
+ import { spawnSync as spawnSync2 } from "child_process";
890
+
891
+ // src/agents.ts
892
+ import { spawnSync } from "child_process";
893
+ function agentLabel(agent) {
894
+ const name = agent.agent_name ?? agent.pi_session ?? agent.window_name ?? agent.session_name;
895
+ return terminalText(name ?? agent.window);
896
+ }
897
+ function agentLocation(agent) {
898
+ const session = agent.session_name ?? agent.session;
899
+ const window = agent.window_name ?? agent.window;
900
+ return terminalText(session === window ? session : `${session}:${window}`);
901
+ }
902
+ function terminalText(value) {
903
+ return [...value].map((character) => {
904
+ const code = character.charCodeAt(0);
905
+ return code < 32 || code === 127 || code >= 128 && code <= 159 ? "\uFFFD" : character;
906
+ }).join("");
907
+ }
908
+ function shellQuote(value) {
909
+ return `'${value.replaceAll("'", `'\\''`)}'`;
910
+ }
911
+ function forgetHostReplica(store, hostId) {
912
+ try {
913
+ const peer = store.peers().find((candidate) => candidate.host_id === hostId);
914
+ store.forgetHost(hostId);
915
+ if (peer) {
916
+ store.upsertPeer({
917
+ name: peer.name,
918
+ target: peer.target,
919
+ tmux_down_at: Date.now()
920
+ });
921
+ }
922
+ } catch {
923
+ }
924
+ }
925
+ function forgetReplica(store, agentId, hostId) {
926
+ try {
927
+ store.forgetAgent(agentId);
928
+ const peer = store.peers().find((candidate) => candidate.host_id === hostId);
929
+ if (peer) store.upsertPeer({ name: peer.name, target: peer.target, watermark: 0 });
930
+ } catch {
931
+ }
932
+ }
933
+ function jumpToAgent(store, agent) {
934
+ const identity = loadIdentity();
935
+ if (agent.host_id === identity?.host_id) {
936
+ const live = tmux.liveWindows();
937
+ if (live && !live.has(agent.window)) {
938
+ forgetReplica(store, agent.agent_id, agent.host_id);
939
+ return {
940
+ ok: false,
941
+ reason: "window_gone",
942
+ message: `${agentLabel(agent)} is gone -- its window no longer exists. Cleared.`
943
+ };
944
+ }
945
+ tmux.attach(agent.session, agent.window);
946
+ return { ok: true };
947
+ }
948
+ const peer = store.peers().find((candidate) => candidate.host_id === agent.host_id);
949
+ const target = peer?.target ?? peer?.name;
950
+ if (!target) {
951
+ return {
952
+ ok: false,
953
+ reason: "no_peer",
954
+ message: `No peer configured for host ${agent.host_id.slice(0, 8)}. Try: murmur peer add <target>`
955
+ };
956
+ }
957
+ const probe = spawnSync(
958
+ "ssh",
959
+ ["-o", "BatchMode=yes", target, `tmux list-windows -a -F ${shellQuote("#{window_id}")}`],
960
+ { encoding: "utf8", timeout: 1e4 }
961
+ );
962
+ if (probe.status !== 0) {
963
+ const sshFailed = probe.status === 255 || probe.error !== void 0;
964
+ if (sshFailed) {
965
+ return {
966
+ ok: false,
967
+ reason: "unreachable",
968
+ message: `cannot reach ${target} over ssh. The collector never prompts for auth, so connect once by hand to warm the connection, then retry.`
969
+ };
970
+ }
971
+ forgetHostReplica(store, agent.host_id);
972
+ return {
973
+ ok: false,
974
+ reason: "no_tmux",
975
+ message: `${target} has no tmux server running, so its agents are gone. Removed them; they will come back when it reports again.`
976
+ };
977
+ }
978
+ const remoteWindows = new Set((probe.stdout ?? "").split("\n").filter(Boolean));
979
+ if (!remoteWindows.has(agent.window)) {
980
+ forgetReplica(store, agent.agent_id, agent.host_id);
981
+ return {
982
+ ok: false,
983
+ reason: "window_gone",
984
+ message: `${agentLabel(agent)} is gone -- ${target} no longer has that window. Cleared.`
985
+ };
986
+ }
987
+ const attachTarget = shellQuote(`${agent.session}:${agent.window}`);
988
+ if (process.env.TMUX) {
989
+ const command = `ssh -t ${shellQuote(target)} tmux attach -t ${shellQuote(attachTarget)}`;
990
+ const name = `@${peer?.display_name ?? target}`;
991
+ const existing = tmux.windowNamed(name);
992
+ if (existing) {
993
+ tmux.selectWindow(existing);
994
+ return { ok: true };
995
+ }
996
+ spawnSync("tmux", ["new-window", "-n", name, command], { stdio: "ignore" });
997
+ return { ok: true };
998
+ }
999
+ spawnSync("ssh", ["-t", target, "tmux", "attach", "-t", attachTarget], { stdio: "inherit" });
1000
+ return { ok: true };
1001
+ }
1002
+
1003
+ // src/glance.ts
1004
+ import { execFileSync as execFileSync3 } from "child_process";
1005
+ var GLANCE_LINES = 40;
1006
+ var SSH_OPTIONS2 = [
1007
+ "-o",
1008
+ "BatchMode=yes",
1009
+ "-o",
1010
+ "ControlMaster=no",
1011
+ "-o",
1012
+ "ControlPath=~/.ssh/control/%r@%h:%p",
1013
+ "-o",
1014
+ "ConnectTimeout=2"
1015
+ ];
1016
+ function glance(store, agent, lines = GLANCE_LINES) {
1017
+ if (agent.host_id === loadIdentity()?.host_id) return tmux.capture(agent.pane, lines);
1018
+ const peer = store.peers().find((candidate) => candidate.host_id === agent.host_id);
1019
+ const target = peer?.target ?? peer?.name;
1020
+ if (!target) return null;
1021
+ try {
1022
+ return execFileSync3(
1023
+ "ssh",
1024
+ [
1025
+ ...SSH_OPTIONS2,
1026
+ target,
1027
+ "tmux",
1028
+ "capture-pane",
1029
+ "-p",
1030
+ "-t",
1031
+ `'${agent.pane}'`,
1032
+ "-S",
1033
+ `-${lines}`
1034
+ ],
1035
+ { encoding: "utf8", timeout: 3e3, stdio: ["ignore", "pipe", "ignore"] }
1036
+ );
1037
+ } catch {
1038
+ return null;
1039
+ }
1040
+ }
1041
+
1042
+ // src/status.ts
1043
+ function emptyCounts() {
1044
+ return { working: 0, blocked: 0, done: 0, crashed: 0, idle: 0 };
1045
+ }
1046
+ function tmuxStatus(view) {
1047
+ const urgency = ["crashed", "blocked", "done", "working", "idle"];
1048
+ return urgency.filter((state) => view.counts[state] > 0).map((state) => `${state} ${view.counts[state]}
1049
+ `).join("");
1050
+ }
1051
+ function status(store, now = Date.now()) {
1052
+ const identity = loadIdentity();
1053
+ const peers = store.peers();
1054
+ const peersByHost = new Map(
1055
+ peers.flatMap((peer) => peer.host_id === null ? [] : [[peer.host_id, peer]])
1056
+ );
1057
+ const events = store.allEvents();
1058
+ const local = foldAll(
1059
+ events.filter((event) => event.host_id === identity?.host_id),
1060
+ pidAlive
1061
+ );
1062
+ const remote = foldAll(
1063
+ events.filter((event) => event.host_id !== identity?.host_id),
1064
+ () => true
1065
+ );
1066
+ const counts = emptyCounts();
1067
+ const orchestratedCounts = emptyCounts();
1068
+ const agents = attentionSort([...local, ...remote]).map((agent) => {
1069
+ const peer = peersByHost.get(agent.host_id);
1070
+ const fetchedAt = peer?.fetched_at ?? null;
1071
+ const state = agent.state === null || agent.state === "cleared" ? "idle" : agent.state;
1072
+ const target = agent.driver === "human" ? counts : orchestratedCounts;
1073
+ target[state] += 1;
1074
+ return {
1075
+ ...agent,
1076
+ fetched_at: fetchedAt,
1077
+ // Replica freshness: how long since we last reached the peer. Local rows
1078
+ // have no fetched_at and are never stale.
1079
+ stale: isStale(fetchedAt, now, STALENESS_MS),
1080
+ age_ms: fetchedAt === null ? null : now - fetchedAt,
1081
+ // Information age: how long since the agent itself said anything. This
1082
+ // is the number a human means by "how stale is that row". A successful
1083
+ // fetch of a three-hour-old event resets age_ms to zero but leaves this
1084
+ // at three hours, which is why they cannot be the same field.
1085
+ event_age_ms: agent.event === null ? null : Math.max(0, now - agent.event.ts),
1086
+ // A jump proved this host's tmux was down and nothing has authored since.
1087
+ // Stronger than staleness: the host answers, its agents are just gone.
1088
+ tmux_down: peer?.tmux_down_at != null,
1089
+ host: peer?.display_name ?? peer?.name ?? (agent.host_id === identity?.host_id ? identity.display_name : agent.host_id)
1090
+ };
1091
+ });
1092
+ return {
1093
+ counts,
1094
+ orchestrated_counts: orchestratedCounts,
1095
+ agents,
1096
+ peers: peers.map((peer) => ({
1097
+ name: peer.name,
1098
+ display_name: peer.display_name,
1099
+ fetched_at: peer.fetched_at,
1100
+ // A peer we have never reached is stale, not fresh. `isStale` reads a
1101
+ // null `fetched_at` as "local, therefore never stale", which is right
1102
+ // for an agent row but backwards for a peer: null there means the very
1103
+ // first collect has not succeeded yet. Left to `isStale`, an
1104
+ // unreachable host you just added would render as up to date.
1105
+ stale: peer.fetched_at === null || isStale(peer.fetched_at, now, STALENESS_MS)
1106
+ }))
1107
+ };
1108
+ }
1109
+ async function statusWithCollect(store, now = Date.now()) {
1110
+ try {
1111
+ await collect(store, ssh, now);
1112
+ } catch (error) {
1113
+ process.stderr.write(
1114
+ `murmur: status: collect: ${error instanceof Error ? error.message : String(error)}
1115
+ `
1116
+ );
1117
+ }
1118
+ return status(store, now);
1119
+ }
1120
+
1121
+ // src/cli/pick.ts
1122
+ var PREVIEW_EVENTS = 8;
1123
+ var PREVIEW_MESSAGE_MAX = 300;
1124
+ var GLYPH = {
1125
+ crashed: "\u2717",
1126
+ // ✗
1127
+ blocked: "!",
1128
+ done: "\u2713",
1129
+ // ✓
1130
+ working: "\u25B6",
1131
+ // ▶
1132
+ idle: "\xB7"
1133
+ // ·
1134
+ };
1135
+ var COLOUR = {
1136
+ crashed: "\x1B[31m",
1137
+ blocked: "\x1B[33m",
1138
+ done: "\x1B[36m",
1139
+ working: "\x1B[37m",
1140
+ idle: "\x1B[90m"
1141
+ };
1142
+ var ANSI_PATTERN = `${String.fromCharCode(27)}\\[[0-9;]*m`;
1143
+ var ANSI_ESCAPE = new RegExp(ANSI_PATTERN, "g");
1144
+ var ANSI_AT_START = new RegExp(`^${ANSI_PATTERN}`);
1145
+ var ANSI_AT_END = new RegExp(`(?:${ANSI_PATTERN})+$`);
1146
+ var REMOTE = "\x1B[36m";
1147
+ var BOLD = "\x1B[1m";
1148
+ var DIM = "\x1B[2m";
1149
+ var RESET = "\x1B[0m";
1150
+ var URGENCY = ["crashed", "blocked", "done", "working", "idle"];
1151
+ var COLUMNS = {
1152
+ glyph: 3,
1153
+ // marker + state glyph
1154
+ state: 8,
1155
+ name: 30,
1156
+ stream: 13,
1157
+ streamWide: 18,
1158
+ // when no host column is shown
1159
+ host: 14
1160
+ };
1161
+ function headerRow(showHost) {
1162
+ return [
1163
+ " ".repeat(COLUMNS.glyph),
1164
+ pad("state", COLUMNS.state),
1165
+ pad("agent", COLUMNS.name),
1166
+ pad("workstream", showHost ? COLUMNS.stream : COLUMNS.streamWide),
1167
+ showHost ? pad("host", COLUMNS.host) : "",
1168
+ "age / flags"
1169
+ ].filter(Boolean).join(" ");
1170
+ }
1171
+ var FILTER_KEYS = [
1172
+ ["ctrl-a", ""],
1173
+ ["ctrl-x", "crashed"],
1174
+ ["ctrl-b", "blocked"],
1175
+ ["ctrl-d", "done"],
1176
+ ["ctrl-w", "working"]
1177
+ ];
1178
+ function timestamp(ts) {
1179
+ return new Date(ts).toLocaleTimeString([], {
1180
+ hour: "2-digit",
1181
+ minute: "2-digit",
1182
+ second: "2-digit"
1183
+ });
1184
+ }
1185
+ function age(ms) {
1186
+ if (ms === null || ms < 6e4) return "";
1187
+ if (ms < 36e5) return `${Math.floor(ms / 6e4)}m`;
1188
+ if (ms < 864e5) return `${Math.floor(ms / 36e5)}h`;
1189
+ return `${Math.floor(ms / 864e5)}d`;
1190
+ }
1191
+ function pad(value, width) {
1192
+ const visible = [...value.replace(ANSI_ESCAPE, "")].length;
1193
+ if (visible <= width) return value + " ".repeat(width - visible);
1194
+ const budget = Math.max(0, width - 1);
1195
+ let out = "";
1196
+ let shown = 0;
1197
+ let index = 0;
1198
+ while (index < value.length && shown < budget) {
1199
+ const sequence = ANSI_AT_START.exec(value.slice(index));
1200
+ if (sequence) {
1201
+ out += sequence[0];
1202
+ index += sequence[0].length;
1203
+ continue;
1204
+ }
1205
+ out += value[index];
1206
+ index += 1;
1207
+ shown += 1;
1208
+ }
1209
+ const tail = value.slice(index).match(ANSI_AT_END);
1210
+ return `${out}\u2026${tail?.[0] ?? ""}${" ".repeat(Math.max(0, width - budget - 1))}`;
1211
+ }
1212
+ function pickerRow(agent, showHost, current, local = true) {
1213
+ const state = agent.state ?? "idle";
1214
+ const colour = COLOUR[state] ?? "";
1215
+ const glyph = GLYPH[state] ?? "?";
1216
+ const marker = current ? `${BOLD}\u25C6${RESET}` : " ";
1217
+ const name = agent.agent_name ?? agent.pi_session ?? agentLabel(agent);
1218
+ const host = showHost ? local ? `${DIM} here${RESET}` : `${REMOTE}\u2192 ${terminalText(agent.host)}${RESET}` : "";
1219
+ const workstream = agent.workstream ? `${DIM}${terminalText(agent.workstream)}${RESET}` : "";
1220
+ const flags = [
1221
+ agent.driver === "orchestrated" ? "crew" : "",
1222
+ agent.stale ? "unreachable" : "",
1223
+ // A jump already proved this one dead. Say so plainly rather than leaving
1224
+ // the row looking merely old, and sort it last.
1225
+ agent.tmux_down ? "no tmux" : "",
1226
+ age(agent.event_age_ms)
1227
+ ].filter(Boolean).join(" ");
1228
+ const label = [
1229
+ `${marker} ${colour}${glyph}${RESET}`,
1230
+ `${colour}${pad(state, COLUMNS.state)}${RESET}`,
1231
+ pad(`${BOLD}${terminalText(name)}${RESET}`, COLUMNS.name),
1232
+ pad(workstream, showHost ? COLUMNS.stream : COLUMNS.streamWide),
1233
+ showHost ? pad(host, COLUMNS.host) : "",
1234
+ flags ? `${DIM}${flags}${RESET}` : ""
1235
+ ].filter(Boolean).join(" ");
1236
+ return `${agent.agent_id} ${label}`;
1237
+ }
1238
+ function previewText(store, agent) {
1239
+ const state = agent.state ?? "idle";
1240
+ const colour = COLOUR[state] ?? "";
1241
+ const head = [
1242
+ `${colour}${GLYPH[state] ?? "?"} ${state}${RESET} ${BOLD}${agent.agent_name ? terminalText(agent.agent_name) : agentLabel(agent)}${RESET}`,
1243
+ // Says where, and whether "where" is this machine. The glance below is a
1244
+ // local capture-pane or an ssh depending on this one fact, so it belongs in
1245
+ // the header rather than being inferred from a hostname.
1246
+ agent.host_id === loadIdentity()?.host_id ? `${DIM}here ${agentLocation(agent)}${RESET}` : `${REMOTE}\u2192 ${terminalText(agent.host)}${RESET} ${DIM}${agentLocation(agent)}${RESET}`
1247
+ ];
1248
+ const facts = [
1249
+ agent.workstream ? `stream ${terminalText(agent.workstream)}` : "",
1250
+ agent.role ? `role ${terminalText(agent.role)}` : "",
1251
+ agent.pi_session ? `session ${terminalText(agent.pi_session)}` : "",
1252
+ agent.driver === "orchestrated" ? "driver orchestrated (crew)" : "",
1253
+ agent.stale ? `fetched ${age(agent.age_ms)} ago` : ""
1254
+ ].filter(Boolean);
1255
+ const pane = glance(store, agent);
1256
+ const live = pane?.trimEnd() ? [`${DIM}\u2500\u2500 pane \u2500\u2500${RESET}`, pane.trimEnd()] : [`${DIM}\u2500\u2500 pane \u2500\u2500${RESET}`, `${DIM}unavailable (host unreachable, or pane gone)${RESET}`];
1257
+ const events = store.allEvents().filter((event) => event.agent_id === agent.agent_id).slice(-PREVIEW_EVENTS);
1258
+ const history = events.length ? events.map((event) => {
1259
+ let message = terminalText(event.message);
1260
+ if (message.length > PREVIEW_MESSAGE_MAX) {
1261
+ message = `${message.slice(0, PREVIEW_MESSAGE_MAX)}\u2026`;
1262
+ }
1263
+ const detail = message && message !== event.state ? ` ${message}` : "";
1264
+ return `${DIM}${timestamp(event.ts)}${RESET} ${terminalText(event.state).padEnd(8)}${detail}`;
1265
+ }) : [`${DIM}no recorded events${RESET}`];
1266
+ return [...head, "", ...facts, "", ...live, "", `${DIM}\u2500\u2500 history \u2500\u2500${RESET}`, ...history].join(
1267
+ "\n"
1268
+ );
1269
+ }
1270
+ function runPreview(store, agentId) {
1271
+ const agent = status(store).agents.find((candidate) => candidate.agent_id === agentId);
1272
+ if (!agent) return;
1273
+ process.stdout.write(`${previewText(store, agent)}
1274
+ `);
1275
+ }
1276
+ async function runPick(store, options = {}) {
1277
+ const identity = loadIdentity();
1278
+ const view = await statusWithCollect(store);
1279
+ const agents = view.agents.filter((agent2) => options.all || agent2.driver === "human");
1280
+ const hidden = view.agents.length - agents.length;
1281
+ if (agents.length === 0) {
1282
+ process.stdout.write(
1283
+ hidden ? `No human agents (+${hidden} crew \u2014 rerun with --all)
1284
+ ` : "No agents\n"
1285
+ );
1286
+ return;
1287
+ }
1288
+ const showHost = agents.some((agent2) => agent2.host_id !== identity?.host_id);
1289
+ const currentPane = process.env.TMUX_PANE ?? "";
1290
+ const input = agents.map(
1291
+ (agent2) => pickerRow(agent2, showHost, agent2.pane === currentPane, agent2.host_id === identity?.host_id)
1292
+ ).join("\n");
1293
+ const counts = /* @__PURE__ */ new Map();
1294
+ for (const agent2 of agents) {
1295
+ const state = agent2.state ?? "idle";
1296
+ counts.set(state, (counts.get(state) ?? 0) + 1);
1297
+ }
1298
+ const prompt = URGENCY.filter((state) => counts.get(state)).map((state) => `${COLOUR[state]}${GLYPH[state]}${counts.get(state)}${RESET}`).join(" ");
1299
+ const self = process.argv[1] ?? "murmur";
1300
+ const width = process.stdout.columns ?? 0;
1301
+ const previewLayout = width > 0 && width < 150 ? "bottom:60%,border-top,wrap" : "right:58%,border-left,wrap";
1302
+ const preview = `${process.execPath} ${self} pick --preview {1}`;
1303
+ const filterBinds = FILTER_KEYS.flatMap(([key, state]) => [
1304
+ "--bind",
1305
+ state ? `${key}:change-query(${state})` : `${key}:change-query()`
1306
+ ]);
1307
+ const result = spawnSync2(
1308
+ "fzf",
1309
+ [
1310
+ "--delimiter",
1311
+ " ",
1312
+ "--with-nth",
1313
+ "2..",
1314
+ "--ansi",
1315
+ "--no-sort",
1316
+ "--layout",
1317
+ "reverse",
1318
+ "--border",
1319
+ "--info",
1320
+ "inline",
1321
+ "--prompt",
1322
+ `${prompt}${prompt ? " " : ""}`,
1323
+ "--header",
1324
+ [
1325
+ `enter jump ctrl-r refresh ctrl-p preview filter: ${FILTER_KEYS.map(
1326
+ ([key, state]) => `${key.replace("ctrl-", "^")} ${state || "all"}`
1327
+ ).join(" ")}`,
1328
+ hidden ? `${hidden} crew hidden (--all)` : "",
1329
+ headerRow(showHost)
1330
+ ].filter(Boolean).join("\n"),
1331
+ "--preview",
1332
+ preview,
1333
+ // Narrow terminals cannot show both the columns and a 58% preview, and
1334
+ // the columns are the point of the list. ctrl-p cycles right / bottom /
1335
+ // hidden, so every column is reachable on a small viewport without
1336
+ // giving up the glance entirely.
1337
+ "--preview-window",
1338
+ previewLayout,
1339
+ "--bind",
1340
+ "ctrl-p:change-preview-window(bottom:60%,border-top,wrap|hidden|right:58%,border-left,wrap)",
1341
+ "--bind",
1342
+ `ctrl-r:reload(${process.execPath} ${self} pick --rows${options.all ? " --all" : ""})`,
1343
+ ...filterBinds,
1344
+ "--no-select-1",
1345
+ "--no-exit-0"
1346
+ ],
1347
+ {
1348
+ input,
1349
+ encoding: "utf8",
1350
+ stdio: ["pipe", "pipe", "inherit"],
1351
+ // FZF_DEFAULT_OPTS can carry a conflicting layout or bindings from the
1352
+ // user's shell; the old picker stripped it for the same reason.
1353
+ env: Object.fromEntries(
1354
+ Object.entries(process.env).filter(([key]) => !key.startsWith("FZF_DEFAULT_OPTS"))
1355
+ )
1356
+ }
1357
+ );
1358
+ const selected = result.stdout?.trim().split(" ")[0];
1359
+ if (!selected) return;
1360
+ const agent = agents.find((candidate) => candidate.agent_id === selected);
1361
+ if (!agent) return;
1362
+ const jump = jumpToAgent(store, agent);
1363
+ if (!jump.ok) {
1364
+ process.stderr.write(`${jump.message}
1365
+ `);
1366
+ process.exitCode = 1;
1367
+ }
1368
+ }
1369
+ async function runRows(store, options = {}) {
1370
+ const identity = loadIdentity();
1371
+ const view = await statusWithCollect(store);
1372
+ const agents = view.agents.filter((agent) => options.all || agent.driver === "human");
1373
+ const showHost = agents.some((agent) => agent.host_id !== identity?.host_id);
1374
+ const currentPane = process.env.TMUX_PANE ?? "";
1375
+ for (const agent of agents) {
1376
+ process.stdout.write(
1377
+ `${pickerRow(agent, showHost, agent.pane === currentPane, agent.host_id === identity?.host_id)}
1378
+ `
1379
+ );
1380
+ }
1381
+ }
1382
+ function registerPick(program2) {
1383
+ program2.command("pick").description("Pick an agent and jump to it").option("--all", "include orchestrated agents").option("--preview <agent-id>", "render the preview pane for one agent (internal)").option("--rows", "print picker rows only (internal, for reload)").action(async (options) => {
1384
+ const store = openStore();
1385
+ try {
1386
+ if (options.preview) runPreview(store, options.preview);
1387
+ else if (options.rows) await runRows(store, options);
1388
+ else await runPick(store, options);
1389
+ } finally {
1390
+ store.close();
1391
+ }
1392
+ });
1393
+ }
1394
+
1395
+ // src/cli/status.ts
1396
+ function registerStatus(program2) {
1397
+ program2.command("status").description("Show folded agent status").option("--json", "print JSON").action(async (options) => {
1398
+ const store = openStore();
1399
+ try {
1400
+ const view = await statusWithCollect(store);
1401
+ process.stdout.write(
1402
+ options.json ? `${JSON.stringify(view, null, 2)}
1403
+ ` : tmuxStatus(view)
1404
+ );
1405
+ } finally {
1406
+ store.close();
1407
+ }
1408
+ });
1409
+ }
1410
+
1411
+ // src/index.ts
1412
+ var VERSION = "0.1.0";
1413
+
1414
+ // src/cli.ts
1415
+ var program = new Command();
1416
+ program.name("murmur").description("Agent state across every machine, in one view.").version(VERSION);
1417
+ registerInit(program);
1418
+ registerLink(program);
1419
+ registerExport(program);
1420
+ registerCollect(program);
1421
+ registerClear(program);
1422
+ registerPeer(program);
1423
+ registerStatus(program);
1424
+ registerPick(program);
1425
+ program.parse();
1426
+ //# sourceMappingURL=cli.js.map