@martintrojer/murmur 0.1.4 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,6 +1,3 @@
1
- // src/index.ts
2
- import { createRequire } from "module";
3
-
4
1
  // src/agents.ts
5
2
  import { spawnSync } from "child_process";
6
3
 
@@ -41,38 +38,15 @@ function hasWarmSocket(target) {
41
38
  }
42
39
  }
43
40
 
44
- // src/identity.ts
45
- import { randomUUID } from "crypto";
46
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
47
- import { hostname } from "os";
48
- import { join as join2 } from "path";
49
-
50
- // src/paths.ts
51
- import { homedir } from "os";
52
- import { join } from "path";
53
- function stateDir() {
54
- return process.env.MURMUR_STATE_DIR ?? join(process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state"), "murmur");
55
- }
56
- function configDir() {
57
- return process.env.MURMUR_CONFIG_DIR ?? join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "murmur");
41
+ // src/ids.ts
42
+ function asSessionId(raw) {
43
+ return raw;
58
44
  }
59
- function dbPath() {
60
- return join(stateDir(), "events.db");
61
- }
62
-
63
- // src/identity.ts
64
- function loadIdentity() {
65
- const path = join2(stateDir(), "identity.json");
66
- return existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) : null;
45
+ function asWindowId(raw) {
46
+ return raw;
67
47
  }
68
- function ensureIdentity(displayName = hostname()) {
69
- const existing = loadIdentity();
70
- if (existing) return existing;
71
- const identity = { host_id: randomUUID(), display_name: displayName };
72
- mkdirSync(stateDir(), { recursive: true });
73
- writeFileSync(join2(stateDir(), "identity.json"), `${JSON.stringify(identity, null, 2)}
74
- `);
75
- return identity;
48
+ function asPaneId(raw) {
49
+ return raw;
76
50
  }
77
51
 
78
52
  // src/mux.ts
@@ -88,10 +62,20 @@ function runTmux(args) {
88
62
  return null;
89
63
  }
90
64
  }
65
+ function exactSession(session) {
66
+ return `=${session}`;
67
+ }
68
+ function exactPaneTarget(session) {
69
+ return `=${session}:`;
70
+ }
71
+ function tmuxBadgeState(state) {
72
+ return state === "running" ? "working" : state;
73
+ }
91
74
  var tmux = {
92
75
  currentWindow() {
93
- const pane = process.env.TMUX_PANE;
94
- if (!pane) return null;
76
+ const raw = process.env.TMUX_PANE;
77
+ if (!raw) return null;
78
+ const pane = asPaneId(raw);
95
79
  const fields = runTmux([
96
80
  "display-message",
97
81
  "-t",
@@ -102,38 +86,31 @@ var tmux = {
102
86
  const [session, window, sessionName, windowName] = fields?.split(" ") ?? [];
103
87
  if (!session || !window) return null;
104
88
  return {
105
- session,
106
- window,
89
+ session: asSessionId(session),
90
+ window: asWindowId(window),
107
91
  pane,
108
92
  session_name: sessionName || null,
109
93
  window_name: windowName || null
110
94
  };
111
95
  },
112
- // Which of this host's windows still exist. Only the authoring node can
113
- // answer this, which is why the check runs on export rather than on the
114
- // reader: a peer holding a `blocked` row for a window that died has nothing
115
- // to supersede it, and the agent stays in every HUD forever.
96
+ // Which of this host's PANES still exist. The only liveness question tmux is
97
+ // ever asked, and the one that matches how an agent is addressed: a pane keeps
98
+ // its id when it moves between windows, so a recorded window id can be gone
99
+ // while the agent is very much alive.
116
100
  //
117
- // null means "could not tell" (no tmux server, tmux missing) and is
118
- // deliberately distinct from an empty set, which means "tmux answered, and
119
- // there are no windows". Treating the first as the second would clear every
120
- // agent on the host the moment tmux was unreachable.
121
- //
122
- // Unlike currentWindow, this deliberately asks tmux rather than reading the
123
- // environment, and it is right to: "which windows exist on this host" is a
124
- // server-wide question with one answer, and export runs over ssh with no
125
- // pane of its own. currentWindow asks "which pane am I in", which only
126
- // $TMUX_PANE can answer.
127
- liveWindows() {
128
- const out = runTmux(["list-windows", "-a", "-F", "#{window_id}"]);
101
+ // null means tmux could not answer; an empty set means it did and there are
102
+ // none. Conflating the two would delete every agent on the host the moment
103
+ // tmux was briefly unreachable.
104
+ livePanes() {
105
+ const out = runTmux(["list-panes", "-a", "-F", "#{pane_id}"]);
129
106
  if (out === null) return null;
130
- return new Set(out.split("\n").filter(Boolean));
107
+ return new Set(out.split("\n").filter(Boolean).map(asPaneId));
131
108
  },
132
- setState(window, state) {
109
+ setWindowBadge(window, state) {
133
110
  if (state === null) {
134
111
  runTmux(["set-window-option", "-qu", "-t", window, "@agent_state"]);
135
112
  } else {
136
- runTmux(["set-window-option", "-q", "-t", window, "@agent_state", state]);
113
+ runTmux(["set-window-option", "-q", "-t", window, "@agent_state", tmuxBadgeState(state)]);
137
114
  runTmux(["set-window-option", "-q", "-t", window, "@pane_agent", "1"]);
138
115
  }
139
116
  runTmux(["refresh-client", "-S"]);
@@ -142,45 +119,51 @@ var tmux = {
142
119
  runTmux(["switch-client", "-t", session]);
143
120
  return runTmux(["select-window", "-t", window]) !== null;
144
121
  },
145
- // Window ids are what the log stores, because they are stable; names are
146
- // what a human recognises in a picker. Names are live tmux state, not
147
- // history, so they are resolved at render time rather than recorded.
148
- windowNames() {
149
- const out = runTmux(["list-windows", "-a", "-F", "#{window_id} #{window_name}"]);
150
- const names = /* @__PURE__ */ new Map();
151
- for (const line of out?.split("\n") ?? []) {
152
- const [id, name] = line.split(" ");
153
- if (id && name) names.set(id, name);
154
- }
155
- return names;
156
- },
157
- // First window carrying this exact name, or null. Used to reuse a per-host
158
- // ssh window instead of opening another one.
159
122
  // Sibling panes, for deciding whether an unowned pane may clear the window's
160
123
  // badge. A window holding an agent and a shell must not lose the badge when
161
124
  // you focus the shell.
162
125
  panesInWindow(window) {
163
126
  const out = runTmux(["list-panes", "-t", window, "-F", "#{pane_id}"]);
164
- return out?.split("\n").filter(Boolean) ?? [];
127
+ return out?.split("\n").filter(Boolean).map(asPaneId) ?? [];
165
128
  },
166
- windowNamed(name) {
167
- const out = runTmux(["list-windows", "-a", "-F", "#{window_id} #{window_name}"]);
168
- for (const line of out?.split("\n") ?? []) {
169
- const [id, windowName] = line.split(" ");
170
- if (id && windowName === name) return id;
171
- }
172
- return null;
129
+ // Which client to send home when the remote attach exits. `switch-client`
130
+ // with no -c moves whichever client tmux considers current, and `murmur pick`
131
+ // usually runs in a popup -- a client of its own, which dies with the popup.
132
+ // Naming the real client is what lets the return outlive the picker.
133
+ clientName() {
134
+ return runTmux(["display-message", "-p", "#{client_name}"]) || null;
173
135
  },
174
- selectWindow(window) {
175
- return runTmux(["select-window", "-t", window]) !== null;
136
+ // Where the jump started, as a switch-client target. Window-level, not just
137
+ // the session: coming back to the right session but the wrong window is
138
+ // still the wrong place. The window id is stable where its index is not,
139
+ // since renumber-windows renumbers on every close.
140
+ currentTarget() {
141
+ return runTmux(["display-message", "-p", "#{session_name}:#{window_id}"]) || null;
142
+ },
143
+ // Whether a wrapper session for this host already exists. Deliberately not
144
+ // returning an id: a session is addressed by name, so a `#{session_id}` would
145
+ // only have to be turned back into one.
146
+ sessionNamed(name) {
147
+ const out = runTmux(["list-sessions", "-F", "#{session_name}"]);
148
+ if (out === null) return false;
149
+ return out.split("\n").includes(name);
150
+ },
151
+ newSession(name, command) {
152
+ return runTmux(["new-session", "-d", "-s", name, command]) !== null;
176
153
  },
177
- newWindow(name, command) {
178
- return runTmux(["new-window", "-n", name, command]) !== null;
154
+ setSessionOption(session, option, value) {
155
+ runTmux(["set-option", "-t", exactPaneTarget(session), option, value]);
179
156
  },
180
- // The window a pane belongs to, for a pane murmur has no event for. Clearing
157
+ switchClient(client, session) {
158
+ const target = exactSession(session);
159
+ const args = client ? ["switch-client", "-c", client, "-t", target] : ["switch-client", "-t", target];
160
+ return runTmux(args) !== null;
161
+ },
162
+ // The window a pane belongs to, for a pane murmur holds no row for. Clearing
181
163
  // a badge is a tmux operation and does not require murmur to own the pane.
182
164
  windowForPane(pane) {
183
- return runTmux(["display-message", "-t", pane, "-p", "#{window_id}"]) || null;
165
+ const out = runTmux(["display-message", "-t", pane, "-p", "#{window_id}"]);
166
+ return out ? asWindowId(out) : null;
184
167
  },
185
168
  capture(pane, lines) {
186
169
  const args = ["capture-pane", "-p", "-t", pane];
@@ -216,6 +199,9 @@ function terminalText(value) {
216
199
  function shellQuote(value) {
217
200
  return `'${value.replaceAll("'", `'\\''`)}'`;
218
201
  }
202
+ function remoteSessionName(peerName) {
203
+ return `${peerName.replace(/^[@$%=]+/, "")}~`;
204
+ }
219
205
  var spawnRunner = (file, args, inherit = false) => {
220
206
  const result = spawnSync(file, args, {
221
207
  encoding: "utf8",
@@ -231,38 +217,14 @@ var spawnRunner = (file, args, inherit = false) => {
231
217
  failed: result.error !== void 0
232
218
  };
233
219
  };
234
- function forgetHostReplica(store, hostId) {
235
- try {
236
- const peer = store.peers().find((candidate) => candidate.host_id === hostId);
237
- store.forgetHost(hostId);
238
- if (peer) {
239
- store.upsertPeer({
240
- name: peer.name,
241
- target: peer.target,
242
- tmux_down_at: Date.now()
243
- });
244
- }
245
- } catch {
246
- }
247
- }
248
- function forgetReplica(store, agentId, hostId) {
249
- try {
250
- store.forgetAgent(agentId);
251
- const peer = store.peers().find((candidate) => candidate.host_id === hostId);
252
- if (peer) store.upsertPeer({ name: peer.name, target: peer.target, watermark: 0 });
253
- } catch {
254
- }
255
- }
256
220
  function jumpToAgent(store, agent, mux = tmux, run = spawnRunner) {
257
- const identity = loadIdentity();
258
- if (agent.host_id === identity?.host_id) {
259
- const live = mux.liveWindows();
260
- if (live && !live.has(agent.window)) {
261
- forgetReplica(store, agent.agent_id, agent.host_id);
221
+ if (agent.local) {
222
+ const panes = mux.livePanes();
223
+ if (panes && !panes.has(agent.pane)) {
262
224
  return {
263
225
  ok: false,
264
- reason: "window_gone",
265
- message: `${agentLabel(agent)} is gone -- its window no longer exists. Cleared.`
226
+ reason: "pane_gone",
227
+ message: `${agentLabel(agent)} is gone -- its pane no longer exists.`
266
228
  };
267
229
  }
268
230
  if (!mux.attach(agent.session, agent.window)) {
@@ -286,7 +248,7 @@ function jumpToAgent(store, agent, mux = tmux, run = spawnRunner) {
286
248
  const probe = run("ssh", [
287
249
  ...SSH_OPTIONS,
288
250
  target,
289
- `tmux list-windows -a -F ${shellQuote("#{window_id}")}`
251
+ `tmux list-panes -a -F ${shellQuote("#{pane_id}")}`
290
252
  ]);
291
253
  if (probe.status !== 0) {
292
254
  const sshFailed = probe.status === 255 || probe.failed;
@@ -297,38 +259,48 @@ function jumpToAgent(store, agent, mux = tmux, run = spawnRunner) {
297
259
  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.`
298
260
  };
299
261
  }
300
- forgetHostReplica(store, agent.host_id);
301
262
  return {
302
263
  ok: false,
303
264
  reason: "no_tmux",
304
- message: `${target} has no tmux server running, so its agents are gone. Removed them; they will come back when it reports again.`
265
+ message: `${target} has no tmux server running, so its agents are gone. They will disappear on the next collect.`
305
266
  };
306
267
  }
307
- const remoteWindows = new Set(probe.stdout.split("\n").filter(Boolean));
308
- if (!remoteWindows.has(agent.window)) {
309
- forgetReplica(store, agent.agent_id, agent.host_id);
268
+ const remotePanes = new Set(probe.stdout.split("\n").filter(Boolean).map(asPaneId));
269
+ if (!remotePanes.has(agent.pane)) {
310
270
  return {
311
271
  ok: false,
312
- reason: "window_gone",
313
- message: `${agentLabel(agent)} is gone -- ${target} no longer has that window. Cleared.`
272
+ reason: "pane_gone",
273
+ message: `${agentLabel(agent)} is gone -- ${target} no longer has that pane.`
314
274
  };
315
275
  }
316
276
  const attachTarget = shellQuote(`${agent.session}:${agent.window}`);
317
277
  if (process.env.TMUX) {
318
- const command = `ssh -t ${shellQuote(target)} tmux attach -t ${shellQuote(attachTarget)}`;
319
- const name = `@${peer?.name ?? target}`;
320
- const existing = mux.windowNamed(name);
321
- if (existing) {
322
- return mux.selectWindow(existing) ? { ok: true } : {
278
+ const client = mux.clientName();
279
+ const origin = mux.currentTarget();
280
+ const name = remoteSessionName(peer?.name ?? target);
281
+ if (mux.sessionNamed(name)) {
282
+ return mux.switchClient(client, name) ? { ok: true } : {
323
283
  ok: false,
324
284
  reason: "attach_failed",
325
- message: `could not switch to the existing ${name} window.`
285
+ message: `could not switch to the existing ${name} session.`
326
286
  };
327
287
  }
328
- return mux.newWindow(name, command) ? { ok: true } : {
288
+ const attach2 = `ssh -t ${shellQuote(target)} tmux attach -t ${shellQuote(attachTarget)}`;
289
+ const restore = origin ? `; tmux switch-client ${client ? `-c ${shellQuote(client)} ` : ""}-t ${shellQuote(`=${origin}`)}` : "";
290
+ if (!mux.newSession(name, `${attach2}${restore}`)) {
291
+ return {
292
+ ok: false,
293
+ reason: "attach_failed",
294
+ message: `could not open a session to attach to ${target}.`
295
+ };
296
+ }
297
+ mux.setSessionOption(name, "status", "off");
298
+ mux.setSessionOption(name, "prefix", "None");
299
+ mux.setSessionOption(name, "detach-on-destroy", "previous");
300
+ return mux.switchClient(client, name) ? { ok: true } : {
329
301
  ok: false,
330
302
  reason: "attach_failed",
331
- message: `could not open a window to attach to ${target}.`
303
+ message: `attached to ${target} in session ${name}, but could not switch to it.`
332
304
  };
333
305
  }
334
306
  const attach = run("ssh", ["-t", target, "tmux", "attach", "-t", attachTarget], true);
@@ -339,193 +311,269 @@ function jumpToAgent(store, agent, mux = tmux, run = spawnRunner) {
339
311
  };
340
312
  }
341
313
 
342
- // src/types.ts
343
- var DEFAULT_DRIVER = "human";
344
-
345
- // src/fold.ts
346
- function foldAgent(events, isAlive) {
347
- for (let index = events.length - 1; index >= 0; index -= 1) {
348
- const event = events[index];
349
- if (!event) continue;
350
- switch (event.state) {
351
- case "blocked":
352
- case "done":
353
- case "crashed":
354
- return { state: event.state, event };
355
- case "cleared":
356
- return { state: null, event: null };
357
- case "working":
358
- return {
359
- state: event.pid !== null && event.pid > 0 && isAlive(event.pid) ? "working" : "crashed",
360
- event
361
- };
362
- }
314
+ // src/snapshot.ts
315
+ var SnapshotInvalidError = class extends Error {
316
+ constructor(path, detail) {
317
+ super(path === "" ? detail : `${path}: ${detail}`);
318
+ this.path = path;
319
+ this.name = "SnapshotInvalidError";
363
320
  }
364
- return { state: null, event: null };
321
+ path;
322
+ };
323
+ function fail(path, detail) {
324
+ throw new SnapshotInvalidError(path, detail);
365
325
  }
366
- function foldAll(events, isAlive) {
367
- const byAgent = /* @__PURE__ */ new Map();
368
- for (const event of events) {
369
- const agentEvents = byAgent.get(event.agent_id);
370
- if (agentEvents) agentEvents.push(event);
371
- else byAgent.set(event.agent_id, [event]);
326
+ function object(value, path, keys) {
327
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
328
+ fail(path, "expected an object");
372
329
  }
373
- return [...byAgent.values()].map((agentEvents) => {
374
- const folded = foldAgent(agentEvents, isAlive);
375
- const source = folded.event ?? agentEvents[agentEvents.length - 1];
376
- if (!source) throw new Error("agent event group cannot be empty");
377
- return {
378
- agent_id: source.agent_id,
379
- host_id: source.host_id,
380
- state: folded.state,
381
- event: folded.event,
382
- workstream: source.workstream,
383
- role: source.role,
384
- cli: source.cli,
385
- driver: source.driver ?? DEFAULT_DRIVER,
386
- session: source.session,
387
- window: source.window,
388
- pane: source.pane,
389
- session_name: source.session_name,
390
- window_name: source.window_name,
391
- agent_name: source.agent_name,
392
- pi_session: source.pi_session,
393
- fetched_at: null
394
- };
395
- });
330
+ const record = value;
331
+ for (const key of keys) if (!(key in record)) fail(path, `missing key ${key}`);
332
+ for (const key of Object.keys(record)) {
333
+ if (!keys.includes(key)) fail(path, `unknown key ${key}`);
334
+ }
335
+ return record;
396
336
  }
397
- var ATTENTION_ORDER = {
398
- blocked: 0,
399
- done: 1,
400
- crashed: 2,
401
- working: 3,
402
- cleared: 4
403
- };
404
- function attentionSort(views) {
405
- return [...views].sort((left, right) => {
406
- const stateOrder = (left.state === null ? 4 : ATTENTION_ORDER[left.state]) - (right.state === null ? 4 : ATTENTION_ORDER[right.state]);
407
- if (stateOrder !== 0) return stateOrder;
408
- return (right.event?.ts ?? 0) - (left.event?.ts ?? 0);
409
- });
337
+ function text(value, path) {
338
+ if (typeof value !== "string" || value === "") fail(path, "expected a non-empty string");
339
+ return value;
410
340
  }
411
- function isStale(fetchedAt, now, thresholdMs = 6e4) {
412
- return fetchedAt !== null && now - fetchedAt > thresholdMs;
341
+ function textOrNull(value, path) {
342
+ if (value === null) return null;
343
+ if (typeof value !== "string") fail(path, "expected a string or null");
344
+ return value;
413
345
  }
414
-
415
- // src/export.ts
416
- var SCHEMA_VERSION = 2;
417
- var EVENT_FIELDS = /* @__PURE__ */ new Set([
346
+ function anyText(value, path) {
347
+ if (typeof value !== "string") fail(path, "expected a string");
348
+ return value;
349
+ }
350
+ function timestamp(value, path) {
351
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 0) {
352
+ fail(path, "expected a non-negative integer");
353
+ }
354
+ return value;
355
+ }
356
+ function member(value, path, allowed) {
357
+ if (typeof value !== "string" || !allowed.includes(value)) {
358
+ fail(path, `expected one of ${allowed.join(", ")}`);
359
+ }
360
+ return value;
361
+ }
362
+ var ACTIVITIES = ["running", "stopped"];
363
+ var DRIVERS = ["human", "orchestrated"];
364
+ var KINDS = ["done", "blocked", "crashed"];
365
+ var TOP_KEYS = [
366
+ "murmur_snapshot",
418
367
  "host_id",
419
- "seq",
420
- "ts",
421
- "agent_id",
368
+ "display_name",
369
+ "murmur_version",
370
+ "generated_at",
371
+ "panes"
372
+ ];
373
+ var PANE_KEYS = [
374
+ "pane",
422
375
  "session",
423
376
  "window",
424
- "pane",
425
377
  "session_name",
426
378
  "window_name",
379
+ "agent",
380
+ "attention"
381
+ ];
382
+ var AGENT_KEYS = [
383
+ "agent_id",
384
+ "activity",
427
385
  "agent_name",
428
386
  "pi_session",
429
387
  "workstream",
430
388
  "role",
431
389
  "cli",
432
390
  "driver",
433
- "kind",
434
- "state",
435
- "message",
436
- "pid",
437
- "synthetic",
438
- "reason"
439
- ]);
440
- function eventToWire(event) {
441
- const { extra, ...known } = event;
442
- return { ...extra, ...known };
443
- }
444
- function eventFromWire(wire) {
445
- const extra = Object.fromEntries(Object.entries(wire).filter(([key]) => !EVENT_FIELDS.has(key)));
391
+ "claimed_at",
392
+ "updated_at"
393
+ ];
394
+ var ATTENTION_KEYS = ["kind", "message", "source", "requested_at"];
395
+ function parseAgent(value, path) {
396
+ if (value === null) return null;
397
+ const row = object(value, path, AGENT_KEYS);
446
398
  return {
447
- host_id: wire.host_id,
448
- seq: wire.seq,
449
- ts: wire.ts,
450
- agent_id: wire.agent_id,
451
- session: wire.session,
452
- window: wire.window,
453
- pane: wire.pane,
454
- session_name: wire.session_name ?? null,
455
- window_name: wire.window_name ?? null,
456
- agent_name: wire.agent_name ?? null,
457
- pi_session: wire.pi_session ?? null,
458
- workstream: wire.workstream ?? null,
459
- role: wire.role ?? null,
460
- cli: wire.cli ?? null,
461
- driver: wire.driver ?? null,
462
- kind: wire.kind,
463
- state: wire.state,
464
- message: wire.message,
465
- pid: wire.pid ?? null,
466
- synthetic: wire.synthetic,
467
- reason: wire.reason,
468
- extra
399
+ agent_id: text(row.agent_id, `${path}.agent_id`),
400
+ activity: member(row.activity, `${path}.activity`, ACTIVITIES),
401
+ agent_name: textOrNull(row.agent_name, `${path}.agent_name`),
402
+ pi_session: textOrNull(row.pi_session, `${path}.pi_session`),
403
+ workstream: textOrNull(row.workstream, `${path}.workstream`),
404
+ role: textOrNull(row.role, `${path}.role`),
405
+ cli: text(row.cli, `${path}.cli`),
406
+ driver: member(row.driver, `${path}.driver`, DRIVERS),
407
+ claimed_at: timestamp(row.claimed_at, `${path}.claimed_at`),
408
+ updated_at: timestamp(row.updated_at, `${path}.updated_at`)
469
409
  };
470
410
  }
471
- function synthesizeCrashes(store, hostId, isAlive) {
472
- const byAgent = /* @__PURE__ */ new Map();
473
- for (const event of store.allEvents()) {
474
- if (event.host_id !== hostId) continue;
475
- const events = byAgent.get(event.agent_id);
476
- if (events) events.push(event);
477
- else byAgent.set(event.agent_id, [event]);
478
- }
479
- for (const events of byAgent.values()) {
480
- events.sort((left, right) => left.seq - right.seq);
481
- const newest = events.at(-1);
482
- if (newest && newest.state === "working" && !newest.synthetic && foldAgent(events, isAlive).state === "crashed") {
483
- const { host_id: _hostId, seq: _seq, ts: _ts, ...event } = newest;
484
- store.append({ ...event, state: "crashed", synthetic: true, reason: "pid_gone" });
485
- }
411
+ function parseAttention(value, path) {
412
+ if (!Array.isArray(value)) fail(path, "expected an array");
413
+ const seen = /* @__PURE__ */ new Set();
414
+ return value.map((entry, index) => {
415
+ const at = `${path}[${index}]`;
416
+ const row = object(entry, at, ATTENTION_KEYS);
417
+ const kind = member(row.kind, `${at}.kind`, KINDS);
418
+ if (seen.has(kind)) fail(`${at}.kind`, `duplicate kind ${kind} for this pane`);
419
+ seen.add(kind);
420
+ return {
421
+ kind,
422
+ message: anyText(row.message, `${at}.message`),
423
+ source: anyText(row.source, `${at}.source`),
424
+ requested_at: timestamp(row.requested_at, `${at}.requested_at`)
425
+ };
426
+ });
427
+ }
428
+ function parsePane(value, path) {
429
+ const row = object(value, path, PANE_KEYS);
430
+ const agent = parseAgent(row.agent, `${path}.agent`);
431
+ const attention = parseAttention(row.attention, `${path}.attention`);
432
+ if (agent === null && attention.length === 0) {
433
+ fail(path, "a pane with no agent and no attention must not be emitted");
486
434
  }
435
+ return {
436
+ pane: asPaneId(text(row.pane, `${path}.pane`)),
437
+ session: asSessionId(text(row.session, `${path}.session`)),
438
+ window: asWindowId(text(row.window, `${path}.window`)),
439
+ session_name: textOrNull(row.session_name, `${path}.session_name`),
440
+ window_name: textOrNull(row.window_name, `${path}.window_name`),
441
+ agent,
442
+ attention
443
+ };
487
444
  }
488
- function clearDeadWindows(store, hostId, live) {
489
- if (live === null) return;
490
- const newest = /* @__PURE__ */ new Map();
491
- for (const event of store.allEvents()) {
492
- if (event.host_id !== hostId) continue;
493
- const previous = newest.get(event.agent_id);
494
- if (!previous || event.seq > previous.seq) newest.set(event.agent_id, event);
495
- }
496
- for (const event of newest.values()) {
497
- if (event.state === "cleared") continue;
498
- if (live.has(event.window)) continue;
499
- const { host_id: _hostId, seq: _seq, ts: _ts, ...rest } = event;
500
- store.append({
501
- ...rest,
502
- state: "cleared",
503
- synthetic: true,
504
- reason: "window_gone",
505
- message: ""
506
- });
445
+ function parseSnapshot(input) {
446
+ let parsed;
447
+ try {
448
+ parsed = JSON.parse(input);
449
+ } catch (error) {
450
+ fail("", `not JSON (${error instanceof Error ? error.message : String(error)})`);
451
+ }
452
+ const top = object(parsed, "", TOP_KEYS);
453
+ if (top.murmur_snapshot !== 1) {
454
+ fail("murmur_snapshot", `expected 1, got ${JSON.stringify(top.murmur_snapshot)}`);
455
+ }
456
+ if (!Array.isArray(top.panes)) fail("panes", "expected an array");
457
+ const panes = top.panes.map((entry, index) => parsePane(entry, `panes[${index}]`));
458
+ const seen = /* @__PURE__ */ new Set();
459
+ for (const pane of panes) {
460
+ if (seen.has(pane.pane)) fail("panes", `duplicate pane ${pane.pane}`);
461
+ seen.add(pane.pane);
462
+ }
463
+ return {
464
+ murmur_snapshot: 1,
465
+ host_id: text(top.host_id, "host_id"),
466
+ display_name: text(top.display_name, "display_name"),
467
+ murmur_version: text(top.murmur_version, "murmur_version"),
468
+ generated_at: timestamp(top.generated_at, "generated_at"),
469
+ panes
470
+ };
471
+ }
472
+
473
+ // src/types.ts
474
+ var DEFAULT_DRIVER = "human";
475
+
476
+ // src/view.ts
477
+ var RENDER_PRIORITY = [
478
+ "crashed",
479
+ "blocked",
480
+ "done",
481
+ "running",
482
+ "idle"
483
+ ];
484
+ var NEEDS_HUMAN = ["blocked", "crashed"];
485
+ var STALENESS_MS = 6e4;
486
+ function age(ms) {
487
+ if (ms === null || ms < 6e4) return "";
488
+ if (ms < 36e5) return `${Math.floor(ms / 6e4)}m`;
489
+ if (ms < 864e5) return `${Math.floor(ms / 36e5)}h`;
490
+ return `${Math.floor(ms / 864e5)}d`;
491
+ }
492
+ function freshness(fetchedAt, now, thresholdMs = STALENESS_MS) {
493
+ return fetchedAt !== null && now - fetchedAt <= thresholdMs ? "fresh" : "stale";
494
+ }
495
+ function renderState(view) {
496
+ for (const kind of ["crashed", "blocked", "done"]) {
497
+ if (view.attention.includes(kind)) return kind;
498
+ }
499
+ return view.activity === "running" ? "running" : "idle";
500
+ }
501
+ function newestAttention(pane) {
502
+ let newest = null;
503
+ for (const entry of pane.attention) {
504
+ if (newest === null || entry.requested_at > newest) newest = entry.requested_at;
507
505
  }
506
+ return newest;
508
507
  }
509
- function exportJsonl(store, since, isAlive, live) {
510
- const identity = ensureIdentity();
511
- synthesizeCrashes(store, identity.host_id, isAlive);
512
- if (live !== void 0) clearDeadWindows(store, identity.host_id, live);
513
- const envelope = {
514
- schema_version: SCHEMA_VERSION,
515
- host_id: identity.host_id,
516
- display_name: identity.display_name,
517
- exported_at: Date.now()
508
+ function paneView(pane, source) {
509
+ const agent = pane.agent;
510
+ return {
511
+ host_id: source.host_id,
512
+ host: source.host,
513
+ local: source.local,
514
+ pane: pane.pane,
515
+ session: pane.session,
516
+ window: pane.window,
517
+ session_name: pane.session_name,
518
+ window_name: pane.window_name,
519
+ activity: agent?.activity ?? null,
520
+ attention: pane.attention.map((entry) => entry.kind),
521
+ freshness: source.freshness,
522
+ agent_id: agent?.agent_id ?? null,
523
+ agent_name: agent?.agent_name ?? null,
524
+ pi_session: agent?.pi_session ?? null,
525
+ workstream: agent?.workstream ?? null,
526
+ role: agent?.role ?? null,
527
+ cli: agent?.cli ?? null,
528
+ driver: agent?.driver ?? DEFAULT_DRIVER,
529
+ updated_at: agent?.updated_at ?? newestAttention(pane),
530
+ snapshot_at: source.snapshot_at,
531
+ fetched_at: source.fetched_at
518
532
  };
519
- const lines = [
520
- JSON.stringify(envelope),
521
- ...store.eventsSince(identity.host_id, since).map((event) => JSON.stringify(eventToWire(event)))
522
- ];
523
- return `${lines.join("\n")}
524
- `;
533
+ }
534
+ function paneViews(store, identity, now = Date.now()) {
535
+ const views = store.localPanes().map(
536
+ (pane) => paneView(pane, {
537
+ host_id: identity.host_id,
538
+ host: identity.display_name,
539
+ local: true,
540
+ // Local panes are always fresh: we are the node that authored them.
541
+ freshness: "fresh",
542
+ snapshot_at: null,
543
+ fetched_at: null
544
+ })
545
+ );
546
+ for (const peer of store.peers()) {
547
+ const snapshot = peer.snapshot;
548
+ if (!snapshot) continue;
549
+ const source = {
550
+ host_id: snapshot.host_id,
551
+ // The name the human typed, not the machine's self-reported hostname: a
552
+ // peer added as `linuxpc` can report a container id, which appears
553
+ // nowhere else in the tool and cannot be typed at `peer remove`.
554
+ host: peer.name,
555
+ local: false,
556
+ freshness: freshness(peer.fetched_at, now),
557
+ snapshot_at: peer.snapshot_at,
558
+ fetched_at: peer.fetched_at
559
+ };
560
+ for (const pane of snapshot.panes) views.push(paneView(pane, source));
561
+ }
562
+ return views;
563
+ }
564
+ var ORDER = new Map(RENDER_PRIORITY.map((state, index) => [state, index]));
565
+ function viewSort(views) {
566
+ return [...views].sort((left, right) => {
567
+ const byState = (ORDER.get(renderState(left)) ?? 99) - (ORDER.get(renderState(right)) ?? 99);
568
+ if (byState !== 0) return byState;
569
+ const byAge = (right.updated_at ?? 0) - (left.updated_at ?? 0);
570
+ if (byAge !== 0) return byAge;
571
+ const byHost = left.host.localeCompare(right.host);
572
+ return byHost !== 0 ? byHost : left.pane.localeCompare(right.pane);
573
+ });
525
574
  }
526
575
 
527
576
  // src/collector.ts
528
- var STALENESS_MS = 6e4;
529
577
  var MAX_CONCURRENT_PEERS = 8;
530
578
  var COLLECT_DEADLINE_MS = 4e3;
531
579
  async function mapSettled(items, limit, task, deadline) {
@@ -549,18 +597,19 @@ async function mapSettled(items, limit, task, deadline) {
549
597
  await (stop ? Promise.race([pool, stop]) : pool);
550
598
  return results;
551
599
  }
552
- function parseJsonl(output) {
553
- const lines = output.trim().split("\n");
554
- const envelope = JSON.parse(lines.shift() ?? "");
555
- if (envelope.schema_version > SCHEMA_VERSION) {
556
- throw new Error(
557
- `unsupported schema version ${envelope.schema_version} (supports ${SCHEMA_VERSION})`
558
- );
559
- }
560
- return {
561
- envelope,
562
- events: lines.map((line) => eventFromWire(JSON.parse(line)))
563
- };
600
+ function isUnreachable(message) {
601
+ 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(
602
+ message
603
+ ) || /\bssh:/.test(message);
604
+ }
605
+ function stripInvocation(message) {
606
+ const firstLine = message.indexOf("\n");
607
+ if (firstLine === -1 || !message.startsWith("Command failed:")) return message;
608
+ const rest = message.slice(firstLine + 1).trim();
609
+ return rest === "" ? message : rest;
610
+ }
611
+ function normalizeFailure(message) {
612
+ return stripInvocation(message).replace(/\s+/g, " ").trim();
564
613
  }
565
614
  async function collect(store, channel, now = Date.now(), deadline) {
566
615
  const results = [];
@@ -574,9 +623,7 @@ async function collect(store, channel, now = Date.now(), deadline) {
574
623
  const fetches = await mapSettled(
575
624
  peers,
576
625
  MAX_CONCURRENT_PEERS,
577
- async (peer) => parseJsonl(
578
- await channel.exec(peer.target, ["murmur", "export", "--since", String(peer.watermark)])
579
- ),
626
+ async (peer) => parseSnapshot(await channel.exec(peer.target, ["murmur", "export"])),
580
627
  bounded
581
628
  );
582
629
  for (const [index, peer] of peers.entries()) {
@@ -584,58 +631,35 @@ async function collect(store, channel, now = Date.now(), deadline) {
584
631
  try {
585
632
  if (!fetch) throw new Error("collect deadline passed before this peer answered");
586
633
  if (fetch.status === "rejected") throw fetch.reason;
587
- const { envelope, events } = fetch.value;
588
- const ingested = store.ingest(events);
589
- const origin = events.filter((event) => event.host_id === envelope.host_id);
590
- const watermark = origin.reduce(
591
- (highest, event) => Math.max(highest, event.seq),
592
- peer.watermark
593
- );
594
- store.upsertPeer({
595
- name: peer.name,
596
- target: peer.target,
597
- host_id: envelope.host_id,
598
- display_name: envelope.display_name,
599
- watermark,
600
- fetched_at: now,
601
- // New events mean the node is authoring again, so whatever a jump
602
- // observed about its tmux is out of date. Only clear on actual new
603
- // events: an export that returns nothing proves the binary ran, not
604
- // that tmux is back, which is the distinction that let a dead host
605
- // look healthy for three hours.
606
- //
607
- // Keyed on the watermark advancing, not on ingest's insert count.
608
- // Two reasons the count was wrong. Ingest is INSERT OR IGNORE, so a
609
- // retry after a partial apply re-sees the same events and reports
610
- // zero -- leaving a recovered host marked down until it happened to
611
- // author again. And the count includes rows from other origins that
612
- // this peer merely relayed, which say nothing about whether this
613
- // peer's tmux is back.
614
- tmux_down_at: watermark > peer.watermark ? null : peer.tmux_down_at
615
- });
616
- results.push({ peer: peer.name, ok: true, ingested });
634
+ store.replacePeerSnapshot(peer.name, { ok: true, snapshot: fetch.value, at: now });
635
+ results.push({ peer: peer.name, ok: true, panes: fetch.value.panes.length });
617
636
  } catch (error) {
618
- const message = error instanceof Error ? error.message : String(error);
619
- process.stderr.write(`murmur: collect: peer ${peer.name}: ${message}
620
- `);
621
- results.push({ peer: peer.name, ok: false, ingested: 0, error: message });
637
+ const message = normalizeFailure(error instanceof Error ? error.message : String(error));
638
+ store.replacePeerSnapshot(peer.name, { ok: false, error: message, at: now });
639
+ results.push({
640
+ peer: peer.name,
641
+ ok: false,
642
+ panes: 0,
643
+ error: message,
644
+ // A peer that answered with a bad document is reachable but broken,
645
+ // and must be visibly so rather than silently stale.
646
+ unreachable: error instanceof SnapshotInvalidError ? false : isUnreachable(normalizeFailure(message))
647
+ });
622
648
  }
623
649
  }
624
650
  } catch (error) {
625
- process.stderr.write(
626
- `murmur: collect: ${error instanceof Error ? error.message : String(error)}
627
- `
628
- );
651
+ results.push({
652
+ peer: "",
653
+ ok: false,
654
+ panes: 0,
655
+ error: error instanceof Error ? error.message : String(error)
656
+ });
629
657
  } finally {
630
658
  clearTimeout(timer);
631
659
  }
632
660
  try {
633
- store.prune();
634
- } catch (error) {
635
- process.stderr.write(
636
- `murmur: collect: prune: ${error instanceof Error ? error.message : String(error)}
637
- `
638
- );
661
+ store.reconcileLocal({ panes: tmux.livePanes(), now });
662
+ } catch {
639
663
  }
640
664
  return results;
641
665
  }
@@ -644,7 +668,7 @@ async function collect(store, channel, now = Date.now(), deadline) {
644
668
  import { execFileSync as execFileSync3 } from "child_process";
645
669
  var GLANCE_LINES = 40;
646
670
  function glance(store, agent, lines = GLANCE_LINES) {
647
- if (agent.host_id === loadIdentity()?.host_id) return tmux.capture(agent.pane, lines);
671
+ if (agent.local) return tmux.capture(agent.pane, lines);
648
672
  const peer = store.peers().find((candidate) => candidate.host_id === agent.host_id);
649
673
  const target = peer?.target ?? peer?.name;
650
674
  if (!target) return null;
@@ -669,337 +693,554 @@ function glance(store, agent, lines = GLANCE_LINES) {
669
693
  }
670
694
  }
671
695
 
696
+ // src/identity.ts
697
+ import { randomUUID } from "crypto";
698
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
699
+ import { hostname } from "os";
700
+ import { join as join2 } from "path";
701
+
702
+ // src/paths.ts
703
+ import { homedir } from "os";
704
+ import { join } from "path";
705
+ function stateDir() {
706
+ return process.env.MURMUR_STATE_DIR ?? join(process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state"), "murmur");
707
+ }
708
+ function configDir() {
709
+ return process.env.MURMUR_CONFIG_DIR ?? join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "murmur");
710
+ }
711
+ function dbPath() {
712
+ return join(stateDir(), "state.db");
713
+ }
714
+
715
+ // src/identity.ts
716
+ function identityPath() {
717
+ return join2(stateDir(), "identity.json");
718
+ }
719
+ var cache = null;
720
+ function loadIdentity() {
721
+ const path = identityPath();
722
+ if (cache?.path === path) return cache.identity;
723
+ const identity = existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) : null;
724
+ cache = { path, identity };
725
+ return identity;
726
+ }
727
+ function write(identity) {
728
+ mkdirSync(stateDir(), { recursive: true });
729
+ writeFileSync(identityPath(), `${JSON.stringify(identity, null, 2)}
730
+ `);
731
+ cache = { path: identityPath(), identity };
732
+ return identity;
733
+ }
734
+ function createIdentity(displayName = hostname()) {
735
+ if (loadIdentity()) throw new Error(`identity already exists: ${identityPath()}`);
736
+ return write({ host_id: randomUUID(), display_name: displayName });
737
+ }
738
+ function setDisplayName(displayName) {
739
+ const existing = loadIdentity();
740
+ return write(
741
+ existing ? { host_id: existing.host_id, display_name: displayName } : { host_id: randomUUID(), display_name: displayName }
742
+ );
743
+ }
744
+
672
745
  // src/status.ts
673
746
  function emptyCounts() {
674
- return { working: 0, blocked: 0, done: 0, crashed: 0, idle: 0 };
747
+ const counts = {};
748
+ for (const state of RENDER_PRIORITY) counts[state] = 0;
749
+ return counts;
675
750
  }
676
- function status(store, now = Date.now()) {
677
- const identity = loadIdentity();
678
- const peers = store.peers();
679
- const peersByHost = new Map(
680
- peers.flatMap((peer) => peer.host_id === null ? [] : [[peer.host_id, peer]])
681
- );
682
- const events = store.allEvents();
683
- const local = foldAll(
684
- events.filter((event) => event.host_id === identity?.host_id),
685
- pidAlive
686
- );
687
- const remote = foldAll(
688
- events.filter((event) => event.host_id !== identity?.host_id),
689
- () => true
690
- );
751
+ function tmuxStatus(view) {
752
+ const needsHuman = new Set(NEEDS_HUMAN);
753
+ const total = (state) => view.counts[state] + (needsHuman.has(state) ? view.orchestrated_counts[state] : 0);
754
+ return RENDER_PRIORITY.filter((state) => total(state) > 0).map((state) => `${state === "running" ? "working" : state} ${total(state)}
755
+ `).join("");
756
+ }
757
+ function status(store, identity, now = Date.now()) {
691
758
  const counts = emptyCounts();
692
759
  const orchestratedCounts = emptyCounts();
693
- const agents = attentionSort([...local, ...remote]).map((agent) => {
694
- const peer = peersByHost.get(agent.host_id);
695
- const fetchedAt = peer?.fetched_at ?? null;
696
- const state = agent.state === null || agent.state === "cleared" ? "idle" : agent.state;
697
- const target = agent.driver === "human" ? counts : orchestratedCounts;
698
- target[state] += 1;
699
- return {
700
- ...agent,
701
- fetched_at: fetchedAt,
702
- // Replica freshness: how long since we last reached the peer. Local rows
703
- // have no fetched_at and are never stale.
704
- stale: isStale(fetchedAt, now, STALENESS_MS),
705
- age_ms: fetchedAt === null ? null : now - fetchedAt,
706
- // Information age: how long since the agent itself said anything. This
707
- // is the number a human means by "how stale is that row". A successful
708
- // fetch of a three-hour-old event resets age_ms to zero but leaves this
709
- // at three hours, which is why they cannot be the same field.
710
- event_age_ms: agent.event === null ? null : Math.max(0, now - agent.event.ts),
711
- // A jump proved this host's tmux was down and nothing has authored since.
712
- // Stronger than staleness: the host answers, its agents are just gone.
713
- tmux_down: peer?.tmux_down_at != null,
714
- // The name the human typed, not the machine's self-reported hostname. A
715
- // peer added as `linuxpc` reported `18c04d69b860` (a container hostname)
716
- // and that is what the picker showed — a string that appears nowhere
717
- // else in the tool and cannot be typed at `peer remove` or searched for.
718
- // Only the local node, which has no peer row, falls back to its own
719
- // discovered display_name.
720
- host: peer?.name ?? (agent.host_id === identity?.host_id ? identity.display_name : agent.host_id)
721
- };
722
- });
760
+ const panes = viewSort(paneViews(store, identity, now));
761
+ for (const pane of panes) {
762
+ const target = pane.driver === "human" ? counts : orchestratedCounts;
763
+ target[renderState(pane)] += 1;
764
+ }
723
765
  return {
724
766
  counts,
725
767
  orchestrated_counts: orchestratedCounts,
726
- agents,
727
- peers: peers.map((peer) => ({
768
+ panes,
769
+ peers: store.peers().map((peer) => ({
728
770
  name: peer.name,
729
771
  display_name: peer.display_name,
730
772
  fetched_at: peer.fetched_at,
731
- // A peer we have never reached is stale, not fresh. `isStale` reads a
732
- // null `fetched_at` as "local, therefore never stale", which is right
733
- // for an agent row but backwards for a peer: null there means the very
734
- // first collect has not succeeded yet. Left to `isStale`, an
735
- // unreachable host you just added would render as up to date.
736
- stale: peer.fetched_at === null || isStale(peer.fetched_at, now, STALENESS_MS)
773
+ // Their clock and ours, separately: a peer polled a second ago can be
774
+ // serving a three-hour-old fact, and one number cannot say both.
775
+ snapshot_at: peer.snapshot_at,
776
+ last_error: peer.last_error,
777
+ // The view's verdict, not a second threshold spelled the same way. A
778
+ // peer we have never reached is stale rather than fresh -- null
779
+ // `fetched_at` means the first collect has not succeeded yet -- and
780
+ // `freshness` is the one place that decides, so this list and the panes
781
+ // the peer contributes cannot disagree about the same host.
782
+ stale: freshness(peer.fetched_at, now) === "stale"
737
783
  }))
738
784
  };
739
785
  }
786
+ async function statusWithCollect(store, identity, now = Date.now(), channel = ssh) {
787
+ try {
788
+ await collect(store, channel, now);
789
+ } catch {
790
+ }
791
+ return status(store, identity, now);
792
+ }
740
793
 
741
794
  // src/store.ts
742
- import { rmSync } from "fs";
795
+ import { randomUUID as randomUUID2 } from "crypto";
796
+ import { mkdirSync as mkdirSync2, rmSync } from "fs";
797
+ import { dirname } from "path";
743
798
  import Database from "better-sqlite3";
744
- var DEFAULT_RETENTION_MS = 7 * 864e5;
745
- var STORE_VERSION = 2;
746
- function resetIfStale(path) {
747
- let salvaged = [];
799
+
800
+ // src/version.ts
801
+ import { createRequire } from "module";
802
+ function readVersion() {
803
+ const require2 = createRequire(import.meta.url);
804
+ for (const candidate of ["../package.json", "../../package.json"]) {
805
+ try {
806
+ return require2(candidate).version;
807
+ } catch {
808
+ }
809
+ }
810
+ throw new Error("cannot locate package.json to read the murmur version");
811
+ }
812
+ var MURMUR_VERSION = readVersion();
813
+
814
+ // src/store.ts
815
+ var SCHEMA_USER_VERSION = 3;
816
+ var SCHEMA = `
817
+ CREATE TABLE agents (
818
+ agent_id TEXT NOT NULL PRIMARY KEY,
819
+ pane TEXT NOT NULL UNIQUE,
820
+ owner_pid INTEGER NOT NULL CHECK (owner_pid > 0),
821
+ activity TEXT NOT NULL CHECK (activity IN ('running', 'stopped')),
822
+ session TEXT NOT NULL,
823
+ window TEXT NOT NULL,
824
+ session_name TEXT,
825
+ window_name TEXT,
826
+ agent_name TEXT,
827
+ pi_session TEXT,
828
+ workstream TEXT,
829
+ role TEXT,
830
+ cli TEXT NOT NULL,
831
+ driver TEXT NOT NULL CHECK (driver IN ('human', 'orchestrated')),
832
+ claimed_at INTEGER NOT NULL,
833
+ updated_at INTEGER NOT NULL
834
+ ) STRICT;
835
+
836
+ CREATE TABLE attention (
837
+ pane TEXT NOT NULL,
838
+ kind TEXT NOT NULL CHECK (kind IN ('done', 'blocked', 'crashed')),
839
+ message TEXT NOT NULL,
840
+ source TEXT NOT NULL,
841
+ session TEXT NOT NULL,
842
+ window TEXT NOT NULL,
843
+ session_name TEXT,
844
+ window_name TEXT,
845
+ requested_at INTEGER NOT NULL,
846
+ PRIMARY KEY (pane, kind)
847
+ ) STRICT;
848
+
849
+ CREATE TABLE peers (
850
+ name TEXT NOT NULL PRIMARY KEY,
851
+ target TEXT NOT NULL,
852
+ host_id TEXT,
853
+ display_name TEXT,
854
+ snapshot TEXT,
855
+ snapshot_at INTEGER,
856
+ fetched_at INTEGER,
857
+ last_attempt_at INTEGER,
858
+ last_error TEXT,
859
+ murmur_version TEXT,
860
+ snapshot_version INTEGER
861
+ ) STRICT;
862
+ `;
863
+ function salvagePeers(path) {
748
864
  try {
749
865
  const existing = new Database(path, { fileMustExist: true });
750
- const version = existing.pragma("user_version", { simple: true }) ?? 0;
751
- if (version === STORE_VERSION) {
866
+ try {
867
+ const version = existing.pragma("user_version", { simple: true }) ?? 0;
868
+ if (version === SCHEMA_USER_VERSION) return [];
869
+ return existing.prepare("SELECT name, target FROM peers").all();
870
+ } catch {
871
+ return [];
872
+ } finally {
752
873
  existing.close();
753
- return salvaged;
754
874
  }
875
+ } catch {
876
+ return [];
877
+ }
878
+ }
879
+ function needsReset(path) {
880
+ try {
881
+ const existing = new Database(path, { fileMustExist: true });
755
882
  try {
756
- salvaged = existing.prepare("SELECT name, target, host_id, display_name FROM peers").all();
757
- } catch {
883
+ return (existing.pragma("user_version", { simple: true }) ?? 0) !== SCHEMA_USER_VERSION;
884
+ } finally {
885
+ existing.close();
758
886
  }
759
- existing.close();
760
887
  } catch {
761
- return salvaged;
762
- }
763
- for (const suffix of ["", "-wal", "-shm"]) rmSync(`${path}${suffix}`, { force: true });
764
- return salvaged;
765
- }
766
- function eventValues(event) {
767
- return [
768
- event.host_id,
769
- event.seq,
770
- event.ts,
771
- event.agent_id,
772
- event.session,
773
- event.window,
774
- event.pane,
775
- event.session_name,
776
- event.window_name,
777
- event.agent_name,
778
- event.pi_session,
779
- event.workstream,
780
- event.role,
781
- event.cli,
782
- event.driver,
783
- event.kind,
784
- event.state,
785
- event.message,
786
- event.pid,
787
- Number(event.synthetic),
788
- event.reason,
789
- JSON.stringify(event.extra)
790
- ];
791
- }
792
- function toEvent(row) {
888
+ return false;
889
+ }
890
+ }
891
+ function toAttention(row) {
892
+ return {
893
+ kind: row.kind,
894
+ message: row.message,
895
+ source: row.source,
896
+ requested_at: row.requested_at
897
+ };
898
+ }
899
+ function toAgent(row) {
793
900
  return {
794
- ...row,
901
+ agent_id: row.agent_id,
902
+ activity: row.activity,
903
+ agent_name: row.agent_name,
904
+ pi_session: row.pi_session,
905
+ workstream: row.workstream,
906
+ role: row.role,
907
+ cli: row.cli,
795
908
  driver: row.driver,
796
- synthetic: row.synthetic === 1,
797
- extra: JSON.parse(row.extra)
909
+ claimed_at: row.claimed_at,
910
+ updated_at: row.updated_at
798
911
  };
799
912
  }
913
+ var PRIORITY = new Map(RENDER_PRIORITY.map((kind, index) => [kind, index]));
914
+ function attentionOrder(left, right) {
915
+ return (PRIORITY.get(left.kind) ?? 99) - (PRIORITY.get(right.kind) ?? 99);
916
+ }
800
917
  function openStore() {
801
- const identity = ensureIdentity();
802
918
  const path = dbPath();
803
- const salvagedPeers = resetIfStale(path);
919
+ mkdirSync2(dirname(path), { recursive: true });
920
+ const salvaged = salvagePeers(path);
921
+ if (needsReset(path)) {
922
+ for (const suffix of ["", "-wal", "-shm"]) rmSync(`${path}${suffix}`, { force: true });
923
+ }
804
924
  const database = new Database(path);
805
925
  database.pragma("journal_mode = WAL");
806
- database.pragma(`user_version = ${STORE_VERSION}`);
807
- database.exec(`
808
- CREATE TABLE IF NOT EXISTS events (
809
- host_id TEXT NOT NULL,
810
- seq INTEGER NOT NULL,
811
- ts INTEGER NOT NULL,
812
- agent_id TEXT NOT NULL,
813
- session TEXT NOT NULL,
814
- window TEXT NOT NULL,
815
- pane TEXT NOT NULL,
816
- session_name TEXT,
817
- window_name TEXT,
818
- agent_name TEXT,
819
- pi_session TEXT,
820
- workstream TEXT,
821
- role TEXT,
822
- cli TEXT,
823
- driver TEXT,
824
- kind TEXT NOT NULL,
825
- state TEXT NOT NULL,
826
- message TEXT NOT NULL,
827
- pid INTEGER,
828
- synthetic INTEGER NOT NULL,
829
- reason TEXT NOT NULL,
830
- extra TEXT NOT NULL,
831
- PRIMARY KEY (host_id, seq)
832
- );
833
- CREATE INDEX IF NOT EXISTS events_agent_seq ON events (agent_id, seq);
834
- CREATE TABLE IF NOT EXISTS peers (
835
- name TEXT PRIMARY KEY,
836
- target TEXT NOT NULL,
837
- host_id TEXT,
838
- display_name TEXT,
839
- watermark INTEGER NOT NULL,
840
- fetched_at INTEGER,
841
- -- When a jump last proved this peer's tmux was not answering. Reader
842
- -- state, not an event: this node cannot author facts about another
843
- -- node's agents, and a jump is a local observation, not something the
844
- -- peer said. Cleared by the next successful collect.
845
- tmux_down_at INTEGER
846
- );
847
- `);
848
- try {
849
- database.exec("ALTER TABLE peers ADD COLUMN tmux_down_at INTEGER");
850
- } catch {
926
+ database.pragma("busy_timeout = 5000");
927
+ const version = database.pragma("user_version", { simple: true }) ?? 0;
928
+ if (version !== SCHEMA_USER_VERSION) {
929
+ database.exec(SCHEMA);
930
+ database.pragma(`user_version = ${SCHEMA_USER_VERSION}`);
931
+ const restore = database.prepare("INSERT OR IGNORE INTO peers (name, target) VALUES (?, ?)");
932
+ for (const peer of salvaged) restore.run(peer.name, peer.target);
851
933
  }
852
- if (salvagedPeers.length > 0) {
853
- const restore = database.prepare(
854
- `INSERT OR IGNORE INTO peers (name, target, host_id, display_name, watermark, fetched_at)
855
- VALUES (?, ?, ?, ?, 0, NULL)`
856
- );
857
- for (const peer of salvagedPeers) {
858
- restore.run(peer.name, peer.target, peer.host_id ?? null, peer.display_name ?? null);
859
- }
860
- }
861
- const eventColumns = `
862
- host_id, seq, ts, agent_id, session, window, pane,
863
- session_name, window_name, agent_name, pi_session,
864
- workstream, role, cli, driver, kind, state, message, pid,
865
- synthetic, reason, extra`;
866
- const eventPlaceholders = new Array(22).fill("?").join(", ");
867
- const insertEvent = database.prepare(
868
- `INSERT INTO events (${eventColumns}) VALUES (${eventPlaceholders})`
869
- );
870
- const ingestEvent = database.prepare(
871
- `INSERT OR IGNORE INTO events (${eventColumns}) VALUES (${eventPlaceholders})`
934
+ const selectAgentByPane = database.prepare("SELECT * FROM agents WHERE pane = ?");
935
+ const insertAgent = database.prepare(`
936
+ INSERT INTO agents (agent_id, pane, owner_pid, activity, session, window,
937
+ session_name, window_name, agent_name, pi_session,
938
+ workstream, role, cli, driver, claimed_at, updated_at)
939
+ VALUES (@agent_id, @pane, @owner_pid, @activity, @session, @window,
940
+ @session_name, @window_name, @agent_name, @pi_session,
941
+ @workstream, @role, @cli, @driver, @claimed_at, @updated_at)
942
+ `);
943
+ const retainAgent = database.prepare(`
944
+ UPDATE agents
945
+ SET session = @session, window = @window, session_name = @session_name,
946
+ window_name = @window_name, agent_name = @agent_name,
947
+ pi_session = @pi_session, workstream = @workstream, role = @role,
948
+ cli = @cli, driver = @driver, updated_at = @updated_at
949
+ WHERE agent_id = @agent_id
950
+ `);
951
+ const deleteAgentByPane = database.prepare("DELETE FROM agents WHERE pane = ?");
952
+ const deleteAttentionForPane = database.prepare("DELETE FROM attention WHERE pane = ?");
953
+ const updateActivity = database.prepare(`
954
+ UPDATE agents
955
+ SET activity = @activity, session = @session, window = @window,
956
+ session_name = @session_name, window_name = @window_name,
957
+ updated_at = @updated_at
958
+ WHERE agent_id = @agent_id AND owner_pid = @owner_pid
959
+ `);
960
+ const deleteAgentOwned = database.prepare(
961
+ "DELETE FROM agents WHERE agent_id = ? AND owner_pid = ?"
872
962
  );
873
- const selectMaxSeq = database.prepare(
874
- "SELECT COALESCE(MAX(seq), 0) AS seq FROM events WHERE host_id = ?"
963
+ const upsertAttention = database.prepare(`
964
+ INSERT INTO attention (pane, kind, message, source, session, window,
965
+ session_name, window_name, requested_at)
966
+ VALUES (@pane, @kind, @message, @source, @session, @window,
967
+ @session_name, @window_name, @requested_at)
968
+ ON CONFLICT (pane, kind) DO UPDATE SET
969
+ message = excluded.message,
970
+ source = excluded.source,
971
+ session = excluded.session,
972
+ window = excluded.window,
973
+ session_name = excluded.session_name,
974
+ window_name = excluded.window_name
975
+ `);
976
+ const selectAgents = database.prepare("SELECT * FROM agents");
977
+ const selectAttention = database.prepare("SELECT * FROM attention");
978
+ const setActivityByPane = database.prepare(
979
+ "UPDATE agents SET activity = ?, updated_at = ? WHERE pane = ?"
875
980
  );
876
- const append = database.transaction((event) => {
877
- const row = selectMaxSeq.get(identity.host_id);
878
- const stored = {
879
- ...event,
880
- host_id: identity.host_id,
881
- seq: row.seq + 1,
882
- ts: event.ts ?? Date.now(),
883
- session_name: event.session_name ?? null,
884
- window_name: event.window_name ?? null,
885
- agent_name: event.agent_name ?? null,
886
- pi_session: event.pi_session ?? null
981
+ const claimAgent = database.transaction((claim) => {
982
+ const now = claim.now ?? Date.now();
983
+ const isAlive = claim.isAlive ?? pidAlive;
984
+ const { location, meta, owner_pid } = claim;
985
+ const incumbent = selectAgentByPane.get(location.pane);
986
+ const values = {
987
+ pane: location.pane,
988
+ owner_pid,
989
+ session: location.session,
990
+ window: location.window,
991
+ session_name: location.session_name,
992
+ window_name: location.window_name,
993
+ agent_name: meta.agent_name,
994
+ pi_session: meta.pi_session,
995
+ workstream: meta.workstream,
996
+ role: meta.role,
997
+ cli: meta.cli,
998
+ driver: meta.driver,
999
+ updated_at: now
887
1000
  };
888
- insertEvent.run(...eventValues(stored));
889
- return stored;
890
- });
891
- const ingest = database.transaction((events) => {
892
- let inserted = 0;
893
- for (const event of events) inserted += ingestEvent.run(...eventValues(event)).changes;
894
- return inserted;
1001
+ if (!incumbent) {
1002
+ const agentId2 = randomUUID2();
1003
+ insertAgent.run({ ...values, agent_id: agentId2, activity: "stopped", claimed_at: now });
1004
+ return { outcome: "claimed", agent_id: agentId2 };
1005
+ }
1006
+ if (incumbent.owner_pid === owner_pid) {
1007
+ retainAgent.run({ ...values, agent_id: incumbent.agent_id });
1008
+ return { outcome: "retained", agent_id: incumbent.agent_id };
1009
+ }
1010
+ if (isAlive(incumbent.owner_pid)) {
1011
+ return { outcome: "refused", held_by_pid: incumbent.owner_pid };
1012
+ }
1013
+ deleteAgentByPane.run(location.pane);
1014
+ deleteAttentionForPane.run(location.pane);
1015
+ const agentId = randomUUID2();
1016
+ insertAgent.run({ ...values, agent_id: agentId, activity: "stopped", claimed_at: now });
1017
+ return { outcome: "replaced", agent_id: agentId, previous_agent_id: incumbent.agent_id };
1018
+ }).immediate;
1019
+ const reconcileLocal = database.transaction((world) => {
1020
+ const summary = { crashed: [], removed: [], attention_removed: [] };
1021
+ if (world.panes === null) return summary;
1022
+ const live = world.panes;
1023
+ const isAlive = world.isAlive ?? pidAlive;
1024
+ const now = world.now ?? Date.now();
1025
+ const alreadyCrashed = new Set(
1026
+ selectAttention.all().filter((row) => row.kind === "crashed").map((row) => row.pane)
1027
+ );
1028
+ for (const row of selectAgents.all()) {
1029
+ const pane = asPaneId(row.pane);
1030
+ if (!live.has(pane)) {
1031
+ deleteAgentByPane.run(row.pane);
1032
+ deleteAttentionForPane.run(row.pane);
1033
+ summary.removed.push(pane);
1034
+ continue;
1035
+ }
1036
+ if (isAlive(row.owner_pid)) continue;
1037
+ if (row.activity === "running") {
1038
+ setActivityByPane.run("stopped", now, row.pane);
1039
+ upsertAttention.run({
1040
+ pane: row.pane,
1041
+ kind: "crashed",
1042
+ message: "",
1043
+ source: "murmur",
1044
+ session: row.session,
1045
+ window: row.window,
1046
+ session_name: row.session_name,
1047
+ window_name: row.window_name,
1048
+ requested_at: now
1049
+ });
1050
+ summary.crashed.push(pane);
1051
+ } else if (!alreadyCrashed.has(row.pane)) {
1052
+ deleteAgentByPane.run(row.pane);
1053
+ summary.removed.push(pane);
1054
+ }
1055
+ }
1056
+ for (const row of selectAttention.all()) {
1057
+ const pane = asPaneId(row.pane);
1058
+ if (live.has(pane)) continue;
1059
+ deleteAttentionForPane.run(row.pane);
1060
+ if (!summary.attention_removed.includes(pane)) summary.attention_removed.push(pane);
1061
+ }
1062
+ return summary;
1063
+ }).immediate;
1064
+ const readLocalPanes = database.transaction(() => {
1065
+ const agents = selectAgents.all();
1066
+ const attention = selectAttention.all();
1067
+ const panes = /* @__PURE__ */ new Map();
1068
+ const locate = (row) => {
1069
+ const existing = panes.get(row.pane);
1070
+ if (existing) return existing;
1071
+ const created = {
1072
+ pane: asPaneId(row.pane),
1073
+ session: asSessionId(row.session),
1074
+ window: asWindowId(row.window),
1075
+ session_name: row.session_name,
1076
+ window_name: row.window_name,
1077
+ agent: null,
1078
+ attention: []
1079
+ };
1080
+ panes.set(row.pane, created);
1081
+ return created;
1082
+ };
1083
+ for (const row of agents) locate(row).agent = toAgent(row);
1084
+ for (const row of attention) locate(row).attention.push(toAttention(row));
1085
+ for (const pane of panes.values()) pane.attention.sort(attentionOrder);
1086
+ return [...panes.values()].sort((left, right) => left.pane.localeCompare(right.pane));
895
1087
  });
1088
+ function peerRecord(row) {
1089
+ let snapshot = null;
1090
+ if (row.snapshot !== null) {
1091
+ try {
1092
+ snapshot = JSON.parse(row.snapshot);
1093
+ } catch {
1094
+ snapshot = null;
1095
+ }
1096
+ }
1097
+ return {
1098
+ name: row.name,
1099
+ target: row.target,
1100
+ host_id: row.host_id,
1101
+ display_name: row.display_name,
1102
+ snapshot,
1103
+ snapshot_at: row.snapshot_at,
1104
+ fetched_at: row.fetched_at,
1105
+ last_attempt_at: row.last_attempt_at,
1106
+ last_error: row.last_error,
1107
+ murmur_version: row.murmur_version,
1108
+ snapshot_version: row.snapshot_version
1109
+ };
1110
+ }
896
1111
  return {
897
- append,
898
- ingest,
899
- eventsSince(hostId, seq) {
900
- const rows = database.prepare("SELECT * FROM events WHERE host_id = ? AND seq > ? ORDER BY seq").all(hostId, seq);
901
- return rows.map(toEvent);
902
- },
903
- allEvents() {
904
- const rows = database.prepare("SELECT * FROM events ORDER BY ts, host_id, seq").all();
905
- return rows.map(toEvent);
1112
+ claimAgent,
1113
+ reconcileLocal,
1114
+ setActivity(update) {
1115
+ return updateActivity.run({
1116
+ activity: update.activity,
1117
+ session: update.location.session,
1118
+ window: update.location.window,
1119
+ session_name: update.location.session_name,
1120
+ window_name: update.location.window_name,
1121
+ updated_at: update.now ?? Date.now(),
1122
+ agent_id: update.agent_id,
1123
+ owner_pid: update.owner_pid
1124
+ }).changes === 1;
906
1125
  },
907
- latestForAgent(hostId, agentId) {
908
- const row = database.prepare(
909
- `SELECT * FROM events
910
- WHERE host_id = ? AND agent_id = ?
911
- ORDER BY seq DESC LIMIT 1`
912
- ).get(hostId, agentId);
913
- return row ? toEvent(row) : null;
1126
+ releaseAgent(release) {
1127
+ return deleteAgentOwned.run(release.agent_id, release.owner_pid).changes === 1;
914
1128
  },
915
- maxSeq(hostId) {
916
- return selectMaxSeq.get(hostId).seq;
917
- },
918
- prune(horizonMs = Number(process.env.MURMUR_RETENTION_MS ?? DEFAULT_RETENTION_MS)) {
919
- return database.prepare(`
920
- DELETE FROM events
921
- WHERE ts < ?
922
- AND (host_id, seq) NOT IN (
923
- SELECT host_id, seq FROM (
924
- SELECT host_id, seq,
925
- ROW_NUMBER() OVER (PARTITION BY agent_id ORDER BY ts DESC, seq DESC) rn
926
- FROM events
927
- ) WHERE rn = 1
928
- )
929
- `).run(Date.now() - horizonMs).changes;
1129
+ requestAttention(request) {
1130
+ upsertAttention.run({
1131
+ pane: request.location.pane,
1132
+ kind: request.kind,
1133
+ message: request.message,
1134
+ source: request.source,
1135
+ session: request.location.session,
1136
+ window: request.location.window,
1137
+ session_name: request.location.session_name,
1138
+ window_name: request.location.window_name,
1139
+ requested_at: request.now ?? Date.now()
1140
+ });
930
1141
  },
931
- peers() {
932
- return database.prepare("SELECT * FROM peers ORDER BY name").all();
1142
+ acknowledgePane(pane) {
1143
+ return deleteAttentionForPane.run(pane).changes;
933
1144
  },
934
- forgetAgent(agentId) {
935
- return database.prepare("DELETE FROM events WHERE agent_id = ?").run(agentId).changes;
1145
+ localPanes() {
1146
+ return readLocalPanes();
936
1147
  },
937
- forgetHost(hostId) {
938
- return database.prepare("DELETE FROM events WHERE host_id = ?").run(hostId).changes;
1148
+ buildLocalSnapshot(identity, world) {
1149
+ reconcileLocal(world);
1150
+ return {
1151
+ murmur_snapshot: 1,
1152
+ host_id: identity.host_id,
1153
+ display_name: identity.display_name,
1154
+ murmur_version: MURMUR_VERSION,
1155
+ generated_at: world.now ?? Date.now(),
1156
+ // Rule 3: a pane with no agent and no attention must not be published.
1157
+ // A no-op against today's `readLocalPanes`, which builds a pane entry
1158
+ // only from a row and so cannot produce an empty one -- kept because the
1159
+ // rule belongs to the DOCUMENT, and the validator rejects such an entry
1160
+ // outright. Without it, one narrowing of the local read would make this
1161
+ // node reachable-but-broken on every peer that collects it, and the
1162
+ // symptom would show up on the other machines.
1163
+ panes: readLocalPanes().filter((pane) => pane.agent !== null || pane.attention.length > 0)
1164
+ };
939
1165
  },
940
- upsertPeer(peer) {
941
- const current = database.prepare("SELECT * FROM peers WHERE name = ?").get(peer.name);
942
- database.prepare(`
943
- INSERT INTO peers (name, target, host_id, display_name, watermark, fetched_at, tmux_down_at)
944
- VALUES (?, ?, ?, ?, ?, ?, ?)
945
- ON CONFLICT(name) DO UPDATE SET
946
- target = excluded.target,
947
- host_id = excluded.host_id,
948
- display_name = excluded.display_name,
949
- watermark = excluded.watermark,
950
- fetched_at = excluded.fetched_at,
951
- tmux_down_at = excluded.tmux_down_at
952
- `).run(
953
- peer.name,
954
- peer.target,
955
- peer.host_id !== void 0 ? peer.host_id : current?.host_id ?? null,
956
- peer.display_name !== void 0 ? peer.display_name : current?.display_name ?? null,
957
- peer.watermark !== void 0 ? peer.watermark : current?.watermark ?? 0,
958
- peer.fetched_at !== void 0 ? peer.fetched_at : current?.fetched_at ?? null,
959
- peer.tmux_down_at !== void 0 ? peer.tmux_down_at : current?.tmux_down_at ?? null
1166
+ peers() {
1167
+ return database.prepare("SELECT * FROM peers ORDER BY name").all().map(
1168
+ peerRecord
960
1169
  );
961
1170
  },
1171
+ addPeer(name, target) {
1172
+ database.prepare(
1173
+ `INSERT INTO peers (name, target) VALUES (?, ?)
1174
+ ON CONFLICT(name) DO UPDATE SET target = excluded.target`
1175
+ ).run(name, target);
1176
+ },
962
1177
  removePeer(name) {
963
1178
  return database.prepare("DELETE FROM peers WHERE name = ?").run(name).changes > 0;
964
1179
  },
1180
+ replacePeerSnapshot(name, fetch) {
1181
+ if (!fetch.ok) {
1182
+ database.prepare("UPDATE peers SET last_attempt_at = ?, last_error = ? WHERE name = ?").run(fetch.at, fetch.error, name);
1183
+ return;
1184
+ }
1185
+ database.prepare(
1186
+ `UPDATE peers
1187
+ SET snapshot = ?, snapshot_at = ?, fetched_at = ?, last_attempt_at = ?,
1188
+ last_error = NULL, host_id = ?, display_name = ?,
1189
+ murmur_version = ?, snapshot_version = ?
1190
+ WHERE name = ?`
1191
+ ).run(
1192
+ JSON.stringify(fetch.snapshot),
1193
+ fetch.snapshot.generated_at,
1194
+ fetch.at,
1195
+ fetch.at,
1196
+ fetch.snapshot.host_id,
1197
+ fetch.snapshot.display_name,
1198
+ fetch.snapshot.murmur_version,
1199
+ fetch.snapshot.murmur_snapshot,
1200
+ name
1201
+ );
1202
+ },
965
1203
  close() {
966
1204
  database.close();
967
1205
  }
968
1206
  };
969
1207
  }
970
-
971
- // src/index.ts
972
- var manifest = createRequire(import.meta.url)("../package.json");
973
- var VERSION = manifest.version;
974
1208
  export {
975
1209
  DEFAULT_DRIVER,
976
1210
  MAX_CONCURRENT_PEERS,
977
- SCHEMA_VERSION,
1211
+ NEEDS_HUMAN,
1212
+ RENDER_PRIORITY,
978
1213
  STALENESS_MS,
979
- STORE_VERSION,
980
- VERSION,
1214
+ SnapshotInvalidError,
1215
+ MURMUR_VERSION as VERSION,
1216
+ age,
981
1217
  agentLabel,
982
1218
  agentLocation,
983
- attentionSort,
1219
+ asPaneId,
1220
+ asSessionId,
1221
+ asWindowId,
984
1222
  collect,
985
1223
  configDir,
1224
+ createIdentity,
986
1225
  dbPath,
987
- ensureIdentity,
988
- eventFromWire,
989
- exportJsonl,
990
- foldAgent,
991
- foldAll,
1226
+ freshness,
992
1227
  glance,
993
1228
  hasWarmSocket,
994
- isStale,
995
1229
  jumpToAgent,
996
1230
  loadIdentity,
997
1231
  openStore,
1232
+ paneViews,
1233
+ parseSnapshot,
998
1234
  pidAlive,
1235
+ renderState,
1236
+ setDisplayName,
999
1237
  shellQuote,
1000
1238
  ssh,
1001
1239
  stateDir,
1002
1240
  status,
1003
- tmux
1241
+ statusWithCollect,
1242
+ tmux,
1243
+ tmuxStatus,
1244
+ viewSort
1004
1245
  };
1005
1246
  //# sourceMappingURL=index.js.map