@martintrojer/murmur 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -3,38 +3,15 @@
3
3
  // src/cli.ts
4
4
  import { Command } from "commander";
5
5
 
6
- // src/cli/clear.ts
7
- import Database2 from "better-sqlite3";
8
-
9
- // src/identity.ts
10
- import { randomUUID } from "crypto";
11
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
12
- import { hostname } from "os";
13
- import { join as join2 } from "path";
14
-
15
- // src/paths.ts
16
- import { homedir } from "os";
17
- import { join } from "path";
18
- function stateDir() {
19
- return process.env.MURMUR_STATE_DIR ?? join(process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state"), "murmur");
20
- }
21
- function dbPath() {
22
- return join(stateDir(), "events.db");
6
+ // src/ids.ts
7
+ function asSessionId(raw) {
8
+ return raw;
23
9
  }
24
-
25
- // src/identity.ts
26
- function loadIdentity() {
27
- const path = join2(stateDir(), "identity.json");
28
- return existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) : null;
10
+ function asWindowId(raw) {
11
+ return raw;
29
12
  }
30
- function ensureIdentity(displayName = hostname()) {
31
- const existing = loadIdentity();
32
- if (existing) return existing;
33
- const identity = { host_id: randomUUID(), display_name: displayName };
34
- mkdirSync(stateDir(), { recursive: true });
35
- writeFileSync(join2(stateDir(), "identity.json"), `${JSON.stringify(identity, null, 2)}
36
- `);
37
- return identity;
13
+ function asPaneId(raw) {
14
+ return raw;
38
15
  }
39
16
 
40
17
  // src/mux.ts
@@ -50,10 +27,20 @@ function runTmux(args) {
50
27
  return null;
51
28
  }
52
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
+ }
53
39
  var tmux = {
54
40
  currentWindow() {
55
- const pane = process.env.TMUX_PANE;
56
- if (!pane) return null;
41
+ const raw = process.env.TMUX_PANE;
42
+ if (!raw) return null;
43
+ const pane = asPaneId(raw);
57
44
  const fields = runTmux([
58
45
  "display-message",
59
46
  "-t",
@@ -64,82 +51,84 @@ var tmux = {
64
51
  const [session, window, sessionName, windowName] = fields?.split(" ") ?? [];
65
52
  if (!session || !window) return null;
66
53
  return {
67
- session,
68
- window,
54
+ session: asSessionId(session),
55
+ window: asWindowId(window),
69
56
  pane,
70
57
  session_name: sessionName || null,
71
58
  window_name: windowName || null
72
59
  };
73
60
  },
74
- // Which of this host's windows still exist. Only the authoring node can
75
- // answer this, which is why the check runs on export rather than on the
76
- // reader: a peer holding a `blocked` row for a window that died has nothing
77
- // to supersede it, and the agent stays in every HUD forever.
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.
78
65
  //
79
- // null means "could not tell" (no tmux server, tmux missing) and is
80
- // deliberately distinct from an empty set, which means "tmux answered, and
81
- // there are no windows". Treating the first as the second would clear every
82
- // agent on the host the moment tmux was unreachable.
83
- //
84
- // Unlike currentWindow, this deliberately asks tmux rather than reading the
85
- // environment, and it is right to: "which windows exist on this host" is a
86
- // server-wide question with one answer, and export runs over ssh with no
87
- // pane of its own. currentWindow asks "which pane am I in", which only
88
- // $TMUX_PANE can answer.
89
- liveWindows() {
90
- 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}"]);
91
71
  if (out === null) return null;
92
- return new Set(out.split("\n").filter(Boolean));
72
+ return new Set(out.split("\n").filter(Boolean).map(asPaneId));
93
73
  },
94
- setState(window, state) {
74
+ setWindowBadge(window, state) {
95
75
  if (state === null) {
96
76
  runTmux(["set-window-option", "-qu", "-t", window, "@agent_state"]);
97
77
  } else {
98
- runTmux(["set-window-option", "-q", "-t", window, "@agent_state", state]);
78
+ runTmux(["set-window-option", "-q", "-t", window, "@agent_state", tmuxBadgeState(state)]);
99
79
  runTmux(["set-window-option", "-q", "-t", window, "@pane_agent", "1"]);
100
80
  }
101
81
  runTmux(["refresh-client", "-S"]);
102
82
  },
103
83
  attach(session, window) {
104
84
  runTmux(["switch-client", "-t", session]);
105
- runTmux(["select-window", "-t", window]);
85
+ return runTmux(["select-window", "-t", window]) !== null;
106
86
  },
107
- // Window ids are what the log stores, because they are stable; names are
108
- // what a human recognises in a picker. Names are live tmux state, not
109
- // history, so they are resolved at render time rather than recorded.
110
- windowNames() {
111
- const out = runTmux(["list-windows", "-a", "-F", "#{window_id} #{window_name}"]);
112
- const names = /* @__PURE__ */ new Map();
113
- for (const line of out?.split("\n") ?? []) {
114
- const [id, name] = line.split(" ");
115
- if (id && name) names.set(id, name);
116
- }
117
- return names;
118
- },
119
- // First window carrying this exact name, or null. Used to reuse a per-host
120
- // ssh window instead of opening another one.
121
87
  // Sibling panes, for deciding whether an unowned pane may clear the window's
122
88
  // badge. A window holding an agent and a shell must not lose the badge when
123
89
  // you focus the shell.
124
90
  panesInWindow(window) {
125
91
  const out = runTmux(["list-panes", "-t", window, "-F", "#{pane_id}"]);
126
- return out?.split("\n").filter(Boolean) ?? [];
92
+ return out?.split("\n").filter(Boolean).map(asPaneId) ?? [];
127
93
  },
128
- windowNamed(name) {
129
- const out = runTmux(["list-windows", "-a", "-F", "#{window_id} #{window_name}"]);
130
- for (const line of out?.split("\n") ?? []) {
131
- const [id, windowName] = line.split(" ");
132
- if (id && windowName === name) return id;
133
- }
134
- 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;
135
100
  },
136
- selectWindow(window) {
137
- runTmux(["select-window", "-t", window]);
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;
138
107
  },
139
- // The window a pane belongs to, for a pane murmur has no event for. Clearing
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]);
121
+ },
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;
126
+ },
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,221 +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
- maxSeq(hostId) {
326
- return selectMaxSeq.get(hostId).seq;
593
+ releaseAgent(release) {
594
+ return deleteAgentOwned.run(release.agent_id, release.owner_pid).changes === 1;
327
595
  },
328
- prune(horizonMs = Number(process.env.MURMUR_RETENTION_MS ?? DEFAULT_RETENTION_MS)) {
329
- return database.prepare(`
330
- DELETE FROM events
331
- WHERE ts < ?
332
- AND (host_id, seq) NOT IN (
333
- SELECT host_id, seq FROM (
334
- SELECT host_id, seq,
335
- ROW_NUMBER() OVER (PARTITION BY agent_id ORDER BY ts DESC, seq DESC) rn
336
- FROM events
337
- ) WHERE rn = 1
338
- )
339
- `).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
+ });
340
608
  },
341
- peers() {
342
- return database.prepare("SELECT * FROM peers ORDER BY name").all();
609
+ acknowledgePane(pane) {
610
+ return deleteAttentionForPane.run(pane).changes;
343
611
  },
344
- forgetAgent(agentId) {
345
- return database.prepare("DELETE FROM events WHERE agent_id = ?").run(agentId).changes;
612
+ localPanes() {
613
+ return readLocalPanes();
346
614
  },
347
- forgetHost(hostId) {
348
- 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
+ };
349
632
  },
350
- upsertPeer(peer) {
351
- const current = database.prepare("SELECT * FROM peers WHERE name = ?").get(peer.name);
352
- database.prepare(`
353
- INSERT INTO peers (name, target, host_id, display_name, watermark, fetched_at, tmux_down_at)
354
- VALUES (?, ?, ?, ?, ?, ?, ?)
355
- ON CONFLICT(name) DO UPDATE SET
356
- target = excluded.target,
357
- host_id = excluded.host_id,
358
- display_name = excluded.display_name,
359
- watermark = excluded.watermark,
360
- fetched_at = excluded.fetched_at,
361
- tmux_down_at = excluded.tmux_down_at
362
- `).run(
363
- peer.name,
364
- peer.target,
365
- peer.host_id !== void 0 ? peer.host_id : current?.host_id ?? null,
366
- peer.display_name !== void 0 ? peer.display_name : current?.display_name ?? null,
367
- peer.watermark !== void 0 ? peer.watermark : current?.watermark ?? 0,
368
- peer.fetched_at !== void 0 ? peer.fetched_at : current?.fetched_at ?? null,
369
- 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
370
636
  );
371
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
+ },
372
644
  removePeer(name) {
373
645
  return database.prepare("DELETE FROM peers WHERE name = ?").run(name).changes > 0;
374
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
+ },
375
670
  close() {
376
671
  database.close();
377
672
  }
@@ -379,97 +674,42 @@ function openStore() {
379
674
  }
380
675
 
381
676
  // src/cli/clear.ts
382
- function windowHasAgent(window, focused, hostId, mux) {
383
- if (!hostId) return false;
384
- const siblings = mux.panesInWindow(window).filter((candidate) => candidate !== focused);
385
- if (siblings.length === 0) return false;
386
- try {
387
- const database = new Database2(dbPath(), { readonly: true, fileMustExist: true });
388
- try {
389
- for (const sibling of siblings) {
390
- const row = database.prepare(
391
- `SELECT state FROM events
392
- WHERE host_id = ? AND agent_id = ?
393
- ORDER BY seq DESC LIMIT 1`
394
- ).get(hostId, `${hostId}:${sibling}`);
395
- if (row && row.state !== "cleared") return true;
396
- }
397
- } finally {
398
- database.close();
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) {
688
+ let store;
406
689
  try {
407
- if (!pane) return;
690
+ if (!raw) return;
691
+ const pane = asPaneId(raw);
408
692
  const window = mux.windowForPane(pane);
409
- const identity = loadIdentity();
410
- let owner;
411
- if (identity) {
412
- try {
413
- const database = new Database2(dbPath(), { readonly: true, fileMustExist: true });
414
- try {
415
- owner = database.prepare(
416
- `SELECT agent_id, session, window, pane, session_name, window_name,
417
- agent_name, pi_session, workstream, role, cli, driver, state
418
- FROM events
419
- WHERE host_id = ? AND agent_id = ?
420
- ORDER BY seq DESC
421
- LIMIT 1`
422
- ).get(identity.host_id, `${identity.host_id}:${pane}`);
423
- } finally {
424
- database.close();
425
- }
426
- } catch {
427
- }
428
- }
429
- if (!owner) {
430
- if (window && !windowHasAgent(window, pane, identity?.host_id, mux)) {
431
- mux.setState(window, null);
432
- }
433
- return;
434
- }
435
- if (owner.state === "cleared") {
436
- mux.setState(owner.window, null);
437
- return;
693
+ try {
694
+ store = openStore();
695
+ store.acknowledgePane(pane);
696
+ } catch {
438
697
  }
439
- const store = openStore();
698
+ if (!window) return;
440
699
  try {
441
- store.append({
442
- agent_id: owner.agent_id,
443
- session: owner.session,
444
- window: owner.window,
445
- pane: owner.pane,
446
- // Carry the names forward: a `cleared` row that drops them makes the
447
- // agent's last event nameless, which is what left "@75" in the picker.
448
- session_name: owner.session_name,
449
- window_name: owner.window_name,
450
- agent_name: owner.agent_name,
451
- pi_session: owner.pi_session,
452
- workstream: owner.workstream,
453
- role: owner.role,
454
- cli: owner.cli,
455
- driver: owner.driver,
456
- kind: "state",
457
- state: "cleared",
458
- message: "",
459
- pid: null,
460
- synthetic: false,
461
- reason: "",
462
- extra: {}
463
- });
464
- } finally {
465
- store.close();
700
+ mux.setWindowBadge(window, store ? windowBadge(window, mux, store) : null);
701
+ } catch {
466
702
  }
467
- mux.setState(owner.window, null);
468
703
  } catch {
704
+ } finally {
705
+ try {
706
+ store?.close();
707
+ } catch {
708
+ }
469
709
  }
470
710
  }
471
711
  function registerClear(program2) {
472
- 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 ?? ""));
473
713
  }
474
714
 
475
715
  // src/channel.ts
@@ -477,8 +717,8 @@ import { execFile, execFileSync as execFileSync2 } from "child_process";
477
717
  import { promisify } from "util";
478
718
  var execFileAsync = promisify(execFile);
479
719
  var CONTROL_PATH = "~/.ssh/control/%r@%h:%p";
480
- var CONNECT_TIMEOUT_S = 2;
481
- var EXEC_TIMEOUT_MS = 1e4;
720
+ var CONNECT_TIMEOUT_S = 1;
721
+ var EXEC_TIMEOUT_MS = 3e3;
482
722
  var SSH_OPTIONS = [
483
723
  "-o",
484
724
  "BatchMode=yes",
@@ -489,11 +729,13 @@ var SSH_OPTIONS = [
489
729
  "-o",
490
730
  `ConnectTimeout=${CONNECT_TIMEOUT_S}`
491
731
  ];
732
+ var MAX_EXPORT_BYTES = 64 * 1024 * 1024;
492
733
  var ssh = {
493
734
  async exec(target, argv) {
494
735
  const { stdout } = await execFileAsync("ssh", [...SSH_OPTIONS, target, ...argv], {
495
736
  encoding: "utf8",
496
- timeout: EXEC_TIMEOUT_MS
737
+ timeout: EXEC_TIMEOUT_MS,
738
+ maxBuffer: MAX_EXPORT_BYTES
497
739
  });
498
740
  return stdout;
499
741
  }
@@ -507,259 +749,324 @@ function hasWarmSocket(target) {
507
749
  }
508
750
  }
509
751
 
510
- // src/types.ts
511
- var DEFAULT_DRIVER = "human";
512
-
513
- // src/fold.ts
514
- function foldAgent(events, isAlive) {
515
- for (let index = events.length - 1; index >= 0; index -= 1) {
516
- const event = events[index];
517
- if (!event) continue;
518
- switch (event.state) {
519
- case "blocked":
520
- case "done":
521
- case "crashed":
522
- return { state: event.state, event };
523
- case "cleared":
524
- return { state: null, event: null };
525
- case "working":
526
- return {
527
- state: event.pid !== null && event.pid > 0 && isAlive(event.pid) ? "working" : "crashed",
528
- event
529
- };
530
- }
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";
531
758
  }
532
- return { state: null, event: null };
533
- }
534
- function foldAll(events, isAlive) {
535
- const byAgent = /* @__PURE__ */ new Map();
536
- for (const event of events) {
537
- const agentEvents = byAgent.get(event.agent_id);
538
- if (agentEvents) agentEvents.push(event);
539
- else byAgent.set(event.agent_id, [event]);
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");
540
767
  }
541
- return [...byAgent.values()].map((agentEvents) => {
542
- const folded = foldAgent(agentEvents, isAlive);
543
- const source = folded.event ?? agentEvents[agentEvents.length - 1];
544
- if (!source) throw new Error("agent event group cannot be empty");
545
- return {
546
- agent_id: source.agent_id,
547
- host_id: source.host_id,
548
- state: folded.state,
549
- event: folded.event,
550
- workstream: source.workstream,
551
- role: source.role,
552
- cli: source.cli,
553
- driver: source.driver ?? DEFAULT_DRIVER,
554
- session: source.session,
555
- window: source.window,
556
- pane: source.pane,
557
- session_name: source.session_name,
558
- window_name: source.window_name,
559
- agent_name: source.agent_name,
560
- pi_session: source.pi_session,
561
- fetched_at: null
562
- };
563
- });
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}`);
772
+ }
773
+ return record;
564
774
  }
565
- var ATTENTION_ORDER = {
566
- blocked: 0,
567
- done: 1,
568
- crashed: 2,
569
- working: 3,
570
- cleared: 4
571
- };
572
- function attentionSort(views) {
573
- return [...views].sort((left, right) => {
574
- const stateOrder = (left.state === null ? 4 : ATTENTION_ORDER[left.state]) - (right.state === null ? 4 : ATTENTION_ORDER[right.state]);
575
- if (stateOrder !== 0) return stateOrder;
576
- return (right.event?.ts ?? 0) - (left.event?.ts ?? 0);
577
- });
775
+ function text(value, path) {
776
+ if (typeof value !== "string" || value === "") fail(path, "expected a non-empty string");
777
+ return value;
578
778
  }
579
- function isStale(fetchedAt, now, thresholdMs = 6e4) {
580
- 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;
581
783
  }
582
-
583
- // src/export.ts
584
- var SCHEMA_VERSION = 2;
585
- 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",
586
805
  "host_id",
587
- "seq",
588
- "ts",
589
- "agent_id",
806
+ "display_name",
807
+ "murmur_version",
808
+ "generated_at",
809
+ "panes"
810
+ ];
811
+ var PANE_KEYS = [
812
+ "pane",
590
813
  "session",
591
814
  "window",
592
- "pane",
593
815
  "session_name",
594
816
  "window_name",
817
+ "agent",
818
+ "attention"
819
+ ];
820
+ var AGENT_KEYS = [
821
+ "agent_id",
822
+ "activity",
595
823
  "agent_name",
596
824
  "pi_session",
597
825
  "workstream",
598
826
  "role",
599
827
  "cli",
600
828
  "driver",
601
- "kind",
602
- "state",
603
- "message",
604
- "pid",
605
- "synthetic",
606
- "reason"
607
- ]);
608
- function eventToWire(event) {
609
- const { extra, ...known } = event;
610
- return { ...extra, ...known };
611
- }
612
- function eventFromWire(wire) {
613
- 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);
614
836
  return {
615
- host_id: wire.host_id,
616
- seq: wire.seq,
617
- ts: wire.ts,
618
- agent_id: wire.agent_id,
619
- session: wire.session,
620
- window: wire.window,
621
- pane: wire.pane,
622
- session_name: wire.session_name ?? null,
623
- window_name: wire.window_name ?? null,
624
- agent_name: wire.agent_name ?? null,
625
- pi_session: wire.pi_session ?? null,
626
- workstream: wire.workstream ?? null,
627
- role: wire.role ?? null,
628
- cli: wire.cli ?? null,
629
- driver: wire.driver ?? null,
630
- kind: wire.kind,
631
- state: wire.state,
632
- message: wire.message,
633
- pid: wire.pid ?? null,
634
- synthetic: wire.synthetic,
635
- reason: wire.reason,
636
- 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`)
637
847
  };
638
848
  }
639
- function synthesizeCrashes(store, hostId, isAlive) {
640
- const byAgent = /* @__PURE__ */ new Map();
641
- for (const event of store.allEvents()) {
642
- if (event.host_id !== hostId) continue;
643
- const events = byAgent.get(event.agent_id);
644
- if (events) events.push(event);
645
- else byAgent.set(event.agent_id, [event]);
646
- }
647
- for (const events of byAgent.values()) {
648
- events.sort((left, right) => left.seq - right.seq);
649
- const newest = events.at(-1);
650
- if (newest && newest.state === "working" && !newest.synthetic && foldAgent(events, isAlive).state === "crashed") {
651
- const { host_id: _hostId, seq: _seq, ts: _ts, ...event } = newest;
652
- store.append({ ...event, state: "crashed", synthetic: true, reason: "pid_gone" });
653
- }
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");
654
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
+ };
655
882
  }
656
- function clearDeadWindows(store, hostId, live) {
657
- if (live === null) return;
658
- const newest = /* @__PURE__ */ new Map();
659
- for (const event of store.allEvents()) {
660
- if (event.host_id !== hostId) continue;
661
- const previous = newest.get(event.agent_id);
662
- 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)})`);
663
889
  }
664
- for (const event of newest.values()) {
665
- if (event.state === "cleared") continue;
666
- if (live.has(event.window)) continue;
667
- const { host_id: _hostId, seq: _seq, ts: _ts, ...rest } = event;
668
- store.append({
669
- ...rest,
670
- state: "cleared",
671
- synthetic: true,
672
- reason: "window_gone",
673
- message: ""
674
- });
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)}`);
675
893
  }
676
- }
677
- function exportJsonl(store, since, isAlive, live) {
678
- const identity = ensureIdentity();
679
- synthesizeCrashes(store, identity.host_id, isAlive);
680
- if (live !== void 0) clearDeadWindows(store, identity.host_id, live);
681
- const envelope = {
682
- schema_version: SCHEMA_VERSION,
683
- host_id: identity.host_id,
684
- display_name: identity.display_name,
685
- 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
686
908
  };
687
- const lines = [
688
- JSON.stringify(envelope),
689
- ...store.eventsSince(identity.host_id, since).map((event) => JSON.stringify(eventToWire(event)))
690
- ];
691
- return `${lines.join("\n")}
692
- `;
693
909
  }
694
910
 
695
911
  // src/collector.ts
696
- var COLLECT_INTERVAL_MS = 3e4;
697
- var STALENESS_MS = 2 * COLLECT_INTERVAL_MS;
698
- function parseJsonl(output) {
699
- const lines = output.trim().split("\n");
700
- const envelope = JSON.parse(lines.shift() ?? "");
701
- if (envelope.schema_version > SCHEMA_VERSION) {
702
- throw new Error(
703
- `unsupported schema version ${envelope.schema_version} (supports ${SCHEMA_VERSION})`
704
- );
705
- }
706
- return {
707
- envelope,
708
- events: lines.map((line) => eventFromWire(JSON.parse(line)))
912
+ var MAX_CONCURRENT_PEERS = 8;
913
+ var COLLECT_DEADLINE_MS = 4e3;
914
+ async function mapSettled(items, limit, task, deadline) {
915
+ const results = new Array(items.length);
916
+ let cursor = 0;
917
+ let expired = false;
918
+ const stop = deadline?.then(() => {
919
+ expired = true;
920
+ });
921
+ const worker = async () => {
922
+ while (cursor < items.length && !expired) {
923
+ const index = cursor++;
924
+ try {
925
+ results[index] = { status: "fulfilled", value: await task(items[index]) };
926
+ } catch (reason) {
927
+ results[index] = { status: "rejected", reason };
928
+ }
929
+ }
709
930
  };
931
+ const pool = Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
932
+ await (stop ? Promise.race([pool, stop]) : pool);
933
+ return results;
934
+ }
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();
710
948
  }
711
- async function collect(store, channel, now = Date.now()) {
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()})`;
954
+ }
955
+ const detail = collapsed.length > 160 ? `${collapsed.slice(0, 157)}...` : collapsed;
956
+ return `${peer}: ${detail}`;
957
+ }
958
+ async function collect(store, channel, now = Date.now(), deadline) {
712
959
  const results = [];
960
+ let timer;
713
961
  try {
714
- for (const peer of store.peers()) {
962
+ const peers = store.peers();
963
+ const bounded = deadline ?? new Promise((resolve) => {
964
+ timer = setTimeout(resolve, COLLECT_DEADLINE_MS);
965
+ timer.unref?.();
966
+ });
967
+ const fetches = await mapSettled(
968
+ peers,
969
+ MAX_CONCURRENT_PEERS,
970
+ async (peer) => parseSnapshot(await channel.exec(peer.target, ["murmur", "export"])),
971
+ bounded
972
+ );
973
+ for (const [index, peer] of peers.entries()) {
974
+ const fetch = fetches[index];
715
975
  try {
716
- const output = await channel.exec(peer.target, [
717
- "murmur",
718
- "export",
719
- "--since",
720
- String(peer.watermark)
721
- ]);
722
- const { envelope, events } = parseJsonl(output);
723
- const ingested = store.ingest(events);
724
- const watermark = events.filter((event) => event.host_id === envelope.host_id).reduce((highest, event) => Math.max(highest, event.seq), peer.watermark);
725
- store.upsertPeer({
726
- name: peer.name,
727
- target: peer.target,
728
- host_id: envelope.host_id,
729
- display_name: envelope.display_name,
730
- watermark,
731
- fetched_at: now,
732
- // New events mean the node is authoring again, so whatever a jump
733
- // observed about its tmux is out of date. Only clear on actual new
734
- // events: an export that returns nothing proves the binary ran, not
735
- // that tmux is back, which is the distinction that let a dead host
736
- // look healthy for three hours.
737
- tmux_down_at: ingested > 0 ? null : peer.tmux_down_at
738
- });
739
- store.prune();
740
- results.push({ peer: peer.name, ok: true, ingested });
976
+ if (!fetch) throw new Error("collect deadline passed before this peer answered");
977
+ if (fetch.status === "rejected") throw fetch.reason;
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 });
741
980
  } catch (error) {
742
- const message = error instanceof Error ? error.message : String(error);
743
- process.stderr.write(`murmur: collect: peer ${peer.name}: ${message}
744
- `);
745
- 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
+ });
746
992
  }
747
993
  }
748
994
  } catch (error) {
749
- process.stderr.write(
750
- `murmur: collect: ${error instanceof Error ? error.message : String(error)}
751
- `
752
- );
995
+ results.push({
996
+ peer: "",
997
+ ok: false,
998
+ panes: 0,
999
+ error: error instanceof Error ? error.message : String(error)
1000
+ });
1001
+ } finally {
1002
+ clearTimeout(timer);
1003
+ }
1004
+ try {
1005
+ store.reconcileLocal({ panes: tmux.livePanes(), now });
1006
+ } catch {
753
1007
  }
754
1008
  return results;
755
1009
  }
756
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
+
757
1054
  // src/cli/collect.ts
758
1055
  function registerCollect(program2) {
759
- 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;
760
1058
  const store = openStore();
761
1059
  try {
762
- 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
+ }
763
1070
  } finally {
764
1071
  store.close();
765
1072
  }
@@ -768,10 +1075,14 @@ function registerCollect(program2) {
768
1075
 
769
1076
  // src/cli/export.ts
770
1077
  function registerExport(program2) {
771
- 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;
772
1081
  const store = openStore();
773
1082
  try {
774
- 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
+ `);
775
1086
  } finally {
776
1087
  store.close();
777
1088
  }
@@ -781,19 +1092,46 @@ function registerExport(program2) {
781
1092
  // src/cli/init.ts
782
1093
  function registerInit(program2) {
783
1094
  program2.command("init").description("Initialize this node's identity").option("--name <name>", "display name").action((opts) => {
784
- const identity = ensureIdentity(opts.name);
1095
+ const existing = loadIdentity();
1096
+ const identity = existing ? opts.name ? setDisplayName(opts.name) : existing : createIdentity(opts.name);
785
1097
  console.log(`host_id: ${identity.host_id}`);
786
1098
  console.log(`display_name: ${identity.display_name}`);
787
1099
  });
788
1100
  }
789
1101
 
790
1102
  // src/cli/link.ts
791
- 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";
792
1104
  import { homedir as homedir2 } from "os";
793
- import { dirname, join as join3 } from "path";
1105
+ import { dirname as dirname2, join as join3 } from "path";
794
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
+ }
795
1130
  function registerLink(program2) {
796
- 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) => {
797
1135
  if (target !== "pi") throw new Error(`unsupported link target: ${target}`);
798
1136
  const destination = join3(
799
1137
  process.env.MURMUR_PI_HOME ?? homedir2(),
@@ -802,12 +1140,32 @@ function registerLink(program2) {
802
1140
  "extensions",
803
1141
  "murmur.ts"
804
1142
  );
805
- mkdirSync2(dirname(destination), { recursive: true });
806
- const source = readFileSync2(
807
- fileURLToPath(new URL("./extension/murmur-pi.js", import.meta.url)),
808
- "utf8"
809
- );
1143
+ mkdirSync3(dirname2(destination), { recursive: true });
1144
+ const entry = fileURLToPath(new URL("./extension/murmur-pi.js", import.meta.url));
810
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");
811
1169
  const pinned = source.replace(
812
1170
  /"@martintrojer\/murmur\/extension-store"/,
813
1171
  JSON.stringify(storePath)
@@ -817,6 +1175,107 @@ function registerLink(program2) {
817
1175
  }
818
1176
  writeFileSync2(destination, pinned);
819
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
+ });
820
1279
  });
821
1280
  }
822
1281
 
@@ -824,6 +1283,7 @@ function registerLink(program2) {
824
1283
  import { readFileSync as readFileSync3 } from "fs";
825
1284
  import { homedir as homedir3 } from "os";
826
1285
  import { join as join4 } from "path";
1286
+ var SNAPSHOT_VERSION = 1;
827
1287
  function parseSshHosts(config) {
828
1288
  const hosts = [];
829
1289
  for (const line of config.split("\n")) {
@@ -842,6 +1302,22 @@ function sshHosts() {
842
1302
  return [];
843
1303
  }
844
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
+ }
845
1321
  function formatTable(rows) {
846
1322
  const widths = [];
847
1323
  for (const row of rows) {
@@ -854,43 +1330,51 @@ function formatTable(rows) {
854
1330
  ).map((line) => `${line}
855
1331
  `).join("");
856
1332
  }
1333
+ function peerAddDecision(input) {
1334
+ const { name, target, snapshot, selfHostId, peers } = input;
1335
+ if (!snapshot) return null;
1336
+ if (snapshot.host_id === selfHostId) {
1337
+ return `${target} is this node; not adding it as a peer
1338
+ `;
1339
+ }
1340
+ const existing = peers.find(
1341
+ (candidate) => candidate.host_id === snapshot.host_id && candidate.name !== name
1342
+ );
1343
+ if (existing) {
1344
+ return `${target} is already configured as peer "${existing.name}" (${snapshot.display_name}); remove it first to rename
1345
+ `;
1346
+ }
1347
+ return null;
1348
+ }
857
1349
  function registerPeer(program2) {
858
1350
  const peer = program2.command("peer").description("Manage peers");
859
1351
  peer.command("add").description("Add a peer and discover its identity").argument("<name>").argument("[target]").action(async (name, target = name) => {
860
1352
  const store = openStore();
861
1353
  try {
862
- let envelope = null;
1354
+ let snapshot = null;
863
1355
  try {
864
- const output = await ssh.exec(target, ["murmur", "export", "--since", "0"]);
865
- envelope = JSON.parse(output.trim().split("\n")[0] ?? "");
1356
+ snapshot = parseSnapshot(await ssh.exec(target, ["murmur", "export"]));
866
1357
  } catch {
867
- envelope = null;
1358
+ snapshot = null;
868
1359
  }
869
- if (envelope) {
870
- if (envelope.host_id === loadIdentity()?.host_id) {
871
- process.stderr.write(`${target} is this node; not adding it as a peer
872
- `);
873
- process.exitCode = 1;
874
- return;
875
- }
876
- const existing = store.peers().find((candidate) => candidate.host_id === envelope.host_id && candidate.name !== name);
877
- if (existing) {
878
- process.stderr.write(
879
- `${target} is already configured as peer "${existing.name}" (${envelope.display_name}); remove it first to rename
880
- `
881
- );
882
- process.exitCode = 1;
883
- return;
884
- }
885
- }
886
- store.upsertPeer({
1360
+ const refusal = peerAddDecision({
887
1361
  name,
888
1362
  target,
889
- host_id: envelope?.host_id ?? null,
890
- display_name: envelope?.display_name ?? null
1363
+ snapshot,
1364
+ selfHostId: loadIdentity()?.host_id ?? null,
1365
+ peers: store.peers()
891
1366
  });
1367
+ if (refusal) {
1368
+ process.stderr.write(refusal);
1369
+ process.exitCode = 1;
1370
+ return;
1371
+ }
1372
+ store.addPeer(name, target);
1373
+ if (snapshot) {
1374
+ store.replacePeerSnapshot(name, { ok: true, snapshot, at: Date.now() });
1375
+ }
892
1376
  process.stdout.write(
893
- envelope ? `Added ${name} (${envelope.display_name})
1377
+ snapshot ? `Added ${name} (${snapshot.display_name})
894
1378
  ` : `Added ${name} (identity pending)
895
1379
  `
896
1380
  );
@@ -912,40 +1396,111 @@ function registerPeer(program2) {
912
1396
  store.close();
913
1397
  }
914
1398
  });
915
- 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) => {
916
1400
  const store = openStore();
917
1401
  try {
918
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
+ });
919
1443
  if (options.json) {
920
- process.stdout.write(`${JSON.stringify(peers)}
1444
+ process.stdout.write(`${JSON.stringify(rows)}
921
1445
  `);
922
1446
  return;
923
1447
  }
924
- if (peers.length === 0) {
925
- 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
+ );
926
1452
  return;
927
1453
  }
928
- const rows = [
929
- // HOSTNAME, not HOST: this is what the node reported about itself,
930
- // which is not the handle any other command takes. NAME is.
931
- ["NAME", "TARGET", "HOSTNAME"],
932
- ...peers.map((configured) => [
933
- configured.name,
934
- configured.target,
935
- 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
+ ])
936
1476
  ])
937
- ];
938
- 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
+ }
939
1500
  } finally {
940
1501
  store.close();
941
1502
  }
942
1503
  });
943
- peer.command("discover").description("Check SSH hosts for warm control sockets").action(() => {
944
- for (const host of sshHosts()) {
945
- process.stdout.write(`${hasWarmSocket(host) ? "[x]" : "[ ]"} ${host}
946
- `);
947
- }
948
- });
949
1504
  }
950
1505
 
951
1506
  // src/cli/pick.ts
@@ -971,51 +1526,41 @@ function terminalText(value) {
971
1526
  function shellQuote(value) {
972
1527
  return `'${value.replaceAll("'", `'\\''`)}'`;
973
1528
  }
974
- function forgetHostReplica(store, hostId) {
975
- try {
976
- const peer = store.peers().find((candidate) => candidate.host_id === hostId);
977
- store.forgetHost(hostId);
978
- if (peer) {
979
- store.upsertPeer({
980
- name: peer.name,
981
- target: peer.target,
982
- tmux_down_at: Date.now()
983
- });
984
- }
985
- } catch {
986
- }
987
- }
988
- function forgetReplica(store, agentId, hostId) {
989
- try {
990
- store.forgetAgent(agentId);
991
- const peer = store.peers().find((candidate) => candidate.host_id === hostId);
992
- if (peer) store.upsertPeer({ name: peer.name, target: peer.target, watermark: 0 });
993
- } catch {
994
- }
1529
+ function remoteSessionName(peerName) {
1530
+ return `${peerName.replace(/^[@$%=]+/, "")}~`;
995
1531
  }
996
- function forgetOneAgent(store, agent, mux = tmux) {
997
- const identity = loadIdentity();
998
- if (agent.host_id === identity?.host_id) {
999
- try {
1000
- mux.setState(agent.window, null);
1001
- } catch {
1532
+ var spawnRunner = (file, args, inherit = false) => {
1533
+ const result = spawnSync(file, args, {
1534
+ encoding: "utf8",
1535
+ timeout: 1e4,
1536
+ ...inherit ? { stdio: "inherit" } : {}
1537
+ });
1538
+ return {
1539
+ status: result.status,
1540
+ stdout: result.stdout ?? "",
1541
+ // spawnSync reports a failure to even start the child in `error`, leaving
1542
+ // status null. Collapsing both here keeps the decision table below reading
1543
+ // as one question rather than two.
1544
+ failed: result.error !== void 0
1545
+ };
1546
+ };
1547
+ function jumpToAgent(store, agent, mux = tmux, run = spawnRunner) {
1548
+ if (agent.local) {
1549
+ const panes = mux.livePanes();
1550
+ if (panes && !panes.has(agent.pane)) {
1551
+ return {
1552
+ ok: false,
1553
+ reason: "pane_gone",
1554
+ message: `${agentLabel(agent)} is gone -- its pane no longer exists.`
1555
+ };
1002
1556
  }
1003
- }
1004
- forgetReplica(store, agent.agent_id, agent.host_id);
1005
- }
1006
- function jumpToAgent(store, agent) {
1007
- const identity = loadIdentity();
1008
- if (agent.host_id === identity?.host_id) {
1009
- const live = tmux.liveWindows();
1010
- if (live && !live.has(agent.window)) {
1011
- forgetReplica(store, agent.agent_id, agent.host_id);
1557
+ if (!mux.attach(agent.session, agent.window)) {
1012
1558
  return {
1013
1559
  ok: false,
1014
- reason: "window_gone",
1015
- message: `${agentLabel(agent)} is gone -- its window no longer exists. Cleared.`
1560
+ reason: "attach_failed",
1561
+ message: `could not attach to ${agentLabel(agent)} (tmux select-window failed).`
1016
1562
  };
1017
1563
  }
1018
- tmux.attach(agent.session, agent.window);
1019
1564
  return { ok: true };
1020
1565
  }
1021
1566
  const peer = store.peers().find((candidate) => candidate.host_id === agent.host_id);
@@ -1027,67 +1572,77 @@ function jumpToAgent(store, agent) {
1027
1572
  message: `No peer configured for host ${agent.host_id.slice(0, 8)}. Try: murmur peer add <target>`
1028
1573
  };
1029
1574
  }
1030
- const probe = spawnSync(
1031
- "ssh",
1032
- ["-o", "BatchMode=yes", target, `tmux list-windows -a -F ${shellQuote("#{window_id}")}`],
1033
- { encoding: "utf8", timeout: 1e4 }
1034
- );
1575
+ const probe = run("ssh", [
1576
+ ...SSH_OPTIONS,
1577
+ target,
1578
+ `tmux list-panes -a -F ${shellQuote("#{pane_id}")}`
1579
+ ]);
1035
1580
  if (probe.status !== 0) {
1036
- const sshFailed = probe.status === 255 || probe.error !== void 0;
1581
+ const sshFailed = probe.status === 255 || probe.failed;
1037
1582
  if (sshFailed) {
1038
1583
  return {
1039
1584
  ok: false,
1040
1585
  reason: "unreachable",
1041
- message: `cannot reach ${target} over ssh. The collector never prompts for auth, so connect once by hand to warm the connection, then retry.`
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.`
1042
1587
  };
1043
1588
  }
1044
- forgetHostReplica(store, agent.host_id);
1045
1589
  return {
1046
1590
  ok: false,
1047
1591
  reason: "no_tmux",
1048
- 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.`
1049
1593
  };
1050
1594
  }
1051
- const remoteWindows = new Set((probe.stdout ?? "").split("\n").filter(Boolean));
1052
- if (!remoteWindows.has(agent.window)) {
1053
- 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)) {
1054
1597
  return {
1055
1598
  ok: false,
1056
- reason: "window_gone",
1057
- 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.`
1058
1601
  };
1059
1602
  }
1060
1603
  const attachTarget = shellQuote(`${agent.session}:${agent.window}`);
1061
1604
  if (process.env.TMUX) {
1062
- const command = `ssh -t ${shellQuote(target)} tmux attach -t ${shellQuote(attachTarget)}`;
1063
- const name = `@${peer?.name ?? target}`;
1064
- const existing = tmux.windowNamed(name);
1065
- if (existing) {
1066
- tmux.selectWindow(existing);
1067
- return { 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 } : {
1610
+ ok: false,
1611
+ reason: "attach_failed",
1612
+ message: `could not switch to the existing ${name} session.`
1613
+ };
1068
1614
  }
1069
- spawnSync("tmux", ["new-window", "-n", name, command], { stdio: "ignore" });
1070
- return { 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 } : {
1628
+ ok: false,
1629
+ reason: "attach_failed",
1630
+ message: `attached to ${target} in session ${name}, but could not switch to it.`
1631
+ };
1071
1632
  }
1072
- spawnSync("ssh", ["-t", target, "tmux", "attach", "-t", attachTarget], { stdio: "inherit" });
1073
- return { ok: true };
1633
+ const attach = run("ssh", ["-t", target, "tmux", "attach", "-t", attachTarget], true);
1634
+ return attach.status === 0 && !attach.failed ? { ok: true } : {
1635
+ ok: false,
1636
+ reason: "attach_failed",
1637
+ message: `ssh attach to ${target} failed.`
1638
+ };
1074
1639
  }
1075
1640
 
1076
1641
  // src/glance.ts
1077
1642
  import { execFileSync as execFileSync3 } from "child_process";
1078
1643
  var GLANCE_LINES = 40;
1079
- var SSH_OPTIONS2 = [
1080
- "-o",
1081
- "BatchMode=yes",
1082
- "-o",
1083
- "ControlMaster=no",
1084
- "-o",
1085
- "ControlPath=~/.ssh/control/%r@%h:%p",
1086
- "-o",
1087
- "ConnectTimeout=2"
1088
- ];
1089
1644
  function glance(store, agent, lines = GLANCE_LINES) {
1090
- if (agent.host_id === loadIdentity()?.host_id) return tmux.capture(agent.pane, lines);
1645
+ if (agent.local) return tmux.capture(agent.pane, lines);
1091
1646
  const peer = store.peers().find((candidate) => candidate.host_id === agent.host_id);
1092
1647
  const target = peer?.target ?? peer?.name;
1093
1648
  if (!target) return null;
@@ -1095,7 +1650,7 @@ function glance(store, agent, lines = GLANCE_LINES) {
1095
1650
  return execFileSync3(
1096
1651
  "ssh",
1097
1652
  [
1098
- ...SSH_OPTIONS2,
1653
+ ...SSH_OPTIONS,
1099
1654
  target,
1100
1655
  "tmux",
1101
1656
  "capture-pane",
@@ -1114,91 +1669,60 @@ function glance(store, agent, lines = GLANCE_LINES) {
1114
1669
 
1115
1670
  // src/status.ts
1116
1671
  function emptyCounts() {
1117
- 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;
1118
1675
  }
1119
1676
  function tmuxStatus(view) {
1120
- const urgency = ["crashed", "blocked", "done", "working", "idle"];
1121
- 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)}
1122
1680
  `).join("");
1123
1681
  }
1124
- function status(store, now = Date.now()) {
1125
- const identity = loadIdentity();
1126
- const peers = store.peers();
1127
- const peersByHost = new Map(
1128
- peers.flatMap((peer) => peer.host_id === null ? [] : [[peer.host_id, peer]])
1129
- );
1130
- const events = store.allEvents();
1131
- const local = foldAll(
1132
- events.filter((event) => event.host_id === identity?.host_id),
1133
- pidAlive
1134
- );
1135
- const remote = foldAll(
1136
- events.filter((event) => event.host_id !== identity?.host_id),
1137
- () => true
1138
- );
1682
+ function status(store, identity, now = Date.now()) {
1139
1683
  const counts = emptyCounts();
1140
1684
  const orchestratedCounts = emptyCounts();
1141
- const agents = attentionSort([...local, ...remote]).map((agent) => {
1142
- const peer = peersByHost.get(agent.host_id);
1143
- const fetchedAt = peer?.fetched_at ?? null;
1144
- const state = agent.state === null || agent.state === "cleared" ? "idle" : agent.state;
1145
- const target = agent.driver === "human" ? counts : orchestratedCounts;
1146
- target[state] += 1;
1147
- return {
1148
- ...agent,
1149
- fetched_at: fetchedAt,
1150
- // Replica freshness: how long since we last reached the peer. Local rows
1151
- // have no fetched_at and are never stale.
1152
- stale: isStale(fetchedAt, now, STALENESS_MS),
1153
- age_ms: fetchedAt === null ? null : now - fetchedAt,
1154
- // Information age: how long since the agent itself said anything. This
1155
- // is the number a human means by "how stale is that row". A successful
1156
- // fetch of a three-hour-old event resets age_ms to zero but leaves this
1157
- // at three hours, which is why they cannot be the same field.
1158
- event_age_ms: agent.event === null ? null : Math.max(0, now - agent.event.ts),
1159
- // A jump proved this host's tmux was down and nothing has authored since.
1160
- // Stronger than staleness: the host answers, its agents are just gone.
1161
- tmux_down: peer?.tmux_down_at != null,
1162
- // The name the human typed, not the machine's self-reported hostname. A
1163
- // peer added as `linuxpc` reported `18c04d69b860` (a container hostname)
1164
- // and that is what the picker showed — a string that appears nowhere
1165
- // else in the tool and cannot be typed at `peer remove` or searched for.
1166
- // Only the local node, which has no peer row, falls back to its own
1167
- // discovered display_name.
1168
- host: peer?.name ?? (agent.host_id === identity?.host_id ? identity.display_name : agent.host_id)
1169
- };
1170
- });
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
+ }
1171
1690
  return {
1172
1691
  counts,
1173
1692
  orchestrated_counts: orchestratedCounts,
1174
- agents,
1175
- peers: peers.map((peer) => ({
1693
+ panes,
1694
+ peers: store.peers().map((peer) => ({
1176
1695
  name: peer.name,
1177
1696
  display_name: peer.display_name,
1178
1697
  fetched_at: peer.fetched_at,
1179
- // A peer we have never reached is stale, not fresh. `isStale` reads a
1180
- // null `fetched_at` as "local, therefore never stale", which is right
1181
- // for an agent row but backwards for a peer: null there means the very
1182
- // first collect has not succeeded yet. Left to `isStale`, an
1183
- // unreachable host you just added would render as up to date.
1184
- 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"
1185
1708
  }))
1186
1709
  };
1187
1710
  }
1188
- async function statusWithCollect(store, now = Date.now()) {
1711
+ async function statusWithCollect(store, identity, now = Date.now(), channel = ssh) {
1189
1712
  try {
1190
- await collect(store, ssh, now);
1191
- } catch (error) {
1192
- process.stderr.write(
1193
- `murmur: status: collect: ${error instanceof Error ? error.message : String(error)}
1194
- `
1195
- );
1713
+ await collect(store, channel, now);
1714
+ } catch {
1196
1715
  }
1197
- return status(store, now);
1716
+ return status(store, identity, now);
1198
1717
  }
1199
1718
 
1200
1719
  // src/cli/pick.ts
1201
- 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 ?? "";
1202
1726
  var PREVIEW_MESSAGE_MAX = 300;
1203
1727
  var GLYPH = {
1204
1728
  crashed: "\u2717",
@@ -1206,7 +1730,7 @@ var GLYPH = {
1206
1730
  blocked: "!",
1207
1731
  done: "\u2713",
1208
1732
  // ✓
1209
- working: "\u25B6",
1733
+ running: "\u25B6",
1210
1734
  // ▶
1211
1735
  idle: "\xB7"
1212
1736
  // ·
@@ -1215,7 +1739,7 @@ var COLOUR = {
1215
1739
  crashed: "\x1B[31m",
1216
1740
  blocked: "\x1B[33m",
1217
1741
  done: "\x1B[36m",
1218
- working: "\x1B[37m",
1742
+ running: "\x1B[37m",
1219
1743
  idle: "\x1B[90m"
1220
1744
  };
1221
1745
  var ANSI_PATTERN = `${String.fromCharCode(27)}\\[[0-9;]*m`;
@@ -1226,7 +1750,10 @@ var REMOTE = "\x1B[36m";
1226
1750
  var BOLD = "\x1B[1m";
1227
1751
  var DIM = "\x1B[2m";
1228
1752
  var RESET = "\x1B[0m";
1229
- 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
+ }
1230
1757
  var COLUMNS = {
1231
1758
  glyph: 3,
1232
1759
  // marker + state glyph
@@ -1248,25 +1775,23 @@ function headerRow(showHost) {
1248
1775
  ].filter(Boolean).join(" ");
1249
1776
  }
1250
1777
  var FILTER_KEYS = [
1251
- ["ctrl-a", ""],
1778
+ ["alt-x", "crashed"],
1779
+ ["alt-b", "blocked"],
1780
+ ["alt-d", "done"],
1781
+ ["alt-w", "running"]
1782
+ ];
1783
+ var FILTER_ALIASES = [
1252
1784
  ["ctrl-x", "crashed"],
1253
- ["ctrl-b", "blocked"],
1254
1785
  ["ctrl-d", "done"],
1255
- ["ctrl-w", "working"]
1786
+ ["ctrl-w", "running"]
1256
1787
  ];
1257
- function timestamp(ts) {
1788
+ function timestamp2(ts) {
1258
1789
  return new Date(ts).toLocaleTimeString([], {
1259
1790
  hour: "2-digit",
1260
1791
  minute: "2-digit",
1261
1792
  second: "2-digit"
1262
1793
  });
1263
1794
  }
1264
- function age(ms) {
1265
- if (ms === null || ms < 6e4) return "";
1266
- if (ms < 36e5) return `${Math.floor(ms / 6e4)}m`;
1267
- if (ms < 864e5) return `${Math.floor(ms / 36e5)}h`;
1268
- return `${Math.floor(ms / 864e5)}d`;
1269
- }
1270
1795
  function pad(value, width) {
1271
1796
  const visible = [...value.replace(ANSI_ESCAPE, "")].length;
1272
1797
  if (visible <= width) return value + " ".repeat(width - visible);
@@ -1291,8 +1816,8 @@ function pad(value, width) {
1291
1816
  function isPopup(env) {
1292
1817
  return Boolean(env.TMUX) && !env.TMUX_PANE;
1293
1818
  }
1294
- function pickerRow(agent, showHost, current, local = true) {
1295
- const state = agent.state ?? "idle";
1819
+ function pickerRow(agent, showHost, current, local = agent.local) {
1820
+ const state = renderState(agent);
1296
1821
  const colour = COLOUR[state] ?? "";
1297
1822
  const glyph = GLYPH[state] ?? "?";
1298
1823
  const marker = current ? `${BOLD}\u25C6${RESET}` : " ";
@@ -1300,13 +1825,16 @@ function pickerRow(agent, showHost, current, local = true) {
1300
1825
  const host = showHost ? local ? `${DIM} here${RESET}` : `${REMOTE}\u2192 ${terminalText(agent.host)}${RESET}` : "";
1301
1826
  const group = agent.workstream ?? agent.session_name;
1302
1827
  const workstream = group ? `${DIM}${terminalText(group)}${RESET}` : "";
1828
+ const extra = agent.attention.filter((kind) => kind !== state);
1303
1829
  const flags = [
1304
1830
  agent.driver === "orchestrated" ? "crew" : "",
1305
- agent.stale ? "unreachable" : "",
1306
- // A jump already proved this one dead. Say so plainly rather than leaving
1307
- // the row looking merely old, and sort it last.
1308
- agent.tmux_down ? "no tmux" : "",
1309
- 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)
1310
1838
  ].filter(Boolean).join(" ");
1311
1839
  const label = [
1312
1840
  `${marker} ${colour}${glyph}${RESET}`,
@@ -1316,51 +1844,62 @@ function pickerRow(agent, showHost, current, local = true) {
1316
1844
  showHost ? pad(host, COLUMNS.host) : "",
1317
1845
  flags ? `${DIM}${flags}${RESET}` : ""
1318
1846
  ].filter(Boolean).join(" ");
1319
- return `${agent.agent_id} ${label}`;
1847
+ return `${agent.host_id} ${agent.pane} ${label}`;
1320
1848
  }
1321
1849
  function previewText(store, agent) {
1322
- const state = agent.state ?? "idle";
1850
+ const state = renderState(agent);
1323
1851
  const colour = COLOUR[state] ?? "";
1324
1852
  const head = [
1325
1853
  `${colour}${GLYPH[state] ?? "?"} ${state}${RESET} ${BOLD}${agent.agent_name ? terminalText(agent.agent_name) : agentLabel(agent)}${RESET}`,
1326
1854
  // Says where, and whether "where" is this machine. The glance below is a
1327
1855
  // local capture-pane or an ssh depending on this one fact, so it belongs in
1328
1856
  // the header rather than being inferred from a hostname.
1329
- 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}`
1330
1858
  ];
1331
1859
  const facts = [
1860
+ `activity ${agent.activity ?? "none (attention only)"}`,
1861
+ agent.attention.length ? `wants ${agent.attention.join(", ")}` : "",
1332
1862
  agent.workstream ? `stream ${terminalText(agent.workstream)}` : "",
1333
1863
  agent.role ? `role ${terminalText(agent.role)}` : "",
1334
1864
  agent.pi_session ? `session ${terminalText(agent.pi_session)}` : "",
1865
+ agent.cli ? `cli ${terminalText(agent.cli)}` : "",
1335
1866
  agent.driver === "orchestrated" ? "driver orchestrated (crew)" : "",
1336
- 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}` : ""
1337
1872
  ].filter(Boolean);
1338
1873
  const pane = glance(store, agent);
1339
- 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}`];
1340
- const events = store.allEvents().filter((event) => event.agent_id === agent.agent_id).slice(-PREVIEW_EVENTS);
1341
- const history = events.length ? events.map((event) => {
1342
- let message = terminalText(event.message);
1343
- if (message.length > PREVIEW_MESSAGE_MAX) {
1344
- message = `${message.slice(0, PREVIEW_MESSAGE_MAX)}\u2026`;
1345
- }
1346
- const detail = message && message !== event.state ? ` ${message}` : "";
1347
- return `${DIM}${timestamp(event.ts)}${RESET} ${terminalText(event.state).padEnd(8)}${detail}`;
1348
- }) : [`${DIM}no recorded events${RESET}`];
1349
- return [...head, "", ...facts, "", ...live, "", `${DIM}\u2500\u2500 history \u2500\u2500${RESET}`, ...history].join(
1350
- "\n"
1351
- );
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");
1352
1882
  }
1353
- function runPreview(store, agentId) {
1354
- const agent = status(store).agents.find((candidate) => candidate.agent_id === agentId);
1355
- if (!agent) return;
1356
- process.stdout.write(`${previewText(store, agent)}
1357
- `);
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
+ );
1358
1894
  }
1359
- async function runPick(store, options = {}) {
1360
- const identity = loadIdentity();
1361
- const view = await statusWithCollect(store);
1362
- const agents = view.agents.filter((agent2) => options.all || agent2.driver === "human");
1363
- 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;
1364
1903
  if (agents.length === 0) {
1365
1904
  process.stdout.write(
1366
1905
  hidden ? `No human agents (+${hidden} crew \u2014 rerun with --all)
@@ -1368,34 +1907,35 @@ async function runPick(store, options = {}) {
1368
1907
  );
1369
1908
  return;
1370
1909
  }
1371
- const showHost = agents.some((agent2) => agent2.host_id !== identity?.host_id);
1910
+ const showHost = agents.some((agent2) => !agent2.local);
1372
1911
  const currentPane = process.env.TMUX_PANE ?? "";
1373
- const input = agents.map(
1374
- (agent2) => pickerRow(agent2, showHost, agent2.pane === currentPane, agent2.host_id === identity?.host_id)
1375
- ).join("\n");
1912
+ const input = agents.map((agent2) => pickerRow(agent2, showHost, agent2.pane === currentPane)).join("\n");
1376
1913
  const counts = /* @__PURE__ */ new Map();
1377
1914
  for (const agent2 of agents) {
1378
- const state = agent2.state ?? "idle";
1915
+ const state = renderState(agent2);
1379
1916
  counts.set(state, (counts.get(state) ?? 0) + 1);
1380
1917
  }
1381
- 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 ? " " : ""}`;
1382
1920
  const self = process.argv[1] ?? "murmur";
1383
1921
  const allFlag = options.all ? " --all" : "";
1384
1922
  const inPopup = isPopup(process.env);
1385
1923
  const width = process.stdout.columns ?? 0;
1386
1924
  const previewLayout = width > 0 && width < 150 ? "bottom:60%,border-top,wrap" : "right:58%,border-left,wrap";
1387
- const preview = `${process.execPath} ${self} pick --preview {1}`;
1388
- 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]) => [
1389
1930
  "--bind",
1390
- state ? `${key}:change-query(${state})` : `${key}:change-query()`
1931
+ query ? `${key}:change-query(${query})` : `${key}:change-query()`
1391
1932
  ]);
1392
- const result = spawnSync2(
1393
- "fzf",
1933
+ const stdout = fzf(
1394
1934
  [
1395
1935
  "--delimiter",
1396
1936
  " ",
1397
1937
  "--with-nth",
1398
- "2..",
1938
+ "3..",
1399
1939
  "--ansi",
1400
1940
  // Literal substring matching, and matching only the visible columns.
1401
1941
  // Default fuzzy scatters query characters across the row: `re` matched
@@ -1406,7 +1946,7 @@ async function runPick(store, options = {}) {
1406
1946
  "--exact",
1407
1947
  // `begin` ranks earlier match positions higher, so `scratch` puts the
1408
1948
  // scratch workstream above a row that merely mentions it. `index` is the
1409
- // empty-query fallback and preserves the attention order the fold
1949
+ // empty-query fallback and preserves the attention order `viewSort`
1410
1950
  // produced, which is the whole point of the list.
1411
1951
  "--tiebreak",
1412
1952
  "begin,index",
@@ -1424,13 +1964,21 @@ async function runPick(store, options = {}) {
1424
1964
  "--info",
1425
1965
  "inline",
1426
1966
  "--prompt",
1427
- `${prompt}${prompt ? " " : ""}`,
1967
+ `${options.all ? CREW_MARK : ""}${basePrompt}`,
1428
1968
  "--header",
1429
1969
  [
1430
- `enter jump ^r refresh ^p preview del forget filter: ${FILTER_KEYS.map(
1431
- ([key, state]) => `${key.replace("ctrl-", "^")} ${state || "all"}`
1432
- ).join(" ")}`,
1433
- 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`,
1434
1982
  headerRow(showHost)
1435
1983
  ].filter(Boolean).join("\n"),
1436
1984
  "--preview",
@@ -1445,83 +1993,89 @@ async function runPick(store, options = {}) {
1445
1993
  "ctrl-p:change-preview-window(bottom:60%,border-top,wrap|hidden|right:58%,border-left,wrap)",
1446
1994
  "--bind",
1447
1995
  `ctrl-r:reload(${process.execPath} ${self} pick --rows${allFlag})`,
1448
- // 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.
2005
+ //
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.
1449
2011
  //
1450
- // The delete key, not a ctrl chord. ctrl-shift-d does not exist -- a
1451
- // terminal sends the same bytes as ctrl-d -- and ctrl-alt-d, while it
1452
- // does dispatch distinctly, sits one modifier away from ctrl-d in a
1453
- // header that lists both. One is a filter and the other destroys a row,
1454
- // so a near-miss is a deleted agent. `delete` is the key that already
1455
- // means remove this, and it collides with no filter letter.
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.
1456
2015
  "--bind",
1457
- `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})"`,
1458
2017
  ...filterBinds,
1459
2018
  "--no-select-1",
1460
2019
  "--no-exit-0"
1461
2020
  ],
1462
- {
1463
- input,
1464
- encoding: "utf8",
1465
- stdio: ["pipe", "pipe", "inherit"],
1466
- // FZF_DEFAULT_OPTS can carry a conflicting layout or bindings from the
1467
- // user's shell; the old picker stripped it for the same reason.
1468
- env: Object.fromEntries(
1469
- Object.entries(process.env).filter(([key]) => !key.startsWith("FZF_DEFAULT_OPTS"))
1470
- )
1471
- }
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
+ )
1472
2027
  );
1473
- const selected = result.stdout?.trim().split(" ")[0];
2028
+ const [selectedHost, selected] = stdout.trim().split(" ");
1474
2029
  if (!selected) return;
1475
- const agent = agents.find((candidate) => candidate.agent_id === selected);
1476
- if (!agent) return;
1477
- 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);
1478
2040
  if (!jump.ok) {
1479
2041
  process.stderr.write(`${jump.message}
1480
2042
  `);
1481
2043
  process.exitCode = 1;
1482
2044
  }
1483
2045
  }
1484
- async function runForget(store, agentId, options = {}) {
1485
- const view = status(store);
1486
- const agent = view.agents.find((candidate) => candidate.agent_id === agentId);
1487
- if (agent) forgetOneAgent(store, agent);
1488
- await runRows(store, options);
1489
- }
1490
2046
  async function runRows(store, options = {}) {
1491
- const identity = loadIdentity();
1492
- const view = await statusWithCollect(store);
1493
- const agents = view.agents.filter((agent) => options.all || agent.driver === "human");
1494
- 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);
1495
2052
  const currentPane = process.env.TMUX_PANE ?? "";
1496
2053
  for (const agent of agents) {
1497
- process.stdout.write(
1498
- `${pickerRow(agent, showHost, agent.pane === currentPane, agent.host_id === identity?.host_id)}
1499
- `
1500
- );
2054
+ process.stdout.write(`${pickerRow(agent, showHost, agent.pane === currentPane)}
2055
+ `);
1501
2056
  }
1502
2057
  }
1503
2058
  function registerPick(program2) {
1504
- 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(
1505
- async (options) => {
1506
- const store = openStore();
1507
- try {
1508
- if (options.preview) runPreview(store, options.preview);
1509
- else if (options.forget) await runForget(store, options.forget, options);
1510
- else if (options.rows) await runRows(store, options);
1511
- else await runPick(store, options);
1512
- } finally {
1513
- store.close();
1514
- }
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();
1515
2067
  }
1516
- );
2068
+ });
1517
2069
  }
1518
2070
 
1519
2071
  // src/cli/status.ts
1520
2072
  function registerStatus(program2) {
1521
- 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;
1522
2076
  const store = openStore();
1523
2077
  try {
1524
- const view = await statusWithCollect(store);
2078
+ const view = await statusWithCollect(store, identity);
1525
2079
  process.stdout.write(
1526
2080
  options.json ? `${JSON.stringify(view, null, 2)}
1527
2081
  ` : tmuxStatus(view)
@@ -1532,19 +2086,15 @@ function registerStatus(program2) {
1532
2086
  });
1533
2087
  }
1534
2088
 
1535
- // src/index.ts
1536
- import { createRequire } from "module";
1537
- var manifest = createRequire(import.meta.url)("../package.json");
1538
- var VERSION = manifest.version;
1539
-
1540
2089
  // src/cli.ts
1541
2090
  var program = new Command();
1542
- 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);
1543
2092
  registerInit(program);
1544
2093
  registerLink(program);
1545
2094
  registerExport(program);
1546
2095
  registerCollect(program);
1547
2096
  registerClear(program);
2097
+ registerNotify(program);
1548
2098
  registerPeer(program);
1549
2099
  registerStatus(program);
1550
2100
  registerPick(program);