@martintrojer/murmur 0.1.4 → 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/cli.js CHANGED
@@ -3,35 +3,15 @@
3
3
  // src/cli.ts
4
4
  import { Command } from "commander";
5
5
 
6
- // src/identity.ts
7
- import { randomUUID } from "crypto";
8
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
9
- import { hostname } from "os";
10
- import { join as join2 } from "path";
11
-
12
- // src/paths.ts
13
- import { homedir } from "os";
14
- import { join } from "path";
15
- function stateDir() {
16
- return process.env.MURMUR_STATE_DIR ?? join(process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state"), "murmur");
6
+ // src/ids.ts
7
+ function asSessionId(raw) {
8
+ return raw;
17
9
  }
18
- function dbPath() {
19
- return join(stateDir(), "events.db");
20
- }
21
-
22
- // src/identity.ts
23
- function loadIdentity() {
24
- const path = join2(stateDir(), "identity.json");
25
- return existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) : null;
10
+ function asWindowId(raw) {
11
+ return raw;
26
12
  }
27
- function ensureIdentity(displayName = hostname()) {
28
- const existing = loadIdentity();
29
- if (existing) return existing;
30
- const identity = { host_id: randomUUID(), display_name: displayName };
31
- mkdirSync(stateDir(), { recursive: true });
32
- writeFileSync(join2(stateDir(), "identity.json"), `${JSON.stringify(identity, null, 2)}
33
- `);
34
- return identity;
13
+ function asPaneId(raw) {
14
+ return raw;
35
15
  }
36
16
 
37
17
  // src/mux.ts
@@ -47,10 +27,20 @@ function runTmux(args) {
47
27
  return null;
48
28
  }
49
29
  }
30
+ function exactSession(session) {
31
+ return `=${session}`;
32
+ }
33
+ function exactPaneTarget(session) {
34
+ return `=${session}:`;
35
+ }
36
+ function tmuxBadgeState(state) {
37
+ return state === "running" ? "working" : state;
38
+ }
50
39
  var tmux = {
51
40
  currentWindow() {
52
- const pane = process.env.TMUX_PANE;
53
- if (!pane) return null;
41
+ const raw = process.env.TMUX_PANE;
42
+ if (!raw) return null;
43
+ const pane = asPaneId(raw);
54
44
  const fields = runTmux([
55
45
  "display-message",
56
46
  "-t",
@@ -61,38 +51,31 @@ var tmux = {
61
51
  const [session, window, sessionName, windowName] = fields?.split(" ") ?? [];
62
52
  if (!session || !window) return null;
63
53
  return {
64
- session,
65
- window,
54
+ session: asSessionId(session),
55
+ window: asWindowId(window),
66
56
  pane,
67
57
  session_name: sessionName || null,
68
58
  window_name: windowName || null
69
59
  };
70
60
  },
71
- // Which of this host's windows still exist. Only the authoring node can
72
- // answer this, which is why the check runs on export rather than on the
73
- // reader: a peer holding a `blocked` row for a window that died has nothing
74
- // to supersede it, and the agent stays in every HUD forever.
75
- //
76
- // null means "could not tell" (no tmux server, tmux missing) and is
77
- // deliberately distinct from an empty set, which means "tmux answered, and
78
- // there are no windows". Treating the first as the second would clear every
79
- // agent on the host the moment tmux was unreachable.
61
+ // Which of this host's PANES still exist. The only liveness question tmux is
62
+ // ever asked, and the one that matches how an agent is addressed: a pane keeps
63
+ // its id when it moves between windows, so a recorded window id can be gone
64
+ // while the agent is very much alive.
80
65
  //
81
- // Unlike currentWindow, this deliberately asks tmux rather than reading the
82
- // environment, and it is right to: "which windows exist on this host" is a
83
- // server-wide question with one answer, and export runs over ssh with no
84
- // pane of its own. currentWindow asks "which pane am I in", which only
85
- // $TMUX_PANE can answer.
86
- liveWindows() {
87
- const out = runTmux(["list-windows", "-a", "-F", "#{window_id}"]);
66
+ // null means tmux could not answer; an empty set means it did and there are
67
+ // none. Conflating the two would delete every agent on the host the moment
68
+ // tmux was briefly unreachable.
69
+ livePanes() {
70
+ const out = runTmux(["list-panes", "-a", "-F", "#{pane_id}"]);
88
71
  if (out === null) return null;
89
- return new Set(out.split("\n").filter(Boolean));
72
+ return new Set(out.split("\n").filter(Boolean).map(asPaneId));
90
73
  },
91
- setState(window, state) {
74
+ setWindowBadge(window, state) {
92
75
  if (state === null) {
93
76
  runTmux(["set-window-option", "-qu", "-t", window, "@agent_state"]);
94
77
  } else {
95
- runTmux(["set-window-option", "-q", "-t", window, "@agent_state", state]);
78
+ runTmux(["set-window-option", "-q", "-t", window, "@agent_state", tmuxBadgeState(state)]);
96
79
  runTmux(["set-window-option", "-q", "-t", window, "@pane_agent", "1"]);
97
80
  }
98
81
  runTmux(["refresh-client", "-S"]);
@@ -101,45 +84,51 @@ var tmux = {
101
84
  runTmux(["switch-client", "-t", session]);
102
85
  return runTmux(["select-window", "-t", window]) !== null;
103
86
  },
104
- // Window ids are what the log stores, because they are stable; names are
105
- // what a human recognises in a picker. Names are live tmux state, not
106
- // history, so they are resolved at render time rather than recorded.
107
- windowNames() {
108
- const out = runTmux(["list-windows", "-a", "-F", "#{window_id} #{window_name}"]);
109
- const names = /* @__PURE__ */ new Map();
110
- for (const line of out?.split("\n") ?? []) {
111
- const [id, name] = line.split(" ");
112
- if (id && name) names.set(id, name);
113
- }
114
- return names;
115
- },
116
- // First window carrying this exact name, or null. Used to reuse a per-host
117
- // ssh window instead of opening another one.
118
87
  // Sibling panes, for deciding whether an unowned pane may clear the window's
119
88
  // badge. A window holding an agent and a shell must not lose the badge when
120
89
  // you focus the shell.
121
90
  panesInWindow(window) {
122
91
  const out = runTmux(["list-panes", "-t", window, "-F", "#{pane_id}"]);
123
- return out?.split("\n").filter(Boolean) ?? [];
92
+ return out?.split("\n").filter(Boolean).map(asPaneId) ?? [];
124
93
  },
125
- windowNamed(name) {
126
- const out = runTmux(["list-windows", "-a", "-F", "#{window_id} #{window_name}"]);
127
- for (const line of out?.split("\n") ?? []) {
128
- const [id, windowName] = line.split(" ");
129
- if (id && windowName === name) return id;
130
- }
131
- return null;
94
+ // Which client to send home when the remote attach exits. `switch-client`
95
+ // with no -c moves whichever client tmux considers current, and `murmur pick`
96
+ // usually runs in a popup -- a client of its own, which dies with the popup.
97
+ // Naming the real client is what lets the return outlive the picker.
98
+ clientName() {
99
+ return runTmux(["display-message", "-p", "#{client_name}"]) || null;
132
100
  },
133
- selectWindow(window) {
134
- return runTmux(["select-window", "-t", window]) !== null;
101
+ // Where the jump started, as a switch-client target. Window-level, not just
102
+ // the session: coming back to the right session but the wrong window is
103
+ // still the wrong place. The window id is stable where its index is not,
104
+ // since renumber-windows renumbers on every close.
105
+ currentTarget() {
106
+ return runTmux(["display-message", "-p", "#{session_name}:#{window_id}"]) || null;
107
+ },
108
+ // Whether a wrapper session for this host already exists. Deliberately not
109
+ // returning an id: a session is addressed by name, so a `#{session_id}` would
110
+ // only have to be turned back into one.
111
+ sessionNamed(name) {
112
+ const out = runTmux(["list-sessions", "-F", "#{session_name}"]);
113
+ if (out === null) return false;
114
+ return out.split("\n").includes(name);
115
+ },
116
+ newSession(name, command) {
117
+ return runTmux(["new-session", "-d", "-s", name, command]) !== null;
118
+ },
119
+ setSessionOption(session, option, value) {
120
+ runTmux(["set-option", "-t", exactPaneTarget(session), option, value]);
135
121
  },
136
- newWindow(name, command) {
137
- return runTmux(["new-window", "-n", name, command]) !== null;
122
+ switchClient(client, session) {
123
+ const target = exactSession(session);
124
+ const args = client ? ["switch-client", "-c", client, "-t", target] : ["switch-client", "-t", target];
125
+ return runTmux(args) !== null;
138
126
  },
139
- // The window a pane belongs to, for a pane murmur has no event for. Clearing
127
+ // The window a pane belongs to, for a pane murmur holds no row for. Clearing
140
128
  // a badge is a tmux operation and does not require murmur to own the pane.
141
129
  windowForPane(pane) {
142
- return runTmux(["display-message", "-t", pane, "-p", "#{window_id}"]) || null;
130
+ const out = runTmux(["display-message", "-t", pane, "-p", "#{window_id}"]);
131
+ return out ? asWindowId(out) : null;
143
132
  },
144
133
  capture(pane, lines) {
145
134
  const args = ["capture-pane", "-p", "-t", pane];
@@ -157,229 +146,527 @@ function pidAlive(pid) {
157
146
  }
158
147
 
159
148
  // src/store.ts
160
- import { rmSync } from "fs";
149
+ import { randomUUID } from "crypto";
150
+ import { mkdirSync, rmSync } from "fs";
151
+ import { dirname } from "path";
161
152
  import Database from "better-sqlite3";
162
- var DEFAULT_RETENTION_MS = 7 * 864e5;
163
- var STORE_VERSION = 2;
164
- function resetIfStale(path) {
165
- let salvaged = [];
153
+
154
+ // src/paths.ts
155
+ import { homedir } from "os";
156
+ import { join } from "path";
157
+ function stateDir() {
158
+ return process.env.MURMUR_STATE_DIR ?? join(process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state"), "murmur");
159
+ }
160
+ function dbPath() {
161
+ return join(stateDir(), "state.db");
162
+ }
163
+
164
+ // src/version.ts
165
+ import { createRequire } from "module";
166
+ function readVersion() {
167
+ const require2 = createRequire(import.meta.url);
168
+ for (const candidate of ["../package.json", "../../package.json"]) {
169
+ try {
170
+ return require2(candidate).version;
171
+ } catch {
172
+ }
173
+ }
174
+ throw new Error("cannot locate package.json to read the murmur version");
175
+ }
176
+ var MURMUR_VERSION = readVersion();
177
+
178
+ // src/types.ts
179
+ var DEFAULT_DRIVER = "human";
180
+
181
+ // src/view.ts
182
+ var RENDER_PRIORITY = [
183
+ "crashed",
184
+ "blocked",
185
+ "done",
186
+ "running",
187
+ "idle"
188
+ ];
189
+ var NEEDS_HUMAN = ["blocked", "crashed"];
190
+ var STALENESS_MS = 6e4;
191
+ function age(ms) {
192
+ if (ms === null || ms < 6e4) return "";
193
+ if (ms < 36e5) return `${Math.floor(ms / 6e4)}m`;
194
+ if (ms < 864e5) return `${Math.floor(ms / 36e5)}h`;
195
+ return `${Math.floor(ms / 864e5)}d`;
196
+ }
197
+ function freshness(fetchedAt, now, thresholdMs = STALENESS_MS) {
198
+ return fetchedAt !== null && now - fetchedAt <= thresholdMs ? "fresh" : "stale";
199
+ }
200
+ function renderState(view) {
201
+ for (const kind of ["crashed", "blocked", "done"]) {
202
+ if (view.attention.includes(kind)) return kind;
203
+ }
204
+ return view.activity === "running" ? "running" : "idle";
205
+ }
206
+ function newestAttention(pane) {
207
+ let newest = null;
208
+ for (const entry of pane.attention) {
209
+ if (newest === null || entry.requested_at > newest) newest = entry.requested_at;
210
+ }
211
+ return newest;
212
+ }
213
+ function paneView(pane, source) {
214
+ const agent = pane.agent;
215
+ return {
216
+ host_id: source.host_id,
217
+ host: source.host,
218
+ local: source.local,
219
+ pane: pane.pane,
220
+ session: pane.session,
221
+ window: pane.window,
222
+ session_name: pane.session_name,
223
+ window_name: pane.window_name,
224
+ activity: agent?.activity ?? null,
225
+ attention: pane.attention.map((entry) => entry.kind),
226
+ freshness: source.freshness,
227
+ agent_id: agent?.agent_id ?? null,
228
+ agent_name: agent?.agent_name ?? null,
229
+ pi_session: agent?.pi_session ?? null,
230
+ workstream: agent?.workstream ?? null,
231
+ role: agent?.role ?? null,
232
+ cli: agent?.cli ?? null,
233
+ driver: agent?.driver ?? DEFAULT_DRIVER,
234
+ updated_at: agent?.updated_at ?? newestAttention(pane),
235
+ snapshot_at: source.snapshot_at,
236
+ fetched_at: source.fetched_at
237
+ };
238
+ }
239
+ function paneViews(store, identity, now = Date.now()) {
240
+ const views = store.localPanes().map(
241
+ (pane) => paneView(pane, {
242
+ host_id: identity.host_id,
243
+ host: identity.display_name,
244
+ local: true,
245
+ // Local panes are always fresh: we are the node that authored them.
246
+ freshness: "fresh",
247
+ snapshot_at: null,
248
+ fetched_at: null
249
+ })
250
+ );
251
+ for (const peer of store.peers()) {
252
+ const snapshot = peer.snapshot;
253
+ if (!snapshot) continue;
254
+ const source = {
255
+ host_id: snapshot.host_id,
256
+ // The name the human typed, not the machine's self-reported hostname: a
257
+ // peer added as `linuxpc` can report a container id, which appears
258
+ // nowhere else in the tool and cannot be typed at `peer remove`.
259
+ host: peer.name,
260
+ local: false,
261
+ freshness: freshness(peer.fetched_at, now),
262
+ snapshot_at: peer.snapshot_at,
263
+ fetched_at: peer.fetched_at
264
+ };
265
+ for (const pane of snapshot.panes) views.push(paneView(pane, source));
266
+ }
267
+ return views;
268
+ }
269
+ var ORDER = new Map(RENDER_PRIORITY.map((state, index) => [state, index]));
270
+ function viewSort(views) {
271
+ return [...views].sort((left, right) => {
272
+ const byState = (ORDER.get(renderState(left)) ?? 99) - (ORDER.get(renderState(right)) ?? 99);
273
+ if (byState !== 0) return byState;
274
+ const byAge = (right.updated_at ?? 0) - (left.updated_at ?? 0);
275
+ if (byAge !== 0) return byAge;
276
+ const byHost = left.host.localeCompare(right.host);
277
+ return byHost !== 0 ? byHost : left.pane.localeCompare(right.pane);
278
+ });
279
+ }
280
+
281
+ // src/store.ts
282
+ var SCHEMA_USER_VERSION = 3;
283
+ var SCHEMA = `
284
+ CREATE TABLE agents (
285
+ agent_id TEXT NOT NULL PRIMARY KEY,
286
+ pane TEXT NOT NULL UNIQUE,
287
+ owner_pid INTEGER NOT NULL CHECK (owner_pid > 0),
288
+ activity TEXT NOT NULL CHECK (activity IN ('running', 'stopped')),
289
+ session TEXT NOT NULL,
290
+ window TEXT NOT NULL,
291
+ session_name TEXT,
292
+ window_name TEXT,
293
+ agent_name TEXT,
294
+ pi_session TEXT,
295
+ workstream TEXT,
296
+ role TEXT,
297
+ cli TEXT NOT NULL,
298
+ driver TEXT NOT NULL CHECK (driver IN ('human', 'orchestrated')),
299
+ claimed_at INTEGER NOT NULL,
300
+ updated_at INTEGER NOT NULL
301
+ ) STRICT;
302
+
303
+ CREATE TABLE attention (
304
+ pane TEXT NOT NULL,
305
+ kind TEXT NOT NULL CHECK (kind IN ('done', 'blocked', 'crashed')),
306
+ message TEXT NOT NULL,
307
+ source TEXT NOT NULL,
308
+ session TEXT NOT NULL,
309
+ window TEXT NOT NULL,
310
+ session_name TEXT,
311
+ window_name TEXT,
312
+ requested_at INTEGER NOT NULL,
313
+ PRIMARY KEY (pane, kind)
314
+ ) STRICT;
315
+
316
+ CREATE TABLE peers (
317
+ name TEXT NOT NULL PRIMARY KEY,
318
+ target TEXT NOT NULL,
319
+ host_id TEXT,
320
+ display_name TEXT,
321
+ snapshot TEXT,
322
+ snapshot_at INTEGER,
323
+ fetched_at INTEGER,
324
+ last_attempt_at INTEGER,
325
+ last_error TEXT,
326
+ murmur_version TEXT,
327
+ snapshot_version INTEGER
328
+ ) STRICT;
329
+ `;
330
+ function salvagePeers(path) {
166
331
  try {
167
332
  const existing = new Database(path, { fileMustExist: true });
168
- const version = existing.pragma("user_version", { simple: true }) ?? 0;
169
- if (version === STORE_VERSION) {
333
+ try {
334
+ const version = existing.pragma("user_version", { simple: true }) ?? 0;
335
+ if (version === SCHEMA_USER_VERSION) return [];
336
+ return existing.prepare("SELECT name, target FROM peers").all();
337
+ } catch {
338
+ return [];
339
+ } finally {
170
340
  existing.close();
171
- return salvaged;
172
341
  }
342
+ } catch {
343
+ return [];
344
+ }
345
+ }
346
+ function needsReset(path) {
347
+ try {
348
+ const existing = new Database(path, { fileMustExist: true });
173
349
  try {
174
- salvaged = existing.prepare("SELECT name, target, host_id, display_name FROM peers").all();
175
- } catch {
350
+ return (existing.pragma("user_version", { simple: true }) ?? 0) !== SCHEMA_USER_VERSION;
351
+ } finally {
352
+ existing.close();
176
353
  }
177
- existing.close();
178
354
  } catch {
179
- return salvaged;
355
+ return false;
180
356
  }
181
- for (const suffix of ["", "-wal", "-shm"]) rmSync(`${path}${suffix}`, { force: true });
182
- return salvaged;
183
357
  }
184
- function eventValues(event) {
185
- return [
186
- event.host_id,
187
- event.seq,
188
- event.ts,
189
- event.agent_id,
190
- event.session,
191
- event.window,
192
- event.pane,
193
- event.session_name,
194
- event.window_name,
195
- event.agent_name,
196
- event.pi_session,
197
- event.workstream,
198
- event.role,
199
- event.cli,
200
- event.driver,
201
- event.kind,
202
- event.state,
203
- event.message,
204
- event.pid,
205
- Number(event.synthetic),
206
- event.reason,
207
- JSON.stringify(event.extra)
208
- ];
358
+ function toAttention(row) {
359
+ return {
360
+ kind: row.kind,
361
+ message: row.message,
362
+ source: row.source,
363
+ requested_at: row.requested_at
364
+ };
209
365
  }
210
- function toEvent(row) {
366
+ function toAgent(row) {
211
367
  return {
212
- ...row,
368
+ agent_id: row.agent_id,
369
+ activity: row.activity,
370
+ agent_name: row.agent_name,
371
+ pi_session: row.pi_session,
372
+ workstream: row.workstream,
373
+ role: row.role,
374
+ cli: row.cli,
213
375
  driver: row.driver,
214
- synthetic: row.synthetic === 1,
215
- extra: JSON.parse(row.extra)
376
+ claimed_at: row.claimed_at,
377
+ updated_at: row.updated_at
216
378
  };
217
379
  }
380
+ var PRIORITY = new Map(RENDER_PRIORITY.map((kind, index) => [kind, index]));
381
+ function attentionOrder(left, right) {
382
+ return (PRIORITY.get(left.kind) ?? 99) - (PRIORITY.get(right.kind) ?? 99);
383
+ }
218
384
  function openStore() {
219
- const identity = ensureIdentity();
220
385
  const path = dbPath();
221
- const salvagedPeers = resetIfStale(path);
386
+ mkdirSync(dirname(path), { recursive: true });
387
+ const salvaged = salvagePeers(path);
388
+ if (needsReset(path)) {
389
+ for (const suffix of ["", "-wal", "-shm"]) rmSync(`${path}${suffix}`, { force: true });
390
+ }
222
391
  const database = new Database(path);
223
392
  database.pragma("journal_mode = WAL");
224
- database.pragma(`user_version = ${STORE_VERSION}`);
225
- database.exec(`
226
- CREATE TABLE IF NOT EXISTS events (
227
- host_id TEXT NOT NULL,
228
- seq INTEGER NOT NULL,
229
- ts INTEGER NOT NULL,
230
- agent_id TEXT NOT NULL,
231
- session TEXT NOT NULL,
232
- window TEXT NOT NULL,
233
- pane TEXT NOT NULL,
234
- session_name TEXT,
235
- window_name TEXT,
236
- agent_name TEXT,
237
- pi_session TEXT,
238
- workstream TEXT,
239
- role TEXT,
240
- cli TEXT,
241
- driver TEXT,
242
- kind TEXT NOT NULL,
243
- state TEXT NOT NULL,
244
- message TEXT NOT NULL,
245
- pid INTEGER,
246
- synthetic INTEGER NOT NULL,
247
- reason TEXT NOT NULL,
248
- extra TEXT NOT NULL,
249
- PRIMARY KEY (host_id, seq)
250
- );
251
- CREATE INDEX IF NOT EXISTS events_agent_seq ON events (agent_id, seq);
252
- CREATE TABLE IF NOT EXISTS peers (
253
- name TEXT PRIMARY KEY,
254
- target TEXT NOT NULL,
255
- host_id TEXT,
256
- display_name TEXT,
257
- watermark INTEGER NOT NULL,
258
- fetched_at INTEGER,
259
- -- When a jump last proved this peer's tmux was not answering. Reader
260
- -- state, not an event: this node cannot author facts about another
261
- -- node's agents, and a jump is a local observation, not something the
262
- -- peer said. Cleared by the next successful collect.
263
- tmux_down_at INTEGER
264
- );
265
- `);
266
- try {
267
- database.exec("ALTER TABLE peers ADD COLUMN tmux_down_at INTEGER");
268
- } catch {
269
- }
270
- if (salvagedPeers.length > 0) {
271
- const restore = database.prepare(
272
- `INSERT OR IGNORE INTO peers (name, target, host_id, display_name, watermark, fetched_at)
273
- VALUES (?, ?, ?, ?, 0, NULL)`
274
- );
275
- for (const peer of salvagedPeers) {
276
- restore.run(peer.name, peer.target, peer.host_id ?? null, peer.display_name ?? null);
277
- }
393
+ database.pragma("busy_timeout = 5000");
394
+ const version = database.pragma("user_version", { simple: true }) ?? 0;
395
+ if (version !== SCHEMA_USER_VERSION) {
396
+ database.exec(SCHEMA);
397
+ database.pragma(`user_version = ${SCHEMA_USER_VERSION}`);
398
+ const restore = database.prepare("INSERT OR IGNORE INTO peers (name, target) VALUES (?, ?)");
399
+ for (const peer of salvaged) restore.run(peer.name, peer.target);
278
400
  }
279
- const eventColumns = `
280
- host_id, seq, ts, agent_id, session, window, pane,
281
- session_name, window_name, agent_name, pi_session,
282
- workstream, role, cli, driver, kind, state, message, pid,
283
- synthetic, reason, extra`;
284
- const eventPlaceholders = new Array(22).fill("?").join(", ");
285
- const insertEvent = database.prepare(
286
- `INSERT INTO events (${eventColumns}) VALUES (${eventPlaceholders})`
287
- );
288
- const ingestEvent = database.prepare(
289
- `INSERT OR IGNORE INTO events (${eventColumns}) VALUES (${eventPlaceholders})`
401
+ const selectAgentByPane = database.prepare("SELECT * FROM agents WHERE pane = ?");
402
+ const insertAgent = database.prepare(`
403
+ INSERT INTO agents (agent_id, pane, owner_pid, activity, session, window,
404
+ session_name, window_name, agent_name, pi_session,
405
+ workstream, role, cli, driver, claimed_at, updated_at)
406
+ VALUES (@agent_id, @pane, @owner_pid, @activity, @session, @window,
407
+ @session_name, @window_name, @agent_name, @pi_session,
408
+ @workstream, @role, @cli, @driver, @claimed_at, @updated_at)
409
+ `);
410
+ const retainAgent = database.prepare(`
411
+ UPDATE agents
412
+ SET session = @session, window = @window, session_name = @session_name,
413
+ window_name = @window_name, agent_name = @agent_name,
414
+ pi_session = @pi_session, workstream = @workstream, role = @role,
415
+ cli = @cli, driver = @driver, updated_at = @updated_at
416
+ WHERE agent_id = @agent_id
417
+ `);
418
+ const deleteAgentByPane = database.prepare("DELETE FROM agents WHERE pane = ?");
419
+ const deleteAttentionForPane = database.prepare("DELETE FROM attention WHERE pane = ?");
420
+ const updateActivity = database.prepare(`
421
+ UPDATE agents
422
+ SET activity = @activity, session = @session, window = @window,
423
+ session_name = @session_name, window_name = @window_name,
424
+ updated_at = @updated_at
425
+ WHERE agent_id = @agent_id AND owner_pid = @owner_pid
426
+ `);
427
+ const deleteAgentOwned = database.prepare(
428
+ "DELETE FROM agents WHERE agent_id = ? AND owner_pid = ?"
290
429
  );
291
- const selectMaxSeq = database.prepare(
292
- "SELECT COALESCE(MAX(seq), 0) AS seq FROM events WHERE host_id = ?"
430
+ const upsertAttention = database.prepare(`
431
+ INSERT INTO attention (pane, kind, message, source, session, window,
432
+ session_name, window_name, requested_at)
433
+ VALUES (@pane, @kind, @message, @source, @session, @window,
434
+ @session_name, @window_name, @requested_at)
435
+ ON CONFLICT (pane, kind) DO UPDATE SET
436
+ message = excluded.message,
437
+ source = excluded.source,
438
+ session = excluded.session,
439
+ window = excluded.window,
440
+ session_name = excluded.session_name,
441
+ window_name = excluded.window_name
442
+ `);
443
+ const selectAgents = database.prepare("SELECT * FROM agents");
444
+ const selectAttention = database.prepare("SELECT * FROM attention");
445
+ const setActivityByPane = database.prepare(
446
+ "UPDATE agents SET activity = ?, updated_at = ? WHERE pane = ?"
293
447
  );
294
- const append = database.transaction((event) => {
295
- const row = selectMaxSeq.get(identity.host_id);
296
- const stored = {
297
- ...event,
298
- host_id: identity.host_id,
299
- seq: row.seq + 1,
300
- ts: event.ts ?? Date.now(),
301
- session_name: event.session_name ?? null,
302
- window_name: event.window_name ?? null,
303
- agent_name: event.agent_name ?? null,
304
- pi_session: event.pi_session ?? null
448
+ const claimAgent = database.transaction((claim) => {
449
+ const now = claim.now ?? Date.now();
450
+ const isAlive = claim.isAlive ?? pidAlive;
451
+ const { location, meta, owner_pid } = claim;
452
+ const incumbent = selectAgentByPane.get(location.pane);
453
+ const values = {
454
+ pane: location.pane,
455
+ owner_pid,
456
+ session: location.session,
457
+ window: location.window,
458
+ session_name: location.session_name,
459
+ window_name: location.window_name,
460
+ agent_name: meta.agent_name,
461
+ pi_session: meta.pi_session,
462
+ workstream: meta.workstream,
463
+ role: meta.role,
464
+ cli: meta.cli,
465
+ driver: meta.driver,
466
+ updated_at: now
305
467
  };
306
- insertEvent.run(...eventValues(stored));
307
- return stored;
308
- });
309
- const ingest = database.transaction((events) => {
310
- let inserted = 0;
311
- for (const event of events) inserted += ingestEvent.run(...eventValues(event)).changes;
312
- return inserted;
468
+ if (!incumbent) {
469
+ const agentId2 = randomUUID();
470
+ insertAgent.run({ ...values, agent_id: agentId2, activity: "stopped", claimed_at: now });
471
+ return { outcome: "claimed", agent_id: agentId2 };
472
+ }
473
+ if (incumbent.owner_pid === owner_pid) {
474
+ retainAgent.run({ ...values, agent_id: incumbent.agent_id });
475
+ return { outcome: "retained", agent_id: incumbent.agent_id };
476
+ }
477
+ if (isAlive(incumbent.owner_pid)) {
478
+ return { outcome: "refused", held_by_pid: incumbent.owner_pid };
479
+ }
480
+ deleteAgentByPane.run(location.pane);
481
+ deleteAttentionForPane.run(location.pane);
482
+ const agentId = randomUUID();
483
+ insertAgent.run({ ...values, agent_id: agentId, activity: "stopped", claimed_at: now });
484
+ return { outcome: "replaced", agent_id: agentId, previous_agent_id: incumbent.agent_id };
485
+ }).immediate;
486
+ const reconcileLocal = database.transaction((world) => {
487
+ const summary = { crashed: [], removed: [], attention_removed: [] };
488
+ if (world.panes === null) return summary;
489
+ const live = world.panes;
490
+ const isAlive = world.isAlive ?? pidAlive;
491
+ const now = world.now ?? Date.now();
492
+ const alreadyCrashed = new Set(
493
+ selectAttention.all().filter((row) => row.kind === "crashed").map((row) => row.pane)
494
+ );
495
+ for (const row of selectAgents.all()) {
496
+ const pane = asPaneId(row.pane);
497
+ if (!live.has(pane)) {
498
+ deleteAgentByPane.run(row.pane);
499
+ deleteAttentionForPane.run(row.pane);
500
+ summary.removed.push(pane);
501
+ continue;
502
+ }
503
+ if (isAlive(row.owner_pid)) continue;
504
+ if (row.activity === "running") {
505
+ setActivityByPane.run("stopped", now, row.pane);
506
+ upsertAttention.run({
507
+ pane: row.pane,
508
+ kind: "crashed",
509
+ message: "",
510
+ source: "murmur",
511
+ session: row.session,
512
+ window: row.window,
513
+ session_name: row.session_name,
514
+ window_name: row.window_name,
515
+ requested_at: now
516
+ });
517
+ summary.crashed.push(pane);
518
+ } else if (!alreadyCrashed.has(row.pane)) {
519
+ deleteAgentByPane.run(row.pane);
520
+ summary.removed.push(pane);
521
+ }
522
+ }
523
+ for (const row of selectAttention.all()) {
524
+ const pane = asPaneId(row.pane);
525
+ if (live.has(pane)) continue;
526
+ deleteAttentionForPane.run(row.pane);
527
+ if (!summary.attention_removed.includes(pane)) summary.attention_removed.push(pane);
528
+ }
529
+ return summary;
530
+ }).immediate;
531
+ const readLocalPanes = database.transaction(() => {
532
+ const agents = selectAgents.all();
533
+ const attention = selectAttention.all();
534
+ const panes = /* @__PURE__ */ new Map();
535
+ const locate = (row) => {
536
+ const existing = panes.get(row.pane);
537
+ if (existing) return existing;
538
+ const created = {
539
+ pane: asPaneId(row.pane),
540
+ session: asSessionId(row.session),
541
+ window: asWindowId(row.window),
542
+ session_name: row.session_name,
543
+ window_name: row.window_name,
544
+ agent: null,
545
+ attention: []
546
+ };
547
+ panes.set(row.pane, created);
548
+ return created;
549
+ };
550
+ for (const row of agents) locate(row).agent = toAgent(row);
551
+ for (const row of attention) locate(row).attention.push(toAttention(row));
552
+ for (const pane of panes.values()) pane.attention.sort(attentionOrder);
553
+ return [...panes.values()].sort((left, right) => left.pane.localeCompare(right.pane));
313
554
  });
555
+ function peerRecord(row) {
556
+ let snapshot = null;
557
+ if (row.snapshot !== null) {
558
+ try {
559
+ snapshot = JSON.parse(row.snapshot);
560
+ } catch {
561
+ snapshot = null;
562
+ }
563
+ }
564
+ return {
565
+ name: row.name,
566
+ target: row.target,
567
+ host_id: row.host_id,
568
+ display_name: row.display_name,
569
+ snapshot,
570
+ snapshot_at: row.snapshot_at,
571
+ fetched_at: row.fetched_at,
572
+ last_attempt_at: row.last_attempt_at,
573
+ last_error: row.last_error,
574
+ murmur_version: row.murmur_version,
575
+ snapshot_version: row.snapshot_version
576
+ };
577
+ }
314
578
  return {
315
- append,
316
- ingest,
317
- eventsSince(hostId, seq) {
318
- const rows = database.prepare("SELECT * FROM events WHERE host_id = ? AND seq > ? ORDER BY seq").all(hostId, seq);
319
- return rows.map(toEvent);
320
- },
321
- allEvents() {
322
- const rows = database.prepare("SELECT * FROM events ORDER BY ts, host_id, seq").all();
323
- return rows.map(toEvent);
579
+ claimAgent,
580
+ reconcileLocal,
581
+ setActivity(update) {
582
+ return updateActivity.run({
583
+ activity: update.activity,
584
+ session: update.location.session,
585
+ window: update.location.window,
586
+ session_name: update.location.session_name,
587
+ window_name: update.location.window_name,
588
+ updated_at: update.now ?? Date.now(),
589
+ agent_id: update.agent_id,
590
+ owner_pid: update.owner_pid
591
+ }).changes === 1;
324
592
  },
325
- latestForAgent(hostId, agentId) {
326
- const row = database.prepare(
327
- `SELECT * FROM events
328
- WHERE host_id = ? AND agent_id = ?
329
- ORDER BY seq DESC LIMIT 1`
330
- ).get(hostId, agentId);
331
- return row ? toEvent(row) : null;
593
+ releaseAgent(release) {
594
+ return deleteAgentOwned.run(release.agent_id, release.owner_pid).changes === 1;
332
595
  },
333
- maxSeq(hostId) {
334
- return selectMaxSeq.get(hostId).seq;
335
- },
336
- prune(horizonMs = Number(process.env.MURMUR_RETENTION_MS ?? DEFAULT_RETENTION_MS)) {
337
- return database.prepare(`
338
- DELETE FROM events
339
- WHERE ts < ?
340
- AND (host_id, seq) NOT IN (
341
- SELECT host_id, seq FROM (
342
- SELECT host_id, seq,
343
- ROW_NUMBER() OVER (PARTITION BY agent_id ORDER BY ts DESC, seq DESC) rn
344
- FROM events
345
- ) WHERE rn = 1
346
- )
347
- `).run(Date.now() - horizonMs).changes;
596
+ requestAttention(request) {
597
+ upsertAttention.run({
598
+ pane: request.location.pane,
599
+ kind: request.kind,
600
+ message: request.message,
601
+ source: request.source,
602
+ session: request.location.session,
603
+ window: request.location.window,
604
+ session_name: request.location.session_name,
605
+ window_name: request.location.window_name,
606
+ requested_at: request.now ?? Date.now()
607
+ });
348
608
  },
349
- peers() {
350
- return database.prepare("SELECT * FROM peers ORDER BY name").all();
609
+ acknowledgePane(pane) {
610
+ return deleteAttentionForPane.run(pane).changes;
351
611
  },
352
- forgetAgent(agentId) {
353
- return database.prepare("DELETE FROM events WHERE agent_id = ?").run(agentId).changes;
612
+ localPanes() {
613
+ return readLocalPanes();
354
614
  },
355
- forgetHost(hostId) {
356
- return database.prepare("DELETE FROM events WHERE host_id = ?").run(hostId).changes;
615
+ buildLocalSnapshot(identity, world) {
616
+ reconcileLocal(world);
617
+ return {
618
+ murmur_snapshot: 1,
619
+ host_id: identity.host_id,
620
+ display_name: identity.display_name,
621
+ murmur_version: MURMUR_VERSION,
622
+ generated_at: world.now ?? Date.now(),
623
+ // Rule 3: a pane with no agent and no attention must not be published.
624
+ // A no-op against today's `readLocalPanes`, which builds a pane entry
625
+ // only from a row and so cannot produce an empty one -- kept because the
626
+ // rule belongs to the DOCUMENT, and the validator rejects such an entry
627
+ // outright. Without it, one narrowing of the local read would make this
628
+ // node reachable-but-broken on every peer that collects it, and the
629
+ // symptom would show up on the other machines.
630
+ panes: readLocalPanes().filter((pane) => pane.agent !== null || pane.attention.length > 0)
631
+ };
357
632
  },
358
- upsertPeer(peer) {
359
- const current = database.prepare("SELECT * FROM peers WHERE name = ?").get(peer.name);
360
- database.prepare(`
361
- INSERT INTO peers (name, target, host_id, display_name, watermark, fetched_at, tmux_down_at)
362
- VALUES (?, ?, ?, ?, ?, ?, ?)
363
- ON CONFLICT(name) DO UPDATE SET
364
- target = excluded.target,
365
- host_id = excluded.host_id,
366
- display_name = excluded.display_name,
367
- watermark = excluded.watermark,
368
- fetched_at = excluded.fetched_at,
369
- tmux_down_at = excluded.tmux_down_at
370
- `).run(
371
- peer.name,
372
- peer.target,
373
- peer.host_id !== void 0 ? peer.host_id : current?.host_id ?? null,
374
- peer.display_name !== void 0 ? peer.display_name : current?.display_name ?? null,
375
- peer.watermark !== void 0 ? peer.watermark : current?.watermark ?? 0,
376
- peer.fetched_at !== void 0 ? peer.fetched_at : current?.fetched_at ?? null,
377
- peer.tmux_down_at !== void 0 ? peer.tmux_down_at : current?.tmux_down_at ?? null
633
+ peers() {
634
+ return database.prepare("SELECT * FROM peers ORDER BY name").all().map(
635
+ peerRecord
378
636
  );
379
637
  },
638
+ addPeer(name, target) {
639
+ database.prepare(
640
+ `INSERT INTO peers (name, target) VALUES (?, ?)
641
+ ON CONFLICT(name) DO UPDATE SET target = excluded.target`
642
+ ).run(name, target);
643
+ },
380
644
  removePeer(name) {
381
645
  return database.prepare("DELETE FROM peers WHERE name = ?").run(name).changes > 0;
382
646
  },
647
+ replacePeerSnapshot(name, fetch) {
648
+ if (!fetch.ok) {
649
+ database.prepare("UPDATE peers SET last_attempt_at = ?, last_error = ? WHERE name = ?").run(fetch.at, fetch.error, name);
650
+ return;
651
+ }
652
+ database.prepare(
653
+ `UPDATE peers
654
+ SET snapshot = ?, snapshot_at = ?, fetched_at = ?, last_attempt_at = ?,
655
+ last_error = NULL, host_id = ?, display_name = ?,
656
+ murmur_version = ?, snapshot_version = ?
657
+ WHERE name = ?`
658
+ ).run(
659
+ JSON.stringify(fetch.snapshot),
660
+ fetch.snapshot.generated_at,
661
+ fetch.at,
662
+ fetch.at,
663
+ fetch.snapshot.host_id,
664
+ fetch.snapshot.display_name,
665
+ fetch.snapshot.murmur_version,
666
+ fetch.snapshot.murmur_snapshot,
667
+ name
668
+ );
669
+ },
383
670
  close() {
384
671
  database.close();
385
672
  }
@@ -387,72 +674,32 @@ function openStore() {
387
674
  }
388
675
 
389
676
  // src/cli/clear.ts
390
- function windowHasAgent(window, focused, hostId, mux, store) {
391
- if (!hostId) return false;
392
- const siblings = mux.panesInWindow(window).filter((candidate) => candidate !== focused);
393
- if (siblings.length === 0) return false;
394
- if (!store) return false;
395
- try {
396
- for (const sibling of siblings) {
397
- const latest = store.latestForAgent(hostId, `${hostId}:${sibling}`);
398
- if (latest && latest.state !== "cleared") return true;
399
- }
400
- return false;
401
- } catch {
402
- return true;
403
- }
677
+ function windowBadge(window, mux, store) {
678
+ const panes = new Set(mux.panesInWindow(window));
679
+ const states = store.localPanes().filter((pane) => panes.has(pane.pane)).map(
680
+ (pane) => renderState({
681
+ activity: pane.agent?.activity ?? null,
682
+ attention: pane.attention.map((entry) => entry.kind)
683
+ })
684
+ );
685
+ return RENDER_PRIORITY.find((state) => state !== "idle" && states.includes(state)) ?? null;
404
686
  }
405
- function clearPane(pane, mux = tmux) {
687
+ function clearPane(raw, mux = tmux) {
406
688
  let store;
407
689
  try {
408
- if (!pane) return;
690
+ if (!raw) return;
691
+ const pane = asPaneId(raw);
409
692
  const window = mux.windowForPane(pane);
410
- const identity = loadIdentity();
411
- let owner;
412
- if (identity) {
413
- try {
414
- store = openStore();
415
- owner = store.latestForAgent(identity.host_id, `${identity.host_id}:${pane}`) ?? void 0;
416
- } catch {
417
- }
418
- }
419
- if (!owner) {
420
- if (window && !windowHasAgent(window, pane, identity?.host_id, mux, store)) {
421
- mux.setState(window, null);
422
- }
423
- return;
424
- }
425
- if (owner.state === "cleared") {
426
- mux.setState(owner.window, null);
427
- return;
693
+ try {
694
+ store = openStore();
695
+ store.acknowledgePane(pane);
696
+ } catch {
428
697
  }
698
+ if (!window) return;
429
699
  try {
430
- store?.append({
431
- agent_id: owner.agent_id,
432
- session: owner.session,
433
- window: owner.window,
434
- pane: owner.pane,
435
- // Carry the names forward: a `cleared` row that drops them makes the
436
- // agent's last event nameless, which is what left "@75" in the picker.
437
- session_name: owner.session_name,
438
- window_name: owner.window_name,
439
- agent_name: owner.agent_name,
440
- pi_session: owner.pi_session,
441
- workstream: owner.workstream,
442
- role: owner.role,
443
- cli: owner.cli,
444
- driver: owner.driver,
445
- kind: "state",
446
- state: "cleared",
447
- message: "",
448
- pid: null,
449
- synthetic: false,
450
- reason: "",
451
- extra: {}
452
- });
700
+ mux.setWindowBadge(window, store ? windowBadge(window, mux, store) : null);
453
701
  } catch {
454
702
  }
455
- mux.setState(owner.window, null);
456
703
  } catch {
457
704
  } finally {
458
705
  try {
@@ -462,7 +709,7 @@ function clearPane(pane, mux = tmux) {
462
709
  }
463
710
  }
464
711
  function registerClear(program2) {
465
- 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 ?? ""));
712
+ program2.command("clear").description("Acknowledge attention for a pane").option("--pane <pane-id>", "focused tmux pane id").action((options) => clearPane(options.pane ?? ""));
466
713
  }
467
714
 
468
715
  // src/channel.ts
@@ -502,193 +749,166 @@ function hasWarmSocket(target) {
502
749
  }
503
750
  }
504
751
 
505
- // src/types.ts
506
- var DEFAULT_DRIVER = "human";
507
-
508
- // src/fold.ts
509
- function foldAgent(events, isAlive) {
510
- for (let index = events.length - 1; index >= 0; index -= 1) {
511
- const event = events[index];
512
- if (!event) continue;
513
- switch (event.state) {
514
- case "blocked":
515
- case "done":
516
- case "crashed":
517
- return { state: event.state, event };
518
- case "cleared":
519
- return { state: null, event: null };
520
- case "working":
521
- return {
522
- state: event.pid !== null && event.pid > 0 && isAlive(event.pid) ? "working" : "crashed",
523
- event
524
- };
525
- }
752
+ // src/snapshot.ts
753
+ var SnapshotInvalidError = class extends Error {
754
+ constructor(path, detail) {
755
+ super(path === "" ? detail : `${path}: ${detail}`);
756
+ this.path = path;
757
+ this.name = "SnapshotInvalidError";
758
+ }
759
+ path;
760
+ };
761
+ function fail(path, detail) {
762
+ throw new SnapshotInvalidError(path, detail);
763
+ }
764
+ function object(value, path, keys) {
765
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
766
+ fail(path, "expected an object");
526
767
  }
527
- return { state: null, event: null };
528
- }
529
- function foldAll(events, isAlive) {
530
- const byAgent = /* @__PURE__ */ new Map();
531
- for (const event of events) {
532
- const agentEvents = byAgent.get(event.agent_id);
533
- if (agentEvents) agentEvents.push(event);
534
- else byAgent.set(event.agent_id, [event]);
768
+ const record = value;
769
+ for (const key of keys) if (!(key in record)) fail(path, `missing key ${key}`);
770
+ for (const key of Object.keys(record)) {
771
+ if (!keys.includes(key)) fail(path, `unknown key ${key}`);
535
772
  }
536
- return [...byAgent.values()].map((agentEvents) => {
537
- const folded = foldAgent(agentEvents, isAlive);
538
- const source = folded.event ?? agentEvents[agentEvents.length - 1];
539
- if (!source) throw new Error("agent event group cannot be empty");
540
- return {
541
- agent_id: source.agent_id,
542
- host_id: source.host_id,
543
- state: folded.state,
544
- event: folded.event,
545
- workstream: source.workstream,
546
- role: source.role,
547
- cli: source.cli,
548
- driver: source.driver ?? DEFAULT_DRIVER,
549
- session: source.session,
550
- window: source.window,
551
- pane: source.pane,
552
- session_name: source.session_name,
553
- window_name: source.window_name,
554
- agent_name: source.agent_name,
555
- pi_session: source.pi_session,
556
- fetched_at: null
557
- };
558
- });
773
+ return record;
559
774
  }
560
- var ATTENTION_ORDER = {
561
- blocked: 0,
562
- done: 1,
563
- crashed: 2,
564
- working: 3,
565
- cleared: 4
566
- };
567
- function attentionSort(views) {
568
- return [...views].sort((left, right) => {
569
- const stateOrder = (left.state === null ? 4 : ATTENTION_ORDER[left.state]) - (right.state === null ? 4 : ATTENTION_ORDER[right.state]);
570
- if (stateOrder !== 0) return stateOrder;
571
- return (right.event?.ts ?? 0) - (left.event?.ts ?? 0);
572
- });
775
+ function text(value, path) {
776
+ if (typeof value !== "string" || value === "") fail(path, "expected a non-empty string");
777
+ return value;
573
778
  }
574
- function isStale(fetchedAt, now, thresholdMs = 6e4) {
575
- return fetchedAt !== null && now - fetchedAt > thresholdMs;
779
+ function textOrNull(value, path) {
780
+ if (value === null) return null;
781
+ if (typeof value !== "string") fail(path, "expected a string or null");
782
+ return value;
576
783
  }
577
-
578
- // src/export.ts
579
- var SCHEMA_VERSION = 2;
580
- var EVENT_FIELDS = /* @__PURE__ */ new Set([
784
+ function anyText(value, path) {
785
+ if (typeof value !== "string") fail(path, "expected a string");
786
+ return value;
787
+ }
788
+ function timestamp(value, path) {
789
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 0) {
790
+ fail(path, "expected a non-negative integer");
791
+ }
792
+ return value;
793
+ }
794
+ function member(value, path, allowed) {
795
+ if (typeof value !== "string" || !allowed.includes(value)) {
796
+ fail(path, `expected one of ${allowed.join(", ")}`);
797
+ }
798
+ return value;
799
+ }
800
+ var ACTIVITIES = ["running", "stopped"];
801
+ var DRIVERS = ["human", "orchestrated"];
802
+ var KINDS = ["done", "blocked", "crashed"];
803
+ var TOP_KEYS = [
804
+ "murmur_snapshot",
581
805
  "host_id",
582
- "seq",
583
- "ts",
584
- "agent_id",
806
+ "display_name",
807
+ "murmur_version",
808
+ "generated_at",
809
+ "panes"
810
+ ];
811
+ var PANE_KEYS = [
812
+ "pane",
585
813
  "session",
586
814
  "window",
587
- "pane",
588
815
  "session_name",
589
816
  "window_name",
817
+ "agent",
818
+ "attention"
819
+ ];
820
+ var AGENT_KEYS = [
821
+ "agent_id",
822
+ "activity",
590
823
  "agent_name",
591
824
  "pi_session",
592
825
  "workstream",
593
826
  "role",
594
827
  "cli",
595
828
  "driver",
596
- "kind",
597
- "state",
598
- "message",
599
- "pid",
600
- "synthetic",
601
- "reason"
602
- ]);
603
- function eventToWire(event) {
604
- const { extra, ...known } = event;
605
- return { ...extra, ...known };
606
- }
607
- function eventFromWire(wire) {
608
- const extra = Object.fromEntries(Object.entries(wire).filter(([key]) => !EVENT_FIELDS.has(key)));
829
+ "claimed_at",
830
+ "updated_at"
831
+ ];
832
+ var ATTENTION_KEYS = ["kind", "message", "source", "requested_at"];
833
+ function parseAgent(value, path) {
834
+ if (value === null) return null;
835
+ const row = object(value, path, AGENT_KEYS);
609
836
  return {
610
- host_id: wire.host_id,
611
- seq: wire.seq,
612
- ts: wire.ts,
613
- agent_id: wire.agent_id,
614
- session: wire.session,
615
- window: wire.window,
616
- pane: wire.pane,
617
- session_name: wire.session_name ?? null,
618
- window_name: wire.window_name ?? null,
619
- agent_name: wire.agent_name ?? null,
620
- pi_session: wire.pi_session ?? null,
621
- workstream: wire.workstream ?? null,
622
- role: wire.role ?? null,
623
- cli: wire.cli ?? null,
624
- driver: wire.driver ?? null,
625
- kind: wire.kind,
626
- state: wire.state,
627
- message: wire.message,
628
- pid: wire.pid ?? null,
629
- synthetic: wire.synthetic,
630
- reason: wire.reason,
631
- extra
837
+ agent_id: text(row.agent_id, `${path}.agent_id`),
838
+ activity: member(row.activity, `${path}.activity`, ACTIVITIES),
839
+ agent_name: textOrNull(row.agent_name, `${path}.agent_name`),
840
+ pi_session: textOrNull(row.pi_session, `${path}.pi_session`),
841
+ workstream: textOrNull(row.workstream, `${path}.workstream`),
842
+ role: textOrNull(row.role, `${path}.role`),
843
+ cli: text(row.cli, `${path}.cli`),
844
+ driver: member(row.driver, `${path}.driver`, DRIVERS),
845
+ claimed_at: timestamp(row.claimed_at, `${path}.claimed_at`),
846
+ updated_at: timestamp(row.updated_at, `${path}.updated_at`)
632
847
  };
633
848
  }
634
- function synthesizeCrashes(store, hostId, isAlive) {
635
- const byAgent = /* @__PURE__ */ new Map();
636
- for (const event of store.allEvents()) {
637
- if (event.host_id !== hostId) continue;
638
- const events = byAgent.get(event.agent_id);
639
- if (events) events.push(event);
640
- else byAgent.set(event.agent_id, [event]);
641
- }
642
- for (const events of byAgent.values()) {
643
- events.sort((left, right) => left.seq - right.seq);
644
- const newest = events.at(-1);
645
- if (newest && newest.state === "working" && !newest.synthetic && foldAgent(events, isAlive).state === "crashed") {
646
- const { host_id: _hostId, seq: _seq, ts: _ts, ...event } = newest;
647
- store.append({ ...event, state: "crashed", synthetic: true, reason: "pid_gone" });
648
- }
849
+ function parseAttention(value, path) {
850
+ if (!Array.isArray(value)) fail(path, "expected an array");
851
+ const seen = /* @__PURE__ */ new Set();
852
+ return value.map((entry, index) => {
853
+ const at = `${path}[${index}]`;
854
+ const row = object(entry, at, ATTENTION_KEYS);
855
+ const kind = member(row.kind, `${at}.kind`, KINDS);
856
+ if (seen.has(kind)) fail(`${at}.kind`, `duplicate kind ${kind} for this pane`);
857
+ seen.add(kind);
858
+ return {
859
+ kind,
860
+ message: anyText(row.message, `${at}.message`),
861
+ source: anyText(row.source, `${at}.source`),
862
+ requested_at: timestamp(row.requested_at, `${at}.requested_at`)
863
+ };
864
+ });
865
+ }
866
+ function parsePane(value, path) {
867
+ const row = object(value, path, PANE_KEYS);
868
+ const agent = parseAgent(row.agent, `${path}.agent`);
869
+ const attention = parseAttention(row.attention, `${path}.attention`);
870
+ if (agent === null && attention.length === 0) {
871
+ fail(path, "a pane with no agent and no attention must not be emitted");
649
872
  }
873
+ return {
874
+ pane: asPaneId(text(row.pane, `${path}.pane`)),
875
+ session: asSessionId(text(row.session, `${path}.session`)),
876
+ window: asWindowId(text(row.window, `${path}.window`)),
877
+ session_name: textOrNull(row.session_name, `${path}.session_name`),
878
+ window_name: textOrNull(row.window_name, `${path}.window_name`),
879
+ agent,
880
+ attention
881
+ };
650
882
  }
651
- function clearDeadWindows(store, hostId, live) {
652
- if (live === null) return;
653
- const newest = /* @__PURE__ */ new Map();
654
- for (const event of store.allEvents()) {
655
- if (event.host_id !== hostId) continue;
656
- const previous = newest.get(event.agent_id);
657
- if (!previous || event.seq > previous.seq) newest.set(event.agent_id, event);
883
+ function parseSnapshot(input) {
884
+ let parsed;
885
+ try {
886
+ parsed = JSON.parse(input);
887
+ } catch (error) {
888
+ fail("", `not JSON (${error instanceof Error ? error.message : String(error)})`);
658
889
  }
659
- for (const event of newest.values()) {
660
- if (event.state === "cleared") continue;
661
- if (live.has(event.window)) continue;
662
- const { host_id: _hostId, seq: _seq, ts: _ts, ...rest } = event;
663
- store.append({
664
- ...rest,
665
- state: "cleared",
666
- synthetic: true,
667
- reason: "window_gone",
668
- message: ""
669
- });
890
+ const top = object(parsed, "", TOP_KEYS);
891
+ if (top.murmur_snapshot !== 1) {
892
+ fail("murmur_snapshot", `expected 1, got ${JSON.stringify(top.murmur_snapshot)}`);
670
893
  }
671
- }
672
- function exportJsonl(store, since, isAlive, live) {
673
- const identity = ensureIdentity();
674
- synthesizeCrashes(store, identity.host_id, isAlive);
675
- if (live !== void 0) clearDeadWindows(store, identity.host_id, live);
676
- const envelope = {
677
- schema_version: SCHEMA_VERSION,
678
- host_id: identity.host_id,
679
- display_name: identity.display_name,
680
- exported_at: Date.now()
894
+ if (!Array.isArray(top.panes)) fail("panes", "expected an array");
895
+ const panes = top.panes.map((entry, index) => parsePane(entry, `panes[${index}]`));
896
+ const seen = /* @__PURE__ */ new Set();
897
+ for (const pane of panes) {
898
+ if (seen.has(pane.pane)) fail("panes", `duplicate pane ${pane.pane}`);
899
+ seen.add(pane.pane);
900
+ }
901
+ return {
902
+ murmur_snapshot: 1,
903
+ host_id: text(top.host_id, "host_id"),
904
+ display_name: text(top.display_name, "display_name"),
905
+ murmur_version: text(top.murmur_version, "murmur_version"),
906
+ generated_at: timestamp(top.generated_at, "generated_at"),
907
+ panes
681
908
  };
682
- const lines = [
683
- JSON.stringify(envelope),
684
- ...store.eventsSince(identity.host_id, since).map((event) => JSON.stringify(eventToWire(event)))
685
- ];
686
- return `${lines.join("\n")}
687
- `;
688
909
  }
689
910
 
690
911
  // src/collector.ts
691
- var STALENESS_MS = 6e4;
692
912
  var MAX_CONCURRENT_PEERS = 8;
693
913
  var COLLECT_DEADLINE_MS = 4e3;
694
914
  async function mapSettled(items, limit, task, deadline) {
@@ -712,18 +932,28 @@ async function mapSettled(items, limit, task, deadline) {
712
932
  await (stop ? Promise.race([pool, stop]) : pool);
713
933
  return results;
714
934
  }
715
- function parseJsonl(output) {
716
- const lines = output.trim().split("\n");
717
- const envelope = JSON.parse(lines.shift() ?? "");
718
- if (envelope.schema_version > SCHEMA_VERSION) {
719
- throw new Error(
720
- `unsupported schema version ${envelope.schema_version} (supports ${SCHEMA_VERSION})`
721
- );
935
+ function isUnreachable(message) {
936
+ 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(
937
+ message
938
+ ) || /\bssh:/.test(message);
939
+ }
940
+ function stripInvocation(message) {
941
+ const firstLine = message.indexOf("\n");
942
+ if (firstLine === -1 || !message.startsWith("Command failed:")) return message;
943
+ const rest = message.slice(firstLine + 1).trim();
944
+ return rest === "" ? message : rest;
945
+ }
946
+ function normalizeFailure(message) {
947
+ return stripInvocation(message).replace(/\s+/g, " ").trim();
948
+ }
949
+ function describeFailure(peer, message) {
950
+ const collapsed = normalizeFailure(message);
951
+ if (isUnreachable(collapsed)) {
952
+ const reason = /ssh: (?:connect to host \S+ port \d+: )?(.+?)(?: \(|$)/i.exec(collapsed);
953
+ return `${peer}: unreachable (${(reason?.[1] ?? "ssh failed").trim()})`;
722
954
  }
723
- return {
724
- envelope,
725
- events: lines.map((line) => eventFromWire(JSON.parse(line)))
726
- };
955
+ const detail = collapsed.length > 160 ? `${collapsed.slice(0, 157)}...` : collapsed;
956
+ return `${peer}: ${detail}`;
727
957
  }
728
958
  async function collect(store, channel, now = Date.now(), deadline) {
729
959
  const results = [];
@@ -737,9 +967,7 @@ async function collect(store, channel, now = Date.now(), deadline) {
737
967
  const fetches = await mapSettled(
738
968
  peers,
739
969
  MAX_CONCURRENT_PEERS,
740
- async (peer) => parseJsonl(
741
- await channel.exec(peer.target, ["murmur", "export", "--since", String(peer.watermark)])
742
- ),
970
+ async (peer) => parseSnapshot(await channel.exec(peer.target, ["murmur", "export"])),
743
971
  bounded
744
972
  );
745
973
  for (const [index, peer] of peers.entries()) {
@@ -747,68 +975,98 @@ async function collect(store, channel, now = Date.now(), deadline) {
747
975
  try {
748
976
  if (!fetch) throw new Error("collect deadline passed before this peer answered");
749
977
  if (fetch.status === "rejected") throw fetch.reason;
750
- const { envelope, events } = fetch.value;
751
- const ingested = store.ingest(events);
752
- const origin = events.filter((event) => event.host_id === envelope.host_id);
753
- const watermark = origin.reduce(
754
- (highest, event) => Math.max(highest, event.seq),
755
- peer.watermark
756
- );
757
- store.upsertPeer({
758
- name: peer.name,
759
- target: peer.target,
760
- host_id: envelope.host_id,
761
- display_name: envelope.display_name,
762
- watermark,
763
- fetched_at: now,
764
- // New events mean the node is authoring again, so whatever a jump
765
- // observed about its tmux is out of date. Only clear on actual new
766
- // events: an export that returns nothing proves the binary ran, not
767
- // that tmux is back, which is the distinction that let a dead host
768
- // look healthy for three hours.
769
- //
770
- // Keyed on the watermark advancing, not on ingest's insert count.
771
- // Two reasons the count was wrong. Ingest is INSERT OR IGNORE, so a
772
- // retry after a partial apply re-sees the same events and reports
773
- // zero -- leaving a recovered host marked down until it happened to
774
- // author again. And the count includes rows from other origins that
775
- // this peer merely relayed, which say nothing about whether this
776
- // peer's tmux is back.
777
- tmux_down_at: watermark > peer.watermark ? null : peer.tmux_down_at
778
- });
779
- results.push({ peer: peer.name, ok: true, ingested });
978
+ store.replacePeerSnapshot(peer.name, { ok: true, snapshot: fetch.value, at: now });
979
+ results.push({ peer: peer.name, ok: true, panes: fetch.value.panes.length });
780
980
  } catch (error) {
781
- const message = error instanceof Error ? error.message : String(error);
782
- process.stderr.write(`murmur: collect: peer ${peer.name}: ${message}
783
- `);
784
- results.push({ peer: peer.name, ok: false, ingested: 0, error: message });
981
+ const message = normalizeFailure(error instanceof Error ? error.message : String(error));
982
+ store.replacePeerSnapshot(peer.name, { ok: false, error: message, at: now });
983
+ results.push({
984
+ peer: peer.name,
985
+ ok: false,
986
+ panes: 0,
987
+ error: message,
988
+ // A peer that answered with a bad document is reachable but broken,
989
+ // and must be visibly so rather than silently stale.
990
+ unreachable: error instanceof SnapshotInvalidError ? false : isUnreachable(normalizeFailure(message))
991
+ });
785
992
  }
786
993
  }
787
994
  } catch (error) {
788
- process.stderr.write(
789
- `murmur: collect: ${error instanceof Error ? error.message : String(error)}
790
- `
791
- );
995
+ results.push({
996
+ peer: "",
997
+ ok: false,
998
+ panes: 0,
999
+ error: error instanceof Error ? error.message : String(error)
1000
+ });
792
1001
  } finally {
793
1002
  clearTimeout(timer);
794
1003
  }
795
1004
  try {
796
- store.prune();
797
- } catch (error) {
798
- process.stderr.write(
799
- `murmur: collect: prune: ${error instanceof Error ? error.message : String(error)}
800
- `
801
- );
1005
+ store.reconcileLocal({ panes: tmux.livePanes(), now });
1006
+ } catch {
802
1007
  }
803
1008
  return results;
804
1009
  }
805
1010
 
1011
+ // src/identity.ts
1012
+ import { randomUUID as randomUUID2 } from "crypto";
1013
+ import { existsSync, mkdirSync as mkdirSync2, readFileSync, writeFileSync } from "fs";
1014
+ import { hostname } from "os";
1015
+ import { join as join2 } from "path";
1016
+ function identityPath() {
1017
+ return join2(stateDir(), "identity.json");
1018
+ }
1019
+ var cache = null;
1020
+ function loadIdentity() {
1021
+ const path = identityPath();
1022
+ if (cache?.path === path) return cache.identity;
1023
+ const identity = existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) : null;
1024
+ cache = { path, identity };
1025
+ return identity;
1026
+ }
1027
+ function write(identity) {
1028
+ mkdirSync2(stateDir(), { recursive: true });
1029
+ writeFileSync(identityPath(), `${JSON.stringify(identity, null, 2)}
1030
+ `);
1031
+ cache = { path: identityPath(), identity };
1032
+ return identity;
1033
+ }
1034
+ function createIdentity(displayName = hostname()) {
1035
+ if (loadIdentity()) throw new Error(`identity already exists: ${identityPath()}`);
1036
+ return write({ host_id: randomUUID2(), display_name: displayName });
1037
+ }
1038
+ function setDisplayName(displayName) {
1039
+ const existing = loadIdentity();
1040
+ return write(
1041
+ existing ? { host_id: existing.host_id, display_name: displayName } : { host_id: randomUUID2(), display_name: displayName }
1042
+ );
1043
+ }
1044
+
1045
+ // src/cli/identity-guard.ts
1046
+ function requireIdentity() {
1047
+ const identity = loadIdentity();
1048
+ if (identity) return identity;
1049
+ process.stderr.write("murmur is not initialised on this node; run: murmur init\n");
1050
+ process.exitCode = 1;
1051
+ return null;
1052
+ }
1053
+
806
1054
  // src/cli/collect.ts
807
1055
  function registerCollect(program2) {
808
- program2.command("collect").description("Collect events from configured peers").action(async () => {
1056
+ program2.command("collect").description("Fetch each peer's snapshot").option("-q, --quiet", "report nothing, not even unreachable peers").action(async (options) => {
1057
+ if (!requireIdentity()) return;
809
1058
  const store = openStore();
810
1059
  try {
811
- await collect(store, ssh);
1060
+ const results = await collect(store, ssh);
1061
+ if (options.quiet) return;
1062
+ for (const result of results) {
1063
+ if (result.ok || !result.error) continue;
1064
+ process.stderr.write(`murmur: ${describeFailure(result.peer, result.error)}
1065
+ `);
1066
+ }
1067
+ if (results.some((result) => !result.ok && !result.unreachable)) {
1068
+ process.exitCode = 1;
1069
+ }
812
1070
  } finally {
813
1071
  store.close();
814
1072
  }
@@ -817,10 +1075,14 @@ function registerCollect(program2) {
817
1075
 
818
1076
  // src/cli/export.ts
819
1077
  function registerExport(program2) {
820
- program2.command("export").description("Export local events as JSONL").requiredOption("--since <seq>", "export events after this sequence", Number).action((options) => {
1078
+ program2.command("export").description("Print this node's current-state snapshot").action(() => {
1079
+ const identity = requireIdentity();
1080
+ if (!identity) return;
821
1081
  const store = openStore();
822
1082
  try {
823
- process.stdout.write(exportJsonl(store, options.since, pidAlive, tmux.liveWindows()));
1083
+ const snapshot = store.buildLocalSnapshot(identity, { panes: tmux.livePanes() });
1084
+ process.stdout.write(`${JSON.stringify(snapshot)}
1085
+ `);
824
1086
  } finally {
825
1087
  store.close();
826
1088
  }
@@ -830,19 +1092,46 @@ function registerExport(program2) {
830
1092
  // src/cli/init.ts
831
1093
  function registerInit(program2) {
832
1094
  program2.command("init").description("Initialize this node's identity").option("--name <name>", "display name").action((opts) => {
833
- const identity = ensureIdentity(opts.name);
1095
+ const existing = loadIdentity();
1096
+ const identity = existing ? opts.name ? setDisplayName(opts.name) : existing : createIdentity(opts.name);
834
1097
  console.log(`host_id: ${identity.host_id}`);
835
1098
  console.log(`display_name: ${identity.display_name}`);
836
1099
  });
837
1100
  }
838
1101
 
839
1102
  // src/cli/link.ts
840
- import { mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
1103
+ import { mkdirSync as mkdirSync3, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
841
1104
  import { homedir as homedir2 } from "os";
842
- import { dirname, join as join3 } from "path";
1105
+ import { dirname as dirname2, join as join3 } from "path";
843
1106
  import { fileURLToPath } from "url";
1107
+ var SHIM_MARKER = "// murmur:shim";
1108
+ function shim(entry, storePath) {
1109
+ return `${SHIM_MARKER}
1110
+ // Generated by \`murmur link pi\`. Do not edit.
1111
+ //
1112
+ // A re-export, not a copy: the extension code lives in the murmur install, so
1113
+ // upgrading murmur upgrades the extension with no reinstall step. Re-run
1114
+ // \`murmur link pi\` only if the install path itself moves.
1115
+ //
1116
+ // The store path is set here rather than resolved by the extension. A bare
1117
+ // specifier cannot resolve from ~/.pi/agent/extensions, and the failure is
1118
+ // silent: the import throws, the extension swallows it, and every state report
1119
+ // is dropped while the tmux badge still paints.
1120
+ //
1121
+ // A dynamic import, not \`export ... from\`: ESM hoists static re-exports above
1122
+ // this assignment, so the extension loaded before the variable was set and read
1123
+ // undefined. Verified -- the static form printed \`undefined\` in the target.
1124
+ process.env.MURMUR_STORE_MODULE ??= ${JSON.stringify(storePath)};
1125
+
1126
+ const { default: extension } = await import(${JSON.stringify(entry)});
1127
+ export default extension;
1128
+ `;
1129
+ }
844
1130
  function registerLink(program2) {
845
- program2.command("link").description("Install a murmur integration").argument("<target>", "integration to install").action((target) => {
1131
+ program2.command("link").description("Install a murmur integration").argument("<target>", "integration to install").option(
1132
+ "--copy",
1133
+ "inline the extension instead of re-exporting it (pins to this version; needs re-linking after an upgrade)"
1134
+ ).action((target, options) => {
846
1135
  if (target !== "pi") throw new Error(`unsupported link target: ${target}`);
847
1136
  const destination = join3(
848
1137
  process.env.MURMUR_PI_HOME ?? homedir2(),
@@ -851,12 +1140,32 @@ function registerLink(program2) {
851
1140
  "extensions",
852
1141
  "murmur.ts"
853
1142
  );
854
- mkdirSync2(dirname(destination), { recursive: true });
855
- const source = readFileSync2(
856
- fileURLToPath(new URL("./extension/murmur-pi.js", import.meta.url)),
857
- "utf8"
858
- );
1143
+ mkdirSync3(dirname2(destination), { recursive: true });
1144
+ const entry = fileURLToPath(new URL("./extension/murmur-pi.js", import.meta.url));
859
1145
  const storePath = fileURLToPath(new URL("./extension/store.js", import.meta.url));
1146
+ const identityMissing = loadIdentity() === null;
1147
+ if (!options.copy) {
1148
+ let replacedCopy = false;
1149
+ try {
1150
+ const existing = readFileSync2(destination, "utf8");
1151
+ replacedCopy = !existing.includes(SHIM_MARKER);
1152
+ } catch {
1153
+ }
1154
+ writeFileSync2(destination, shim(entry, storePath));
1155
+ console.log(destination);
1156
+ if (replacedCopy) {
1157
+ console.log(
1158
+ "Replaced an inlined copy from an older murmur. That copy was pinned to the version that wrote it, so it had stopped picking up fixes; running agents keep the old code until they restart."
1159
+ );
1160
+ }
1161
+ if (identityMissing) {
1162
+ console.log(
1163
+ "This node has no identity yet, so the extension will record nothing. Run: murmur init"
1164
+ );
1165
+ }
1166
+ return;
1167
+ }
1168
+ const source = readFileSync2(entry, "utf8");
860
1169
  const pinned = source.replace(
861
1170
  /"@martintrojer\/murmur\/extension-store"/,
862
1171
  JSON.stringify(storePath)
@@ -866,6 +1175,107 @@ function registerLink(program2) {
866
1175
  }
867
1176
  writeFileSync2(destination, pinned);
868
1177
  console.log(destination);
1178
+ if (identityMissing) {
1179
+ console.log(
1180
+ "This node has no identity yet, so the extension will record nothing. Run: murmur init"
1181
+ );
1182
+ }
1183
+ });
1184
+ }
1185
+
1186
+ // src/cli/notify.ts
1187
+ function notifyFields(input, payload = {}) {
1188
+ const field = (key, flag) => {
1189
+ if (flag) return clean(flag);
1190
+ const value = payload[key];
1191
+ return typeof value === "string" ? clean(value) : "";
1192
+ };
1193
+ const source = field("source", input.source) || "agent";
1194
+ const title = field("title", input.title);
1195
+ const eventType = field("type", input.eventType);
1196
+ const message = field("message", input.message) || title || eventType || "attention";
1197
+ return { source, message };
1198
+ }
1199
+ function clean(value) {
1200
+ const flattened = [...value].map((character) => {
1201
+ const code = character.charCodeAt(0);
1202
+ const control = code < 32 || code === 127 || code >= 128 && code <= 159;
1203
+ return control ? " " : character;
1204
+ }).join("");
1205
+ return flattened.replace(/\s+/g, " ").trim();
1206
+ }
1207
+ function parsePayload(raw) {
1208
+ if (!raw.trim()) return {};
1209
+ try {
1210
+ const parsed = JSON.parse(raw);
1211
+ return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed : {};
1212
+ } catch {
1213
+ return {};
1214
+ }
1215
+ }
1216
+ function runNotify(store, input, payload = {}, mux = tmux) {
1217
+ const location = resolveLocation(input.pane, mux);
1218
+ if (!location) return false;
1219
+ const { source, message } = notifyFields(input, payload);
1220
+ store.requestAttention({
1221
+ kind: "blocked",
1222
+ location,
1223
+ message,
1224
+ // The harness name goes here, not in `driver`. `driver` answers "who is
1225
+ // waiting on this agent" -- a human, or a supervisor consuming the result --
1226
+ // and a codex agent driven by a human is `human` on exactly that question.
1227
+ // `source` answers "who asked", which is the free-text field a new harness
1228
+ // needs no schema change for.
1229
+ source
1230
+ });
1231
+ mux.setWindowBadge(location.window, "blocked");
1232
+ return true;
1233
+ }
1234
+ function resolveLocation(pane, mux) {
1235
+ const here = mux.currentWindow();
1236
+ if (!pane) return here;
1237
+ const target = asPaneId(pane);
1238
+ if (here && here.pane === target) return here;
1239
+ if (here && mux.panesInWindow(here.window).includes(target)) {
1240
+ return { ...here, pane: target };
1241
+ }
1242
+ return null;
1243
+ }
1244
+ function registerNotify(program2) {
1245
+ program2.command("notify").description("Record an attention request for a harness that cannot report itself").option("--source <name>", "harness name, e.g. codex or opencode").option("--event-type <type>", "why attention is wanted").option("--title <title>", "harness display title").option("--message <message>", "the text to show").option("--pane <pane>", "pane to notify about (default: $TMUX_PANE)").action(
1246
+ async (options) => {
1247
+ const payload = parsePayload(await readStdin());
1248
+ const store = openStore();
1249
+ try {
1250
+ runNotify(store, options, payload);
1251
+ } finally {
1252
+ store.close();
1253
+ }
1254
+ }
1255
+ );
1256
+ }
1257
+ var STDIN_DEADLINE_MS = 250;
1258
+ async function readStdin() {
1259
+ if (process.stdin.isTTY) return "";
1260
+ const chunks = [];
1261
+ return new Promise((resolve) => {
1262
+ const onData = (chunk) => chunks.push(chunk);
1263
+ const done = () => {
1264
+ process.stdin.off("data", onData);
1265
+ process.stdin.unref?.();
1266
+ resolve(Buffer.concat(chunks).toString("utf8"));
1267
+ };
1268
+ const timer = setTimeout(done, STDIN_DEADLINE_MS);
1269
+ timer.unref?.();
1270
+ process.stdin.on("data", onData);
1271
+ process.stdin.once("end", () => {
1272
+ clearTimeout(timer);
1273
+ done();
1274
+ });
1275
+ process.stdin.once("error", () => {
1276
+ clearTimeout(timer);
1277
+ done();
1278
+ });
869
1279
  });
870
1280
  }
871
1281
 
@@ -873,6 +1283,7 @@ function registerLink(program2) {
873
1283
  import { readFileSync as readFileSync3 } from "fs";
874
1284
  import { homedir as homedir3 } from "os";
875
1285
  import { join as join4 } from "path";
1286
+ var SNAPSHOT_VERSION = 1;
876
1287
  function parseSshHosts(config) {
877
1288
  const hosts = [];
878
1289
  for (const line of config.split("\n")) {
@@ -891,6 +1302,22 @@ function sshHosts() {
891
1302
  return [];
892
1303
  }
893
1304
  }
1305
+ function lastSeen(fetchedAt, now) {
1306
+ if (fetchedAt === null) return "never";
1307
+ if (freshness(fetchedAt, now, STALENESS_MS) === "fresh") return "just now";
1308
+ return `${age(now - fetchedAt)} ago`;
1309
+ }
1310
+ function versionCell(peer, ours = SNAPSHOT_VERSION) {
1311
+ if (peer.murmur_version === null && peer.snapshot_version === null) {
1312
+ return { text: "unknown", incompatible: false };
1313
+ }
1314
+ const version = peer.murmur_version ?? "unreported";
1315
+ const incompatible = peer.snapshot_version !== null && peer.snapshot_version !== ours;
1316
+ return {
1317
+ text: incompatible ? `${version} (snapshot ${peer.snapshot_version} \u2260 ${ours})` : version,
1318
+ incompatible
1319
+ };
1320
+ }
894
1321
  function formatTable(rows) {
895
1322
  const widths = [];
896
1323
  for (const row of rows) {
@@ -904,17 +1331,17 @@ function formatTable(rows) {
904
1331
  `).join("");
905
1332
  }
906
1333
  function peerAddDecision(input) {
907
- const { name, target, envelope, selfHostId, peers } = input;
908
- if (!envelope) return null;
909
- if (envelope.host_id === selfHostId) {
1334
+ const { name, target, snapshot, selfHostId, peers } = input;
1335
+ if (!snapshot) return null;
1336
+ if (snapshot.host_id === selfHostId) {
910
1337
  return `${target} is this node; not adding it as a peer
911
1338
  `;
912
1339
  }
913
1340
  const existing = peers.find(
914
- (candidate) => candidate.host_id === envelope.host_id && candidate.name !== name
1341
+ (candidate) => candidate.host_id === snapshot.host_id && candidate.name !== name
915
1342
  );
916
1343
  if (existing) {
917
- return `${target} is already configured as peer "${existing.name}" (${envelope.display_name}); remove it first to rename
1344
+ return `${target} is already configured as peer "${existing.name}" (${snapshot.display_name}); remove it first to rename
918
1345
  `;
919
1346
  }
920
1347
  return null;
@@ -924,17 +1351,16 @@ function registerPeer(program2) {
924
1351
  peer.command("add").description("Add a peer and discover its identity").argument("<name>").argument("[target]").action(async (name, target = name) => {
925
1352
  const store = openStore();
926
1353
  try {
927
- let envelope = null;
1354
+ let snapshot = null;
928
1355
  try {
929
- const output = await ssh.exec(target, ["murmur", "export", "--since", "0"]);
930
- envelope = JSON.parse(output.trim().split("\n")[0] ?? "");
1356
+ snapshot = parseSnapshot(await ssh.exec(target, ["murmur", "export"]));
931
1357
  } catch {
932
- envelope = null;
1358
+ snapshot = null;
933
1359
  }
934
1360
  const refusal = peerAddDecision({
935
1361
  name,
936
1362
  target,
937
- envelope,
1363
+ snapshot,
938
1364
  selfHostId: loadIdentity()?.host_id ?? null,
939
1365
  peers: store.peers()
940
1366
  });
@@ -943,14 +1369,12 @@ function registerPeer(program2) {
943
1369
  process.exitCode = 1;
944
1370
  return;
945
1371
  }
946
- store.upsertPeer({
947
- name,
948
- target,
949
- host_id: envelope?.host_id ?? null,
950
- display_name: envelope?.display_name ?? null
951
- });
1372
+ store.addPeer(name, target);
1373
+ if (snapshot) {
1374
+ store.replacePeerSnapshot(name, { ok: true, snapshot, at: Date.now() });
1375
+ }
952
1376
  process.stdout.write(
953
- envelope ? `Added ${name} (${envelope.display_name})
1377
+ snapshot ? `Added ${name} (${snapshot.display_name})
954
1378
  ` : `Added ${name} (identity pending)
955
1379
  `
956
1380
  );
@@ -972,40 +1396,111 @@ function registerPeer(program2) {
972
1396
  store.close();
973
1397
  }
974
1398
  });
975
- peer.command("list").description("List configured peers").option("--json", "print JSON").action((options) => {
1399
+ peer.command("list").description("List peers; --all adds SSH hosts that could become peers").option("--json", "print JSON").option("-a, --all", "also show SSH hosts that are not peers yet").action((options) => {
976
1400
  const store = openStore();
977
1401
  try {
978
1402
  const peers = store.peers();
1403
+ const configured = new Map(peers.map((entry) => [entry.target, entry]));
1404
+ const discovered = options.all ? sshHosts().filter((host) => !configured.has(host)) : [];
1405
+ const now = Date.now();
1406
+ const rows = [...configured.keys(), ...discovered].map((target) => {
1407
+ const entry = configured.get(target);
1408
+ return {
1409
+ // The handle other commands take: a peer's name, or for a host that
1410
+ // is not one yet, the ssh host `peer add` wants.
1411
+ name: entry?.name ?? target,
1412
+ target,
1413
+ peer: entry !== void 0,
1414
+ // What the node called itself. Shown, never typed: it can be a
1415
+ // container id.
1416
+ hostname: entry?.display_name ?? null,
1417
+ // Being a peer is not the same as being reachable, and the old
1418
+ // output said only the first. A node asleep for twelve hours read
1419
+ // exactly like one polled a second ago.
1420
+ last_seen: entry === void 0 ? null : lastSeen(entry.fetched_at, now),
1421
+ // A warm ControlMaster socket makes a collect ~10ms instead of
1422
+ // ~170ms, and is the only path that works on a host demanding a
1423
+ // hardware-token touch per connection. A speed hint, never a
1424
+ // requirement -- which is why the old bare `[x]` / `[ ]` was
1425
+ // unreadable: it never said what was being checked.
1426
+ //
1427
+ // Safe for every row: `ssh -O check` talks to a local socket and
1428
+ // never dials, so a host that is down or does not exist answers in
1429
+ // ~16ms. Measured.
1430
+ ssh: hasWarmSocket(target) ? "warm" : "cold",
1431
+ // What it is running, or undefined when nothing is known -- either
1432
+ // because the host is not a peer yet, or because it is a peer that
1433
+ // has never answered. Undefined is what drops the column, so the
1434
+ // test is "has anything told us", not "is this configured": a fleet
1435
+ // of asleep peers must not buy a column of "unknown".
1436
+ version: entry === void 0 || entry.murmur_version === null && entry.snapshot_version === null ? void 0 : versionCell(entry),
1437
+ // Named where it can be acted on: a peer that answered with a bad
1438
+ // document is reachable but broken, which is an operator task and
1439
+ // reads nothing like a sleeping laptop.
1440
+ error: entry?.last_error ?? null
1441
+ };
1442
+ });
979
1443
  if (options.json) {
980
- process.stdout.write(`${JSON.stringify(peers)}
1444
+ process.stdout.write(`${JSON.stringify(rows)}
981
1445
  `);
982
1446
  return;
983
1447
  }
984
- if (peers.length === 0) {
985
- process.stdout.write("no peers configured\n");
1448
+ if (rows.length === 0) {
1449
+ process.stdout.write(
1450
+ options.all ? "no peers configured, and no hosts in ~/.ssh/config\n" : "no peers configured. See what could be added with: murmur peer list --all\n"
1451
+ );
986
1452
  return;
987
1453
  }
988
- const rows = [
989
- // HOSTNAME, not HOST: this is what the node reported about itself,
990
- // which is not the handle any other command takes. NAME is.
991
- ["NAME", "TARGET", "HOSTNAME"],
992
- ...peers.map((configured) => [
993
- configured.name,
994
- configured.target,
995
- configured.display_name ?? "unknown"
1454
+ const showPeerColumn = rows.some((row) => !row.peer);
1455
+ const showVersionColumn = rows.some((row) => row.version !== void 0);
1456
+ process.stdout.write(
1457
+ formatTable([
1458
+ [
1459
+ "NAME",
1460
+ "TARGET",
1461
+ ...showPeerColumn ? ["PEER"] : [],
1462
+ "HOSTNAME",
1463
+ ...showVersionColumn ? ["VERSION"] : [],
1464
+ "LAST SEEN",
1465
+ "SSH"
1466
+ ],
1467
+ ...rows.map((row) => [
1468
+ row.name,
1469
+ row.target,
1470
+ ...showPeerColumn ? [row.peer ? "yes" : "-"] : [],
1471
+ row.hostname ?? "unknown",
1472
+ ...showVersionColumn ? [row.version?.text ?? "-"] : [],
1473
+ row.last_seen ?? "-",
1474
+ row.ssh
1475
+ ])
996
1476
  ])
997
- ];
998
- process.stdout.write(formatTable(rows));
1477
+ );
1478
+ const incompatible = rows.filter((row) => row.version?.incompatible);
1479
+ if (incompatible.length > 0) {
1480
+ process.stdout.write(
1481
+ `
1482
+ ${incompatible.length} peer${incompatible.length === 1 ? "" : "s"} speak an incompatible snapshot version; state will not sync until murmur versions match: ${incompatible.map((row) => row.name).join(", ")}
1483
+ `
1484
+ );
1485
+ }
1486
+ const broken = rows.filter((row) => row.error);
1487
+ for (const row of broken) {
1488
+ process.stdout.write(`
1489
+ ${row.name}: last attempt failed -- ${row.error}
1490
+ `);
1491
+ }
1492
+ const addable = rows.filter((row) => !row.peer).length;
1493
+ if (addable > 0) {
1494
+ process.stdout.write(
1495
+ `
1496
+ ${addable} host${addable === 1 ? "" : "s"} not yet a peer. Add one with: murmur peer add <name>
1497
+ `
1498
+ );
1499
+ }
999
1500
  } finally {
1000
1501
  store.close();
1001
1502
  }
1002
1503
  });
1003
- peer.command("discover").description("Check SSH hosts for warm control sockets").action(() => {
1004
- for (const host of sshHosts()) {
1005
- process.stdout.write(`${hasWarmSocket(host) ? "[x]" : "[ ]"} ${host}
1006
- `);
1007
- }
1008
- });
1009
1504
  }
1010
1505
 
1011
1506
  // src/cli/pick.ts
@@ -1031,6 +1526,9 @@ function terminalText(value) {
1031
1526
  function shellQuote(value) {
1032
1527
  return `'${value.replaceAll("'", `'\\''`)}'`;
1033
1528
  }
1529
+ function remoteSessionName(peerName) {
1530
+ return `${peerName.replace(/^[@$%=]+/, "")}~`;
1531
+ }
1034
1532
  var spawnRunner = (file, args, inherit = false) => {
1035
1533
  const result = spawnSync(file, args, {
1036
1534
  encoding: "utf8",
@@ -1046,48 +1544,14 @@ var spawnRunner = (file, args, inherit = false) => {
1046
1544
  failed: result.error !== void 0
1047
1545
  };
1048
1546
  };
1049
- function forgetHostReplica(store, hostId) {
1050
- try {
1051
- const peer = store.peers().find((candidate) => candidate.host_id === hostId);
1052
- store.forgetHost(hostId);
1053
- if (peer) {
1054
- store.upsertPeer({
1055
- name: peer.name,
1056
- target: peer.target,
1057
- tmux_down_at: Date.now()
1058
- });
1059
- }
1060
- } catch {
1061
- }
1062
- }
1063
- function forgetReplica(store, agentId, hostId) {
1064
- try {
1065
- store.forgetAgent(agentId);
1066
- const peer = store.peers().find((candidate) => candidate.host_id === hostId);
1067
- if (peer) store.upsertPeer({ name: peer.name, target: peer.target, watermark: 0 });
1068
- } catch {
1069
- }
1070
- }
1071
- function forgetOneAgent(store, agent, mux = tmux) {
1072
- const identity = loadIdentity();
1073
- if (agent.host_id === identity?.host_id) {
1074
- try {
1075
- mux.setState(agent.window, null);
1076
- } catch {
1077
- }
1078
- }
1079
- forgetReplica(store, agent.agent_id, agent.host_id);
1080
- }
1081
1547
  function jumpToAgent(store, agent, mux = tmux, run = spawnRunner) {
1082
- const identity = loadIdentity();
1083
- if (agent.host_id === identity?.host_id) {
1084
- const live = mux.liveWindows();
1085
- if (live && !live.has(agent.window)) {
1086
- forgetReplica(store, agent.agent_id, agent.host_id);
1548
+ if (agent.local) {
1549
+ const panes = mux.livePanes();
1550
+ if (panes && !panes.has(agent.pane)) {
1087
1551
  return {
1088
1552
  ok: false,
1089
- reason: "window_gone",
1090
- message: `${agentLabel(agent)} is gone -- its window no longer exists. Cleared.`
1553
+ reason: "pane_gone",
1554
+ message: `${agentLabel(agent)} is gone -- its pane no longer exists.`
1091
1555
  };
1092
1556
  }
1093
1557
  if (!mux.attach(agent.session, agent.window)) {
@@ -1111,7 +1575,7 @@ function jumpToAgent(store, agent, mux = tmux, run = spawnRunner) {
1111
1575
  const probe = run("ssh", [
1112
1576
  ...SSH_OPTIONS,
1113
1577
  target,
1114
- `tmux list-windows -a -F ${shellQuote("#{window_id}")}`
1578
+ `tmux list-panes -a -F ${shellQuote("#{pane_id}")}`
1115
1579
  ]);
1116
1580
  if (probe.status !== 0) {
1117
1581
  const sshFailed = probe.status === 255 || probe.failed;
@@ -1122,38 +1586,48 @@ function jumpToAgent(store, agent, mux = tmux, run = spawnRunner) {
1122
1586
  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.`
1123
1587
  };
1124
1588
  }
1125
- forgetHostReplica(store, agent.host_id);
1126
1589
  return {
1127
1590
  ok: false,
1128
1591
  reason: "no_tmux",
1129
- message: `${target} has no tmux server running, so its agents are gone. Removed them; they will come back when it reports again.`
1592
+ message: `${target} has no tmux server running, so its agents are gone. They will disappear on the next collect.`
1130
1593
  };
1131
1594
  }
1132
- const remoteWindows = new Set(probe.stdout.split("\n").filter(Boolean));
1133
- if (!remoteWindows.has(agent.window)) {
1134
- forgetReplica(store, agent.agent_id, agent.host_id);
1595
+ const remotePanes = new Set(probe.stdout.split("\n").filter(Boolean).map(asPaneId));
1596
+ if (!remotePanes.has(agent.pane)) {
1135
1597
  return {
1136
1598
  ok: false,
1137
- reason: "window_gone",
1138
- message: `${agentLabel(agent)} is gone -- ${target} no longer has that window. Cleared.`
1599
+ reason: "pane_gone",
1600
+ message: `${agentLabel(agent)} is gone -- ${target} no longer has that pane.`
1139
1601
  };
1140
1602
  }
1141
1603
  const attachTarget = shellQuote(`${agent.session}:${agent.window}`);
1142
1604
  if (process.env.TMUX) {
1143
- const command = `ssh -t ${shellQuote(target)} tmux attach -t ${shellQuote(attachTarget)}`;
1144
- const name = `@${peer?.name ?? target}`;
1145
- const existing = mux.windowNamed(name);
1146
- if (existing) {
1147
- return mux.selectWindow(existing) ? { ok: true } : {
1605
+ const client = mux.clientName();
1606
+ const origin = mux.currentTarget();
1607
+ const name = remoteSessionName(peer?.name ?? target);
1608
+ if (mux.sessionNamed(name)) {
1609
+ return mux.switchClient(client, name) ? { ok: true } : {
1148
1610
  ok: false,
1149
1611
  reason: "attach_failed",
1150
- message: `could not switch to the existing ${name} window.`
1612
+ message: `could not switch to the existing ${name} session.`
1151
1613
  };
1152
1614
  }
1153
- return mux.newWindow(name, command) ? { ok: true } : {
1615
+ const attach2 = `ssh -t ${shellQuote(target)} tmux attach -t ${shellQuote(attachTarget)}`;
1616
+ const restore = origin ? `; tmux switch-client ${client ? `-c ${shellQuote(client)} ` : ""}-t ${shellQuote(`=${origin}`)}` : "";
1617
+ if (!mux.newSession(name, `${attach2}${restore}`)) {
1618
+ return {
1619
+ ok: false,
1620
+ reason: "attach_failed",
1621
+ message: `could not open a session to attach to ${target}.`
1622
+ };
1623
+ }
1624
+ mux.setSessionOption(name, "status", "off");
1625
+ mux.setSessionOption(name, "prefix", "None");
1626
+ mux.setSessionOption(name, "detach-on-destroy", "previous");
1627
+ return mux.switchClient(client, name) ? { ok: true } : {
1154
1628
  ok: false,
1155
1629
  reason: "attach_failed",
1156
- message: `could not open a window to attach to ${target}.`
1630
+ message: `attached to ${target} in session ${name}, but could not switch to it.`
1157
1631
  };
1158
1632
  }
1159
1633
  const attach = run("ssh", ["-t", target, "tmux", "attach", "-t", attachTarget], true);
@@ -1168,7 +1642,7 @@ function jumpToAgent(store, agent, mux = tmux, run = spawnRunner) {
1168
1642
  import { execFileSync as execFileSync3 } from "child_process";
1169
1643
  var GLANCE_LINES = 40;
1170
1644
  function glance(store, agent, lines = GLANCE_LINES) {
1171
- if (agent.host_id === loadIdentity()?.host_id) return tmux.capture(agent.pane, lines);
1645
+ if (agent.local) return tmux.capture(agent.pane, lines);
1172
1646
  const peer = store.peers().find((candidate) => candidate.host_id === agent.host_id);
1173
1647
  const target = peer?.target ?? peer?.name;
1174
1648
  if (!target) return null;
@@ -1195,91 +1669,60 @@ function glance(store, agent, lines = GLANCE_LINES) {
1195
1669
 
1196
1670
  // src/status.ts
1197
1671
  function emptyCounts() {
1198
- return { working: 0, blocked: 0, done: 0, crashed: 0, idle: 0 };
1672
+ const counts = {};
1673
+ for (const state of RENDER_PRIORITY) counts[state] = 0;
1674
+ return counts;
1199
1675
  }
1200
1676
  function tmuxStatus(view) {
1201
- const urgency = ["crashed", "blocked", "done", "working", "idle"];
1202
- return urgency.filter((state) => view.counts[state] > 0).map((state) => `${state} ${view.counts[state]}
1677
+ const needsHuman = new Set(NEEDS_HUMAN);
1678
+ const total = (state) => view.counts[state] + (needsHuman.has(state) ? view.orchestrated_counts[state] : 0);
1679
+ return RENDER_PRIORITY.filter((state) => total(state) > 0).map((state) => `${state === "running" ? "working" : state} ${total(state)}
1203
1680
  `).join("");
1204
1681
  }
1205
- function status(store, now = Date.now()) {
1206
- const identity = loadIdentity();
1207
- const peers = store.peers();
1208
- const peersByHost = new Map(
1209
- peers.flatMap((peer) => peer.host_id === null ? [] : [[peer.host_id, peer]])
1210
- );
1211
- const events = store.allEvents();
1212
- const local = foldAll(
1213
- events.filter((event) => event.host_id === identity?.host_id),
1214
- pidAlive
1215
- );
1216
- const remote = foldAll(
1217
- events.filter((event) => event.host_id !== identity?.host_id),
1218
- () => true
1219
- );
1682
+ function status(store, identity, now = Date.now()) {
1220
1683
  const counts = emptyCounts();
1221
1684
  const orchestratedCounts = emptyCounts();
1222
- const agents = attentionSort([...local, ...remote]).map((agent) => {
1223
- const peer = peersByHost.get(agent.host_id);
1224
- const fetchedAt = peer?.fetched_at ?? null;
1225
- const state = agent.state === null || agent.state === "cleared" ? "idle" : agent.state;
1226
- const target = agent.driver === "human" ? counts : orchestratedCounts;
1227
- target[state] += 1;
1228
- return {
1229
- ...agent,
1230
- fetched_at: fetchedAt,
1231
- // Replica freshness: how long since we last reached the peer. Local rows
1232
- // have no fetched_at and are never stale.
1233
- stale: isStale(fetchedAt, now, STALENESS_MS),
1234
- age_ms: fetchedAt === null ? null : now - fetchedAt,
1235
- // Information age: how long since the agent itself said anything. This
1236
- // is the number a human means by "how stale is that row". A successful
1237
- // fetch of a three-hour-old event resets age_ms to zero but leaves this
1238
- // at three hours, which is why they cannot be the same field.
1239
- event_age_ms: agent.event === null ? null : Math.max(0, now - agent.event.ts),
1240
- // A jump proved this host's tmux was down and nothing has authored since.
1241
- // Stronger than staleness: the host answers, its agents are just gone.
1242
- tmux_down: peer?.tmux_down_at != null,
1243
- // The name the human typed, not the machine's self-reported hostname. A
1244
- // peer added as `linuxpc` reported `18c04d69b860` (a container hostname)
1245
- // and that is what the picker showed — a string that appears nowhere
1246
- // else in the tool and cannot be typed at `peer remove` or searched for.
1247
- // Only the local node, which has no peer row, falls back to its own
1248
- // discovered display_name.
1249
- host: peer?.name ?? (agent.host_id === identity?.host_id ? identity.display_name : agent.host_id)
1250
- };
1251
- });
1685
+ const panes = viewSort(paneViews(store, identity, now));
1686
+ for (const pane of panes) {
1687
+ const target = pane.driver === "human" ? counts : orchestratedCounts;
1688
+ target[renderState(pane)] += 1;
1689
+ }
1252
1690
  return {
1253
1691
  counts,
1254
1692
  orchestrated_counts: orchestratedCounts,
1255
- agents,
1256
- peers: peers.map((peer) => ({
1693
+ panes,
1694
+ peers: store.peers().map((peer) => ({
1257
1695
  name: peer.name,
1258
1696
  display_name: peer.display_name,
1259
1697
  fetched_at: peer.fetched_at,
1260
- // A peer we have never reached is stale, not fresh. `isStale` reads a
1261
- // null `fetched_at` as "local, therefore never stale", which is right
1262
- // for an agent row but backwards for a peer: null there means the very
1263
- // first collect has not succeeded yet. Left to `isStale`, an
1264
- // unreachable host you just added would render as up to date.
1265
- stale: peer.fetched_at === null || isStale(peer.fetched_at, now, STALENESS_MS)
1698
+ // Their clock and ours, separately: a peer polled a second ago can be
1699
+ // serving a three-hour-old fact, and one number cannot say both.
1700
+ snapshot_at: peer.snapshot_at,
1701
+ last_error: peer.last_error,
1702
+ // The view's verdict, not a second threshold spelled the same way. A
1703
+ // peer we have never reached is stale rather than fresh -- null
1704
+ // `fetched_at` means the first collect has not succeeded yet -- and
1705
+ // `freshness` is the one place that decides, so this list and the panes
1706
+ // the peer contributes cannot disagree about the same host.
1707
+ stale: freshness(peer.fetched_at, now) === "stale"
1266
1708
  }))
1267
1709
  };
1268
1710
  }
1269
- async function statusWithCollect(store, now = Date.now(), channel = ssh) {
1711
+ async function statusWithCollect(store, identity, now = Date.now(), channel = ssh) {
1270
1712
  try {
1271
1713
  await collect(store, channel, now);
1272
- } catch (error) {
1273
- process.stderr.write(
1274
- `murmur: status: collect: ${error instanceof Error ? error.message : String(error)}
1275
- `
1276
- );
1714
+ } catch {
1277
1715
  }
1278
- return status(store, now);
1716
+ return status(store, identity, now);
1279
1717
  }
1280
1718
 
1281
1719
  // src/cli/pick.ts
1282
- var PREVIEW_EVENTS = 8;
1720
+ var spawnFzf = (args, input, env) => spawnSync2("fzf", args, {
1721
+ input,
1722
+ encoding: "utf8",
1723
+ stdio: ["pipe", "pipe", "inherit"],
1724
+ env
1725
+ }).stdout ?? "";
1283
1726
  var PREVIEW_MESSAGE_MAX = 300;
1284
1727
  var GLYPH = {
1285
1728
  crashed: "\u2717",
@@ -1287,7 +1730,7 @@ var GLYPH = {
1287
1730
  blocked: "!",
1288
1731
  done: "\u2713",
1289
1732
  // ✓
1290
- working: "\u25B6",
1733
+ running: "\u25B6",
1291
1734
  // ▶
1292
1735
  idle: "\xB7"
1293
1736
  // ·
@@ -1296,7 +1739,7 @@ var COLOUR = {
1296
1739
  crashed: "\x1B[31m",
1297
1740
  blocked: "\x1B[33m",
1298
1741
  done: "\x1B[36m",
1299
- working: "\x1B[37m",
1742
+ running: "\x1B[37m",
1300
1743
  idle: "\x1B[90m"
1301
1744
  };
1302
1745
  var ANSI_PATTERN = `${String.fromCharCode(27)}\\[[0-9;]*m`;
@@ -1307,7 +1750,10 @@ var REMOTE = "\x1B[36m";
1307
1750
  var BOLD = "\x1B[1m";
1308
1751
  var DIM = "\x1B[2m";
1309
1752
  var RESET = "\x1B[0m";
1310
- var URGENCY = ["crashed", "blocked", "done", "working", "idle"];
1753
+ var CREW_MARK = "crew ";
1754
+ function isVisible(agent) {
1755
+ return agent.driver === "human" || NEEDS_HUMAN.some((kind) => agent.attention.includes(kind));
1756
+ }
1311
1757
  var COLUMNS = {
1312
1758
  glyph: 3,
1313
1759
  // marker + state glyph
@@ -1329,25 +1775,23 @@ function headerRow(showHost) {
1329
1775
  ].filter(Boolean).join(" ");
1330
1776
  }
1331
1777
  var FILTER_KEYS = [
1332
- ["ctrl-a", ""],
1778
+ ["alt-x", "crashed"],
1779
+ ["alt-b", "blocked"],
1780
+ ["alt-d", "done"],
1781
+ ["alt-w", "running"]
1782
+ ];
1783
+ var FILTER_ALIASES = [
1333
1784
  ["ctrl-x", "crashed"],
1334
- ["ctrl-b", "blocked"],
1335
1785
  ["ctrl-d", "done"],
1336
- ["ctrl-w", "working"]
1786
+ ["ctrl-w", "running"]
1337
1787
  ];
1338
- function timestamp(ts) {
1788
+ function timestamp2(ts) {
1339
1789
  return new Date(ts).toLocaleTimeString([], {
1340
1790
  hour: "2-digit",
1341
1791
  minute: "2-digit",
1342
1792
  second: "2-digit"
1343
1793
  });
1344
1794
  }
1345
- function age(ms) {
1346
- if (ms === null || ms < 6e4) return "";
1347
- if (ms < 36e5) return `${Math.floor(ms / 6e4)}m`;
1348
- if (ms < 864e5) return `${Math.floor(ms / 36e5)}h`;
1349
- return `${Math.floor(ms / 864e5)}d`;
1350
- }
1351
1795
  function pad(value, width) {
1352
1796
  const visible = [...value.replace(ANSI_ESCAPE, "")].length;
1353
1797
  if (visible <= width) return value + " ".repeat(width - visible);
@@ -1372,8 +1816,8 @@ function pad(value, width) {
1372
1816
  function isPopup(env) {
1373
1817
  return Boolean(env.TMUX) && !env.TMUX_PANE;
1374
1818
  }
1375
- function pickerRow(agent, showHost, current, local = true) {
1376
- const state = agent.state ?? "idle";
1819
+ function pickerRow(agent, showHost, current, local = agent.local) {
1820
+ const state = renderState(agent);
1377
1821
  const colour = COLOUR[state] ?? "";
1378
1822
  const glyph = GLYPH[state] ?? "?";
1379
1823
  const marker = current ? `${BOLD}\u25C6${RESET}` : " ";
@@ -1381,13 +1825,16 @@ function pickerRow(agent, showHost, current, local = true) {
1381
1825
  const host = showHost ? local ? `${DIM} here${RESET}` : `${REMOTE}\u2192 ${terminalText(agent.host)}${RESET}` : "";
1382
1826
  const group = agent.workstream ?? agent.session_name;
1383
1827
  const workstream = group ? `${DIM}${terminalText(group)}${RESET}` : "";
1828
+ const extra = agent.attention.filter((kind) => kind !== state);
1384
1829
  const flags = [
1385
1830
  agent.driver === "orchestrated" ? "crew" : "",
1386
- agent.stale ? "unreachable" : "",
1387
- // A jump already proved this one dead. Say so plainly rather than leaving
1388
- // the row looking merely old, and sort it last.
1389
- agent.tmux_down ? "no tmux" : "",
1390
- age(agent.event_age_ms)
1831
+ // Freshness is a property of the NODE, and it is stated explicitly rather
1832
+ // than inferred from an age: a stale node keeps its last-known fields, and
1833
+ // the reader has to be told those fields are old.
1834
+ agent.freshness === "stale" ? "stale host" : "",
1835
+ ...extra,
1836
+ agent.activity === "running" && state !== "running" ? "running" : "",
1837
+ age(agent.updated_at === null ? null : Date.now() - agent.updated_at)
1391
1838
  ].filter(Boolean).join(" ");
1392
1839
  const label = [
1393
1840
  `${marker} ${colour}${glyph}${RESET}`,
@@ -1397,51 +1844,62 @@ function pickerRow(agent, showHost, current, local = true) {
1397
1844
  showHost ? pad(host, COLUMNS.host) : "",
1398
1845
  flags ? `${DIM}${flags}${RESET}` : ""
1399
1846
  ].filter(Boolean).join(" ");
1400
- return `${agent.agent_id} ${label}`;
1847
+ return `${agent.host_id} ${agent.pane} ${label}`;
1401
1848
  }
1402
1849
  function previewText(store, agent) {
1403
- const state = agent.state ?? "idle";
1850
+ const state = renderState(agent);
1404
1851
  const colour = COLOUR[state] ?? "";
1405
1852
  const head = [
1406
1853
  `${colour}${GLYPH[state] ?? "?"} ${state}${RESET} ${BOLD}${agent.agent_name ? terminalText(agent.agent_name) : agentLabel(agent)}${RESET}`,
1407
1854
  // Says where, and whether "where" is this machine. The glance below is a
1408
1855
  // local capture-pane or an ssh depending on this one fact, so it belongs in
1409
1856
  // the header rather than being inferred from a hostname.
1410
- agent.host_id === loadIdentity()?.host_id ? `${DIM}here ${agentLocation(agent)}${RESET}` : `${REMOTE}\u2192 ${terminalText(agent.host)}${RESET} ${DIM}${agentLocation(agent)}${RESET}`
1857
+ agent.local ? `${DIM}here ${agentLocation(agent)}${RESET}` : `${REMOTE}\u2192 ${terminalText(agent.host)}${RESET} ${DIM}${agentLocation(agent)}${RESET}`
1411
1858
  ];
1412
1859
  const facts = [
1860
+ `activity ${agent.activity ?? "none (attention only)"}`,
1861
+ agent.attention.length ? `wants ${agent.attention.join(", ")}` : "",
1413
1862
  agent.workstream ? `stream ${terminalText(agent.workstream)}` : "",
1414
1863
  agent.role ? `role ${terminalText(agent.role)}` : "",
1415
1864
  agent.pi_session ? `session ${terminalText(agent.pi_session)}` : "",
1865
+ agent.cli ? `cli ${terminalText(agent.cli)}` : "",
1416
1866
  agent.driver === "orchestrated" ? "driver orchestrated (crew)" : "",
1417
- agent.stale ? `fetched ${age(agent.age_ms)} ago` : ""
1867
+ // Two ages, never one. A node polled a second ago can be serving a
1868
+ // three-hour-old fact, and collapsing them is how that read as fresh.
1869
+ agent.updated_at === null ? "" : `said ${timestamp2(agent.updated_at)}`,
1870
+ agent.local ? "" : `fetched ${agent.fetched_at === null ? "never" : timestamp2(agent.fetched_at)}`,
1871
+ agent.freshness === "stale" ? `${DIM}host is stale: fields below are last-known${RESET}` : ""
1418
1872
  ].filter(Boolean);
1419
1873
  const pane = glance(store, agent);
1420
- 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}`];
1421
- const events = store.allEvents().filter((event) => event.agent_id === agent.agent_id).slice(-PREVIEW_EVENTS);
1422
- const history = events.length ? events.map((event) => {
1423
- let message = terminalText(event.message);
1424
- if (message.length > PREVIEW_MESSAGE_MAX) {
1425
- message = `${message.slice(0, PREVIEW_MESSAGE_MAX)}\u2026`;
1426
- }
1427
- const detail = message && message !== event.state ? ` ${message}` : "";
1428
- return `${DIM}${timestamp(event.ts)}${RESET} ${terminalText(event.state).padEnd(8)}${detail}`;
1429
- }) : [`${DIM}no recorded events${RESET}`];
1430
- return [...head, "", ...facts, "", ...live, "", `${DIM}\u2500\u2500 history \u2500\u2500${RESET}`, ...history].join(
1431
- "\n"
1432
- );
1874
+ const live = pane?.trimEnd() ? [
1875
+ `${DIM}\u2500\u2500 pane \u2500\u2500${RESET}`,
1876
+ pane.trimEnd().slice(-PREVIEW_MESSAGE_MAX * 20)
1877
+ ] : [
1878
+ `${DIM}\u2500\u2500 pane \u2500\u2500${RESET}`,
1879
+ `${DIM}unavailable (host unreachable, or pane gone)${RESET}`
1880
+ ];
1881
+ return [...head, "", ...facts, "", ...live].join("\n");
1433
1882
  }
1434
- function runPreview(store, agentId) {
1435
- const agent = status(store).agents.find((candidate) => candidate.agent_id === agentId);
1436
- if (!agent) return;
1437
- process.stdout.write(`${previewText(store, agent)}
1438
- `);
1883
+ function runPreview(store, paneId, hostId) {
1884
+ const identity = requireIdentity();
1885
+ if (!identity) return;
1886
+ const agent = status(store, identity).panes.find(
1887
+ (candidate) => candidate.pane === paneId && (hostId === void 0 || candidate.host_id === hostId)
1888
+ );
1889
+ process.stdout.write(
1890
+ agent ? `${previewText(store, agent)}
1891
+ ` : `${DIM}${paneId} is no longer here.${RESET}
1892
+ `
1893
+ );
1439
1894
  }
1440
- async function runPick(store, options = {}) {
1441
- const identity = loadIdentity();
1442
- const view = await statusWithCollect(store);
1443
- const agents = view.agents.filter((agent2) => options.all || agent2.driver === "human");
1444
- const hidden = view.agents.length - agents.length;
1895
+ async function runPick(store, options = {}, deps = {}) {
1896
+ const fzf = deps.fzf ?? spawnFzf;
1897
+ const jumpTo = deps.jump ?? jumpToAgent;
1898
+ const identity = requireIdentity();
1899
+ if (!identity) return;
1900
+ const view = await statusWithCollect(store, identity);
1901
+ const agents = view.panes.filter((agent2) => options.all || isVisible(agent2));
1902
+ const hidden = view.panes.length - agents.length;
1445
1903
  if (agents.length === 0) {
1446
1904
  process.stdout.write(
1447
1905
  hidden ? `No human agents (+${hidden} crew \u2014 rerun with --all)
@@ -1449,34 +1907,35 @@ async function runPick(store, options = {}) {
1449
1907
  );
1450
1908
  return;
1451
1909
  }
1452
- const showHost = agents.some((agent2) => agent2.host_id !== identity?.host_id);
1910
+ const showHost = agents.some((agent2) => !agent2.local);
1453
1911
  const currentPane = process.env.TMUX_PANE ?? "";
1454
- const input = agents.map(
1455
- (agent2) => pickerRow(agent2, showHost, agent2.pane === currentPane, agent2.host_id === identity?.host_id)
1456
- ).join("\n");
1912
+ const input = agents.map((agent2) => pickerRow(agent2, showHost, agent2.pane === currentPane)).join("\n");
1457
1913
  const counts = /* @__PURE__ */ new Map();
1458
1914
  for (const agent2 of agents) {
1459
- const state = agent2.state ?? "idle";
1915
+ const state = renderState(agent2);
1460
1916
  counts.set(state, (counts.get(state) ?? 0) + 1);
1461
1917
  }
1462
- const prompt = URGENCY.filter((state) => counts.get(state)).map((state) => `${COLOUR[state]}${GLYPH[state]}${counts.get(state)}${RESET}`).join(" ");
1918
+ const prompt = RENDER_PRIORITY.filter((state) => counts.get(state)).map((state) => `${COLOUR[state]}${GLYPH[state]}${counts.get(state)}${RESET}`).join(" ");
1919
+ const basePrompt = `${prompt}${prompt ? " " : ""}`;
1463
1920
  const self = process.argv[1] ?? "murmur";
1464
1921
  const allFlag = options.all ? " --all" : "";
1465
1922
  const inPopup = isPopup(process.env);
1466
1923
  const width = process.stdout.columns ?? 0;
1467
1924
  const previewLayout = width > 0 && width < 150 ? "bottom:60%,border-top,wrap" : "right:58%,border-left,wrap";
1468
- const preview = `${process.execPath} ${self} pick --preview {1}`;
1469
- const filterBinds = FILTER_KEYS.flatMap(([key, state]) => [
1925
+ const preview = `${process.execPath} ${self} pick --preview {2} --host {1}`;
1926
+ const filterBinds = [
1927
+ ...FILTER_KEYS.map(([key, query]) => [key, query]),
1928
+ ...FILTER_ALIASES
1929
+ ].flatMap(([key, query]) => [
1470
1930
  "--bind",
1471
- state ? `${key}:change-query(${state})` : `${key}:change-query()`
1931
+ query ? `${key}:change-query(${query})` : `${key}:change-query()`
1472
1932
  ]);
1473
- const result = spawnSync2(
1474
- "fzf",
1933
+ const stdout = fzf(
1475
1934
  [
1476
1935
  "--delimiter",
1477
1936
  " ",
1478
1937
  "--with-nth",
1479
- "2..",
1938
+ "3..",
1480
1939
  "--ansi",
1481
1940
  // Literal substring matching, and matching only the visible columns.
1482
1941
  // Default fuzzy scatters query characters across the row: `re` matched
@@ -1487,7 +1946,7 @@ async function runPick(store, options = {}) {
1487
1946
  "--exact",
1488
1947
  // `begin` ranks earlier match positions higher, so `scratch` puts the
1489
1948
  // scratch workstream above a row that merely mentions it. `index` is the
1490
- // empty-query fallback and preserves the attention order the fold
1949
+ // empty-query fallback and preserves the attention order `viewSort`
1491
1950
  // produced, which is the whole point of the list.
1492
1951
  "--tiebreak",
1493
1952
  "begin,index",
@@ -1505,13 +1964,21 @@ async function runPick(store, options = {}) {
1505
1964
  "--info",
1506
1965
  "inline",
1507
1966
  "--prompt",
1508
- `${prompt}${prompt ? " " : ""}`,
1967
+ `${options.all ? CREW_MARK : ""}${basePrompt}`,
1509
1968
  "--header",
1510
1969
  [
1511
- `enter jump ^r refresh ^p preview del forget filter: ${FILTER_KEYS.map(
1512
- ([key, state]) => `${key.replace("ctrl-", "^")} ${state || "all"}`
1513
- ).join(" ")}`,
1514
- hidden ? `${hidden} crew hidden (--all)` : "",
1970
+ // No `del forget`. There is no replica to evict: a reader holds one
1971
+ // snapshot per peer, and the next fetch replaces it whole -- so a delete
1972
+ // key could only remove a row the next collect would put straight back,
1973
+ // while looking like it had done something.
1974
+ `enter jump ^r refresh ^p preview ^u clear`,
1975
+ // "toggle crew", not "show crew": the header is built once and the
1976
+ // binding flips per keypress, so a directional label would be wrong
1977
+ // half the time. The prompt's `crew` marker says which way it is
1978
+ // currently set.
1979
+ `filter: ${FILTER_KEYS.map(([key, query]) => `${key.replace("alt-", "M-")} ${query}`).join(
1980
+ " "
1981
+ )} M-a toggle crew`,
1515
1982
  headerRow(showHost)
1516
1983
  ].filter(Boolean).join("\n"),
1517
1984
  "--preview",
@@ -1526,83 +1993,89 @@ async function runPick(store, options = {}) {
1526
1993
  "ctrl-p:change-preview-window(bottom:60%,border-top,wrap|hidden|right:58%,border-left,wrap)",
1527
1994
  "--bind",
1528
1995
  `ctrl-r:reload(${process.execPath} ${self} pick --rows${allFlag})`,
1529
- // Manual dismissal for a row nothing else will clear.
1996
+ // M-a toggles the POPULATION, which is what "all" means everywhere else in
1997
+ // murmur: the --all flag, and the "crew hidden (--all)" notice.
1998
+ //
1999
+ // It used to be the "clear the filter" key, labelled "all", which is the
2000
+ // collision that made it look broken: pressing it emptied the query
2001
+ // instead of revealing the hidden crew rows named two lines below, and
2002
+ // nothing said why. One word, two meanings, and the wrong one bound to
2003
+ // the key people reach for. Clearing is fzf's own ctrl-u, which needed no
2004
+ // binding at all.
1530
2005
  //
1531
- // The delete key, not a ctrl chord. ctrl-shift-d does not exist -- a
1532
- // terminal sends the same bytes as ctrl-d -- and ctrl-alt-d, while it
1533
- // does dispatch distinctly, sits one modifier away from ctrl-d in a
1534
- // header that lists both. One is a filter and the other destroys a row,
1535
- // so a near-miss is a deleted agent. `delete` is the key that already
1536
- // means remove this, and it collides with no filter letter.
2006
+ // `transform` rather than a fixed reload, because a bind string is built
2007
+ // once at launch and cannot know it has already fired: binding
2008
+ // `--rows --all` meant the second press re-ran the same thing and the
2009
+ // toggle only worked one way. transform runs a shell snippet per
2010
+ // keypress, so it can branch on the current state.
2011
+ //
2012
+ // The state lives in the prompt, which is the only mutable string fzf
2013
+ // exposes to a binding. CREW_MARK is carried at the front of it: visible
2014
+ // as a label, and readable back through $FZF_PROMPT.
1537
2015
  "--bind",
1538
- `delete:reload(${process.execPath} ${self} pick --forget {1}${allFlag})`,
2016
+ `alt-a:transform:[[ $FZF_PROMPT == "${CREW_MARK}"* ]] && echo "reload(${process.execPath} ${self} pick --rows)+change-prompt(${basePrompt})" || echo "reload(${process.execPath} ${self} pick --rows --all)+change-prompt(${CREW_MARK}${basePrompt})"`,
1539
2017
  ...filterBinds,
1540
2018
  "--no-select-1",
1541
2019
  "--no-exit-0"
1542
2020
  ],
1543
- {
1544
- input,
1545
- encoding: "utf8",
1546
- stdio: ["pipe", "pipe", "inherit"],
1547
- // FZF_DEFAULT_OPTS can carry a conflicting layout or bindings from the
1548
- // user's shell; the old picker stripped it for the same reason.
1549
- env: Object.fromEntries(
1550
- Object.entries(process.env).filter(([key]) => !key.startsWith("FZF_DEFAULT_OPTS"))
1551
- )
1552
- }
2021
+ input,
2022
+ // FZF_DEFAULT_OPTS can carry a conflicting layout or bindings from the
2023
+ // user's shell; the old picker stripped it for the same reason.
2024
+ Object.fromEntries(
2025
+ Object.entries(process.env).filter(([key]) => !key.startsWith("FZF_DEFAULT_OPTS"))
2026
+ )
1553
2027
  );
1554
- const selected = result.stdout?.trim().split(" ")[0];
2028
+ const [selectedHost, selected] = stdout.trim().split(" ");
1555
2029
  if (!selected) return;
1556
- const agent = agents.find((candidate) => candidate.agent_id === selected);
1557
- if (!agent) return;
1558
- const jump = jumpToAgent(store, agent);
2030
+ const agent = view.panes.find(
2031
+ (candidate) => candidate.pane === selected && candidate.host_id === selectedHost
2032
+ );
2033
+ if (!agent) {
2034
+ process.stderr.write(`${selected} is no longer here.
2035
+ `);
2036
+ process.exitCode = 1;
2037
+ return;
2038
+ }
2039
+ const jump = jumpTo(store, agent);
1559
2040
  if (!jump.ok) {
1560
2041
  process.stderr.write(`${jump.message}
1561
2042
  `);
1562
2043
  process.exitCode = 1;
1563
2044
  }
1564
2045
  }
1565
- async function runForget(store, agentId, options = {}) {
1566
- const view = status(store);
1567
- const agent = view.agents.find((candidate) => candidate.agent_id === agentId);
1568
- if (agent) forgetOneAgent(store, agent);
1569
- await runRows(store, options);
1570
- }
1571
2046
  async function runRows(store, options = {}) {
1572
- const identity = loadIdentity();
1573
- const view = await statusWithCollect(store);
1574
- const agents = view.agents.filter((agent) => options.all || agent.driver === "human");
1575
- const showHost = agents.some((agent) => agent.host_id !== identity?.host_id);
2047
+ const identity = requireIdentity();
2048
+ if (!identity) return;
2049
+ const view = await statusWithCollect(store, identity);
2050
+ const agents = view.panes.filter((agent) => options.all || isVisible(agent));
2051
+ const showHost = agents.some((agent) => !agent.local);
1576
2052
  const currentPane = process.env.TMUX_PANE ?? "";
1577
2053
  for (const agent of agents) {
1578
- process.stdout.write(
1579
- `${pickerRow(agent, showHost, agent.pane === currentPane, agent.host_id === identity?.host_id)}
1580
- `
1581
- );
2054
+ process.stdout.write(`${pickerRow(agent, showHost, agent.pane === currentPane)}
2055
+ `);
1582
2056
  }
1583
2057
  }
1584
2058
  function registerPick(program2) {
1585
- 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)").option("--forget <agent-id>", "drop one agent, then print rows (internal)").action(
1586
- async (options) => {
1587
- const store = openStore();
1588
- try {
1589
- if (options.preview) runPreview(store, options.preview);
1590
- else if (options.forget) await runForget(store, options.forget, options);
1591
- else if (options.rows) await runRows(store, options);
1592
- else await runPick(store, options);
1593
- } finally {
1594
- store.close();
1595
- }
2059
+ program2.command("pick").description("Pick an agent and jump to it").option("--all", "include orchestrated agents").option("--preview <pane>", "render the preview pane for one pane (internal)").option("--host <host-id>", "host of the pane being previewed (internal)").option("--rows", "print picker rows only (internal, for reload)").action(async (options) => {
2060
+ const store = openStore();
2061
+ try {
2062
+ if (options.preview) runPreview(store, options.preview, options.host);
2063
+ else if (options.rows) await runRows(store, options);
2064
+ else await runPick(store, options);
2065
+ } finally {
2066
+ store.close();
1596
2067
  }
1597
- );
2068
+ });
1598
2069
  }
1599
2070
 
1600
2071
  // src/cli/status.ts
1601
2072
  function registerStatus(program2) {
1602
- program2.command("status").description("Show folded agent status").option("--json", "print JSON").action(async (options) => {
2073
+ program2.command("status").description("Show current agent status").option("--json", "print JSON").action(async (options) => {
2074
+ const identity = requireIdentity();
2075
+ if (!identity) return;
1603
2076
  const store = openStore();
1604
2077
  try {
1605
- const view = await statusWithCollect(store);
2078
+ const view = await statusWithCollect(store, identity);
1606
2079
  process.stdout.write(
1607
2080
  options.json ? `${JSON.stringify(view, null, 2)}
1608
2081
  ` : tmuxStatus(view)
@@ -1613,19 +2086,15 @@ function registerStatus(program2) {
1613
2086
  });
1614
2087
  }
1615
2088
 
1616
- // src/index.ts
1617
- import { createRequire } from "module";
1618
- var manifest = createRequire(import.meta.url)("../package.json");
1619
- var VERSION = manifest.version;
1620
-
1621
2089
  // src/cli.ts
1622
2090
  var program = new Command();
1623
- program.name("murmur").description("Agent state across every machine, in one view.").version(VERSION);
2091
+ program.name("murmur").description("Agent state across every machine, in one view.").version(MURMUR_VERSION);
1624
2092
  registerInit(program);
1625
2093
  registerLink(program);
1626
2094
  registerExport(program);
1627
2095
  registerCollect(program);
1628
2096
  registerClear(program);
2097
+ registerNotify(program);
1629
2098
  registerPeer(program);
1630
2099
  registerStatus(program);
1631
2100
  registerPick(program);