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