@martintrojer/murmur 0.1.4 → 0.2.1

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