@martintrojer/murmur 0.1.0 → 0.1.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/ARCHITECTURE.md CHANGED
@@ -55,6 +55,40 @@ Two consequences worth stating, because both are easy to violate by accident:
55
55
  thing today, and differ the moment anything gossips. Free to preserve now,
56
56
  expensive to retrofit.
57
57
 
58
+ ## A tmux pane is the agent's address
59
+
60
+ Everything below assumes agents run inside tmux. That assumption does real work
61
+ in one specific way: a pane is how murmur names an agent and how a jump reaches
62
+ one.
63
+
64
+ The extension resolves its pane from `$TMUX_PANE` and returns early if there is
65
+ none, so a pi started in a plain terminal writes no events and never appears in
66
+ the picker. That is the honest outcome rather than a gap: with no pane there is
67
+ no address, and a row you cannot jump to is worse than no row.
68
+
69
+ Two nearby calls look contradictory and are not:
70
+
71
+ - `currentWindow()` answers "which pane am I in", so only `$TMUX_PANE` can tell
72
+ it. Asking tmux instead reports whichever pane the server considers active,
73
+ which would let a non-tmux pi record itself in an unrelated agent's pane and
74
+ overwrite that agent's state.
75
+ - `liveWindows()` answers "which windows exist on this host", which is
76
+ server-wide and correct from anywhere. Export runs over ssh with no pane of
77
+ its own and still has to see the windows.
78
+
79
+ The remote jump inherits the same requirement, since it is `ssh -t <host> tmux
80
+ attach`. tmux must be running on the far side, which is why a dead remote tmux
81
+ server gets its own diagnosis rather than being reported as an unreachable
82
+ host.
83
+
84
+ The badge also has to be cleared from outside, which is why `murmur clear
85
+ --pane <id>` exists and why tmux hooks have to call it. A window option is what
86
+ the status bar and the `tms` session picker read, so it outlives the agent
87
+ unless something clears it, and the agent itself cannot: "you looked at it" is
88
+ an event only the multiplexer sees. Hooks run in the tmux server with no
89
+ `$TMUX_PANE`, so the pane id is passed explicitly — and it must be, because the
90
+ badge belongs to the window while the looking belongs to one pane.
91
+
58
92
  ## The six units
59
93
 
60
94
  | Unit | Does | Depends on |
package/README.md CHANGED
@@ -66,6 +66,12 @@ against herdr, T3 Code and `mu`.
66
66
  tmux, [pi](https://github.com/earendil-works/pi-coding-agent), `fzf`, and Node
67
67
  20+. For more than one machine: ssh access, and murmur installed on each.
68
68
 
69
+ **Agents must run inside tmux**, on every machine. A tmux pane is how murmur
70
+ addresses an agent, so a pi started in a plain terminal records nothing and
71
+ never appears in the picker. That is deliberate: there would be no way to jump
72
+ to it. Remote agents need tmux on the remote too, since the jump is
73
+ `ssh -t <host> tmux attach`.
74
+
69
75
  ## Install
70
76
 
71
77
  On every node that runs agents:
@@ -79,25 +85,48 @@ murmur link pi # install the agent-side extension
79
85
  `link pi` writes the extension into `~/.pi/agent/extensions/`, pinned to this
80
86
  installation. Re-run it after upgrading murmur.
81
87
 
82
- Then, on whichever machine you want to watch from:
88
+ Also on every node, in `.tmux.conf`, so a finished agent stops asking for
89
+ attention once you look at it:
90
+
91
+ ```tmux
92
+ set-hook -g after-select-pane "run-shell -b 'murmur clear --pane #{pane_id}'"
93
+ set-hook -g after-select-window "run-shell -b 'murmur clear --pane #{pane_id}'"
94
+ set-hook -g client-session-changed "run-shell -b 'murmur clear --pane #{pane_id}'"
95
+ ```
96
+
97
+ These are per node and not optional. The `cleared` event they write replicates,
98
+ so a node without them leaves its agents marked `done` in *every* peer's picker,
99
+ not only its own status bar. Verify with `tmux show-hooks -g`: `set-hook`
100
+ accepts a hook name your tmux does not have and exits 0, so a wrong name fails
101
+ silently.
102
+
103
+ The pane id is passed explicitly because hooks run in the tmux server, where
104
+ `$TMUX_PANE` is unset, and because the badge belongs to the window while "you
105
+ looked at it" is true of one pane. Without it, a window holding an agent and a
106
+ shell clears when you focus the shell.
107
+
108
+ Then, on whichever machine you want to watch from, add the peers and bind the
109
+ picker to a key:
83
110
 
84
111
  ```bash
85
112
  murmur peer add devbox # an ssh target; identity is discovered
86
- murmur pick
87
113
  ```
88
114
 
89
- Bind it to a key and you have the whole interface:
90
-
91
115
  ```tmux
92
116
  bind -N "agent state picker" a display-popup -E -w 80% -h 60% "murmur pick"
93
117
  ```
94
118
 
119
+ In the picker: `^r` refreshes, `^p` cycles the preview, and `del` drops a stuck
120
+ row. `^b` / `^w` / `^d` / `^x` filter to blocked, working, done or crashed, and
121
+ `^a` clears the filter. Typing matches the agent name, its workstream or tmux
122
+ session, and its host, as literal substrings rather than scattered characters.
123
+
95
124
  `murmur status` prints per-state counts for a status bar. Everything else is
96
125
  `--help`.
97
126
 
98
127
  ## Status
99
128
 
100
- **0.1.0.** In daily use on one machine and verified across two over real ssh.
129
+ **0.1.1.** In daily use on one machine and verified across two over real ssh.
101
130
  It is new and not battle-tested. The known gaps are listed at the end of
102
131
  [ARCHITECTURE.md](ARCHITECTURE.md#known-gaps); the one most likely to annoy you
103
132
  is that jumping to a remote agent nests tmux inside tmux, which every tool in
package/dist/cli.js CHANGED
@@ -52,7 +52,7 @@ function runTmux(args) {
52
52
  }
53
53
  var tmux = {
54
54
  currentWindow() {
55
- const pane = process.env.TMUX_PANE ?? runTmux(["display-message", "-p", "#{pane_id}"]);
55
+ const pane = process.env.TMUX_PANE;
56
56
  if (!pane) return null;
57
57
  const fields = runTmux([
58
58
  "display-message",
@@ -80,6 +80,12 @@ var tmux = {
80
80
  // deliberately distinct from an empty set, which means "tmux answered, and
81
81
  // there are no windows". Treating the first as the second would clear every
82
82
  // agent on the host the moment tmux was unreachable.
83
+ //
84
+ // Unlike currentWindow, this deliberately asks tmux rather than reading the
85
+ // environment, and it is right to: "which windows exist on this host" is a
86
+ // server-wide question with one answer, and export runs over ssh with no
87
+ // pane of its own. currentWindow asks "which pane am I in", which only
88
+ // $TMUX_PANE can answer.
83
89
  liveWindows() {
84
90
  const out = runTmux(["list-windows", "-a", "-F", "#{window_id}"]);
85
91
  if (out === null) return null;
@@ -112,6 +118,13 @@ var tmux = {
112
118
  },
113
119
  // First window carrying this exact name, or null. Used to reuse a per-host
114
120
  // ssh window instead of opening another one.
121
+ // Sibling panes, for deciding whether an unowned pane may clear the window's
122
+ // badge. A window holding an agent and a shell must not lose the badge when
123
+ // you focus the shell.
124
+ panesInWindow(window) {
125
+ const out = runTmux(["list-panes", "-t", window, "-F", "#{pane_id}"]);
126
+ return out?.split("\n").filter(Boolean) ?? [];
127
+ },
115
128
  windowNamed(name) {
116
129
  const out = runTmux(["list-windows", "-a", "-F", "#{window_id} #{window_name}"]);
117
130
  for (const line of out?.split("\n") ?? []) {
@@ -366,6 +379,29 @@ function openStore() {
366
379
  }
367
380
 
368
381
  // src/cli/clear.ts
382
+ function windowHasAgent(window, focused, hostId, mux) {
383
+ if (!hostId) return false;
384
+ const siblings = mux.panesInWindow(window).filter((candidate) => candidate !== focused);
385
+ if (siblings.length === 0) return false;
386
+ try {
387
+ const database = new Database2(dbPath(), { readonly: true, fileMustExist: true });
388
+ try {
389
+ for (const sibling of siblings) {
390
+ const row = database.prepare(
391
+ `SELECT state FROM events
392
+ WHERE host_id = ? AND agent_id = ?
393
+ ORDER BY seq DESC LIMIT 1`
394
+ ).get(hostId, `${hostId}:${sibling}`);
395
+ if (row && row.state !== "cleared") return true;
396
+ }
397
+ } finally {
398
+ database.close();
399
+ }
400
+ return false;
401
+ } catch {
402
+ return true;
403
+ }
404
+ }
369
405
  function clearPane(pane, mux = tmux) {
370
406
  try {
371
407
  if (!pane) return;
@@ -391,10 +427,15 @@ function clearPane(pane, mux = tmux) {
391
427
  }
392
428
  }
393
429
  if (!owner) {
394
- if (window) mux.setState(window, null);
430
+ if (window && !windowHasAgent(window, pane, identity?.host_id, mux)) {
431
+ mux.setState(window, null);
432
+ }
433
+ return;
434
+ }
435
+ if (owner.state === "cleared") {
436
+ mux.setState(owner.window, null);
395
437
  return;
396
438
  }
397
- if (owner.state === "cleared") return;
398
439
  const store = openStore();
399
440
  try {
400
441
  store.append({
@@ -930,6 +971,16 @@ function forgetReplica(store, agentId, hostId) {
930
971
  } catch {
931
972
  }
932
973
  }
974
+ function forgetOneAgent(store, agent, mux = tmux) {
975
+ const identity = loadIdentity();
976
+ if (agent.host_id === identity?.host_id) {
977
+ try {
978
+ mux.setState(agent.window, null);
979
+ } catch {
980
+ }
981
+ }
982
+ forgetReplica(store, agent.agent_id, agent.host_id);
983
+ }
933
984
  function jumpToAgent(store, agent) {
934
985
  const identity = loadIdentity();
935
986
  if (agent.host_id === identity?.host_id) {
@@ -1163,7 +1214,7 @@ function headerRow(showHost) {
1163
1214
  " ".repeat(COLUMNS.glyph),
1164
1215
  pad("state", COLUMNS.state),
1165
1216
  pad("agent", COLUMNS.name),
1166
- pad("workstream", showHost ? COLUMNS.stream : COLUMNS.streamWide),
1217
+ pad("stream", showHost ? COLUMNS.stream : COLUMNS.streamWide),
1167
1218
  showHost ? pad("host", COLUMNS.host) : "",
1168
1219
  "age / flags"
1169
1220
  ].filter(Boolean).join(" ");
@@ -1216,7 +1267,8 @@ function pickerRow(agent, showHost, current, local = true) {
1216
1267
  const marker = current ? `${BOLD}\u25C6${RESET}` : " ";
1217
1268
  const name = agent.agent_name ?? agent.pi_session ?? agentLabel(agent);
1218
1269
  const host = showHost ? local ? `${DIM} here${RESET}` : `${REMOTE}\u2192 ${terminalText(agent.host)}${RESET}` : "";
1219
- const workstream = agent.workstream ? `${DIM}${terminalText(agent.workstream)}${RESET}` : "";
1270
+ const group = agent.workstream ?? agent.session_name;
1271
+ const workstream = group ? `${DIM}${terminalText(group)}${RESET}` : "";
1220
1272
  const flags = [
1221
1273
  agent.driver === "orchestrated" ? "crew" : "",
1222
1274
  agent.stale ? "unreachable" : "",
@@ -1297,6 +1349,7 @@ async function runPick(store, options = {}) {
1297
1349
  }
1298
1350
  const prompt = URGENCY.filter((state) => counts.get(state)).map((state) => `${COLOUR[state]}${GLYPH[state]}${counts.get(state)}${RESET}`).join(" ");
1299
1351
  const self = process.argv[1] ?? "murmur";
1352
+ const allFlag = options.all ? " --all" : "";
1300
1353
  const width = process.stdout.columns ?? 0;
1301
1354
  const previewLayout = width > 0 && width < 150 ? "bottom:60%,border-top,wrap" : "right:58%,border-left,wrap";
1302
1355
  const preview = `${process.execPath} ${self} pick --preview {1}`;
@@ -1312,7 +1365,19 @@ async function runPick(store, options = {}) {
1312
1365
  "--with-nth",
1313
1366
  "2..",
1314
1367
  "--ansi",
1315
- "--no-sort",
1368
+ // Literal substring matching, and matching only the visible columns.
1369
+ // Default fuzzy scatters query characters across the row: `re` matched
1370
+ // "Fix Murmur Pick Fzf Filter" as well as "recovered". A query here is a
1371
+ // word or two of an agent or workstream name, so substring is what the
1372
+ // fingers expect. Prefix a token with ' to opt back into fuzzy.
1373
+ // Same choice as the tms session picker, for consistency across the two.
1374
+ "--exact",
1375
+ // `begin` ranks earlier match positions higher, so `scratch` puts the
1376
+ // scratch workstream above a row that merely mentions it. `index` is the
1377
+ // empty-query fallback and preserves the attention order the fold
1378
+ // produced, which is the whole point of the list.
1379
+ "--tiebreak",
1380
+ "begin,index",
1316
1381
  "--layout",
1317
1382
  "reverse",
1318
1383
  "--border",
@@ -1322,7 +1387,7 @@ async function runPick(store, options = {}) {
1322
1387
  `${prompt}${prompt ? " " : ""}`,
1323
1388
  "--header",
1324
1389
  [
1325
- `enter jump ctrl-r refresh ctrl-p preview filter: ${FILTER_KEYS.map(
1390
+ `enter jump ^r refresh ^p preview del forget filter: ${FILTER_KEYS.map(
1326
1391
  ([key, state]) => `${key.replace("ctrl-", "^")} ${state || "all"}`
1327
1392
  ).join(" ")}`,
1328
1393
  hidden ? `${hidden} crew hidden (--all)` : "",
@@ -1339,7 +1404,17 @@ async function runPick(store, options = {}) {
1339
1404
  "--bind",
1340
1405
  "ctrl-p:change-preview-window(bottom:60%,border-top,wrap|hidden|right:58%,border-left,wrap)",
1341
1406
  "--bind",
1342
- `ctrl-r:reload(${process.execPath} ${self} pick --rows${options.all ? " --all" : ""})`,
1407
+ `ctrl-r:reload(${process.execPath} ${self} pick --rows${allFlag})`,
1408
+ // Manual dismissal for a row nothing else will clear.
1409
+ //
1410
+ // The delete key, not a ctrl chord. ctrl-shift-d does not exist -- a
1411
+ // terminal sends the same bytes as ctrl-d -- and ctrl-alt-d, while it
1412
+ // does dispatch distinctly, sits one modifier away from ctrl-d in a
1413
+ // header that lists both. One is a filter and the other destroys a row,
1414
+ // so a near-miss is a deleted agent. `delete` is the key that already
1415
+ // means remove this, and it collides with no filter letter.
1416
+ "--bind",
1417
+ `delete:reload(${process.execPath} ${self} pick --forget {1}${allFlag})`,
1343
1418
  ...filterBinds,
1344
1419
  "--no-select-1",
1345
1420
  "--no-exit-0"
@@ -1366,6 +1441,12 @@ async function runPick(store, options = {}) {
1366
1441
  process.exitCode = 1;
1367
1442
  }
1368
1443
  }
1444
+ async function runForget(store, agentId, options = {}) {
1445
+ const view = status(store);
1446
+ const agent = view.agents.find((candidate) => candidate.agent_id === agentId);
1447
+ if (agent) forgetOneAgent(store, agent);
1448
+ await runRows(store, options);
1449
+ }
1369
1450
  async function runRows(store, options = {}) {
1370
1451
  const identity = loadIdentity();
1371
1452
  const view = await statusWithCollect(store);
@@ -1380,16 +1461,19 @@ async function runRows(store, options = {}) {
1380
1461
  }
1381
1462
  }
1382
1463
  function registerPick(program2) {
1383
- program2.command("pick").description("Pick an agent and jump to it").option("--all", "include orchestrated agents").option("--preview <agent-id>", "render the preview pane for one agent (internal)").option("--rows", "print picker rows only (internal, for reload)").action(async (options) => {
1384
- const store = openStore();
1385
- try {
1386
- if (options.preview) runPreview(store, options.preview);
1387
- else if (options.rows) await runRows(store, options);
1388
- else await runPick(store, options);
1389
- } finally {
1390
- store.close();
1464
+ program2.command("pick").description("Pick an agent and jump to it").option("--all", "include orchestrated agents").option("--preview <agent-id>", "render the preview pane for one agent (internal)").option("--rows", "print picker rows only (internal, for reload)").option("--forget <agent-id>", "drop one agent, then print rows (internal)").action(
1465
+ async (options) => {
1466
+ const store = openStore();
1467
+ try {
1468
+ if (options.preview) runPreview(store, options.preview);
1469
+ else if (options.forget) await runForget(store, options.forget, options);
1470
+ else if (options.rows) await runRows(store, options);
1471
+ else await runPick(store, options);
1472
+ } finally {
1473
+ store.close();
1474
+ }
1391
1475
  }
1392
- });
1476
+ );
1393
1477
  }
1394
1478
 
1395
1479
  // src/cli/status.ts
@@ -1409,7 +1493,9 @@ function registerStatus(program2) {
1409
1493
  }
1410
1494
 
1411
1495
  // src/index.ts
1412
- var VERSION = "0.1.0";
1496
+ import { createRequire } from "module";
1497
+ var manifest = createRequire(import.meta.url)("../package.json");
1498
+ var VERSION = manifest.version;
1413
1499
 
1414
1500
  // src/cli.ts
1415
1501
  var program = new Command();