@martintrojer/murmur 0.1.0 → 0.1.2
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 +34 -0
- package/README.md +34 -5
- package/dist/cli.js +116 -18
- package/dist/cli.js.map +1 -1
- package/dist/extension/murmur-pi.js +14 -1
- package/dist/extension/murmur-pi.js.map +1 -1
- package/dist/index.d.ts +23 -22
- package/dist/index.js +19 -2
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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
|
-
|
|
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.
|
|
129
|
+
**0.1.2.** 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
|
|
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
|
|
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("
|
|
1217
|
+
pad("stream", showHost ? COLUMNS.stream : COLUMNS.streamWide),
|
|
1167
1218
|
showHost ? pad("host", COLUMNS.host) : "",
|
|
1168
1219
|
"age / flags"
|
|
1169
1220
|
].filter(Boolean).join(" ");
|
|
@@ -1209,6 +1260,9 @@ function pad(value, width) {
|
|
|
1209
1260
|
const tail = value.slice(index).match(ANSI_AT_END);
|
|
1210
1261
|
return `${out}\u2026${tail?.[0] ?? ""}${" ".repeat(Math.max(0, width - budget - 1))}`;
|
|
1211
1262
|
}
|
|
1263
|
+
function isPopup(env) {
|
|
1264
|
+
return Boolean(env.TMUX) && !env.TMUX_PANE;
|
|
1265
|
+
}
|
|
1212
1266
|
function pickerRow(agent, showHost, current, local = true) {
|
|
1213
1267
|
const state = agent.state ?? "idle";
|
|
1214
1268
|
const colour = COLOUR[state] ?? "";
|
|
@@ -1216,7 +1270,8 @@ function pickerRow(agent, showHost, current, local = true) {
|
|
|
1216
1270
|
const marker = current ? `${BOLD}\u25C6${RESET}` : " ";
|
|
1217
1271
|
const name = agent.agent_name ?? agent.pi_session ?? agentLabel(agent);
|
|
1218
1272
|
const host = showHost ? local ? `${DIM} here${RESET}` : `${REMOTE}\u2192 ${terminalText(agent.host)}${RESET}` : "";
|
|
1219
|
-
const
|
|
1273
|
+
const group = agent.workstream ?? agent.session_name;
|
|
1274
|
+
const workstream = group ? `${DIM}${terminalText(group)}${RESET}` : "";
|
|
1220
1275
|
const flags = [
|
|
1221
1276
|
agent.driver === "orchestrated" ? "crew" : "",
|
|
1222
1277
|
agent.stale ? "unreachable" : "",
|
|
@@ -1297,6 +1352,8 @@ async function runPick(store, options = {}) {
|
|
|
1297
1352
|
}
|
|
1298
1353
|
const prompt = URGENCY.filter((state) => counts.get(state)).map((state) => `${COLOUR[state]}${GLYPH[state]}${counts.get(state)}${RESET}`).join(" ");
|
|
1299
1354
|
const self = process.argv[1] ?? "murmur";
|
|
1355
|
+
const allFlag = options.all ? " --all" : "";
|
|
1356
|
+
const inPopup = isPopup(process.env);
|
|
1300
1357
|
const width = process.stdout.columns ?? 0;
|
|
1301
1358
|
const previewLayout = width > 0 && width < 150 ? "bottom:60%,border-top,wrap" : "right:58%,border-left,wrap";
|
|
1302
1359
|
const preview = `${process.execPath} ${self} pick --preview {1}`;
|
|
@@ -1312,17 +1369,37 @@ async function runPick(store, options = {}) {
|
|
|
1312
1369
|
"--with-nth",
|
|
1313
1370
|
"2..",
|
|
1314
1371
|
"--ansi",
|
|
1315
|
-
|
|
1372
|
+
// Literal substring matching, and matching only the visible columns.
|
|
1373
|
+
// Default fuzzy scatters query characters across the row: `re` matched
|
|
1374
|
+
// "Fix Murmur Pick Fzf Filter" as well as "recovered". A query here is a
|
|
1375
|
+
// word or two of an agent or workstream name, so substring is what the
|
|
1376
|
+
// fingers expect. Prefix a token with ' to opt back into fuzzy.
|
|
1377
|
+
// Same choice as the tms session picker, for consistency across the two.
|
|
1378
|
+
"--exact",
|
|
1379
|
+
// `begin` ranks earlier match positions higher, so `scratch` puts the
|
|
1380
|
+
// scratch workstream above a row that merely mentions it. `index` is the
|
|
1381
|
+
// empty-query fallback and preserves the attention order the fold
|
|
1382
|
+
// produced, which is the whole point of the list.
|
|
1383
|
+
"--tiebreak",
|
|
1384
|
+
"begin,index",
|
|
1316
1385
|
"--layout",
|
|
1317
1386
|
"reverse",
|
|
1387
|
+
// `display-popup` draws its own border, so fzf's is a second one a
|
|
1388
|
+
// character inside the first. A popup is the normal way to run this, via
|
|
1389
|
+
// the prefix+a binding, so the doubled frame was what you saw most.
|
|
1390
|
+
//
|
|
1391
|
+
// Detected by $TMUX set with $TMUX_PANE unset: tmux exports TMUX to a
|
|
1392
|
+
// popup but not TMUX_PANE, since a popup is not a pane. Outside tmux
|
|
1393
|
+
// neither is set, so the three cases stay distinguishable.
|
|
1318
1394
|
"--border",
|
|
1395
|
+
inPopup ? "none" : "rounded",
|
|
1319
1396
|
"--info",
|
|
1320
1397
|
"inline",
|
|
1321
1398
|
"--prompt",
|
|
1322
1399
|
`${prompt}${prompt ? " " : ""}`,
|
|
1323
1400
|
"--header",
|
|
1324
1401
|
[
|
|
1325
|
-
`enter jump
|
|
1402
|
+
`enter jump ^r refresh ^p preview del forget filter: ${FILTER_KEYS.map(
|
|
1326
1403
|
([key, state]) => `${key.replace("ctrl-", "^")} ${state || "all"}`
|
|
1327
1404
|
).join(" ")}`,
|
|
1328
1405
|
hidden ? `${hidden} crew hidden (--all)` : "",
|
|
@@ -1339,7 +1416,17 @@ async function runPick(store, options = {}) {
|
|
|
1339
1416
|
"--bind",
|
|
1340
1417
|
"ctrl-p:change-preview-window(bottom:60%,border-top,wrap|hidden|right:58%,border-left,wrap)",
|
|
1341
1418
|
"--bind",
|
|
1342
|
-
`ctrl-r:reload(${process.execPath} ${self} pick --rows${
|
|
1419
|
+
`ctrl-r:reload(${process.execPath} ${self} pick --rows${allFlag})`,
|
|
1420
|
+
// Manual dismissal for a row nothing else will clear.
|
|
1421
|
+
//
|
|
1422
|
+
// The delete key, not a ctrl chord. ctrl-shift-d does not exist -- a
|
|
1423
|
+
// terminal sends the same bytes as ctrl-d -- and ctrl-alt-d, while it
|
|
1424
|
+
// does dispatch distinctly, sits one modifier away from ctrl-d in a
|
|
1425
|
+
// header that lists both. One is a filter and the other destroys a row,
|
|
1426
|
+
// so a near-miss is a deleted agent. `delete` is the key that already
|
|
1427
|
+
// means remove this, and it collides with no filter letter.
|
|
1428
|
+
"--bind",
|
|
1429
|
+
`delete:reload(${process.execPath} ${self} pick --forget {1}${allFlag})`,
|
|
1343
1430
|
...filterBinds,
|
|
1344
1431
|
"--no-select-1",
|
|
1345
1432
|
"--no-exit-0"
|
|
@@ -1366,6 +1453,12 @@ async function runPick(store, options = {}) {
|
|
|
1366
1453
|
process.exitCode = 1;
|
|
1367
1454
|
}
|
|
1368
1455
|
}
|
|
1456
|
+
async function runForget(store, agentId, options = {}) {
|
|
1457
|
+
const view = status(store);
|
|
1458
|
+
const agent = view.agents.find((candidate) => candidate.agent_id === agentId);
|
|
1459
|
+
if (agent) forgetOneAgent(store, agent);
|
|
1460
|
+
await runRows(store, options);
|
|
1461
|
+
}
|
|
1369
1462
|
async function runRows(store, options = {}) {
|
|
1370
1463
|
const identity = loadIdentity();
|
|
1371
1464
|
const view = await statusWithCollect(store);
|
|
@@ -1380,16 +1473,19 @@ async function runRows(store, options = {}) {
|
|
|
1380
1473
|
}
|
|
1381
1474
|
}
|
|
1382
1475
|
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)").
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1476
|
+
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(
|
|
1477
|
+
async (options) => {
|
|
1478
|
+
const store = openStore();
|
|
1479
|
+
try {
|
|
1480
|
+
if (options.preview) runPreview(store, options.preview);
|
|
1481
|
+
else if (options.forget) await runForget(store, options.forget, options);
|
|
1482
|
+
else if (options.rows) await runRows(store, options);
|
|
1483
|
+
else await runPick(store, options);
|
|
1484
|
+
} finally {
|
|
1485
|
+
store.close();
|
|
1486
|
+
}
|
|
1391
1487
|
}
|
|
1392
|
-
|
|
1488
|
+
);
|
|
1393
1489
|
}
|
|
1394
1490
|
|
|
1395
1491
|
// src/cli/status.ts
|
|
@@ -1409,7 +1505,9 @@ function registerStatus(program2) {
|
|
|
1409
1505
|
}
|
|
1410
1506
|
|
|
1411
1507
|
// src/index.ts
|
|
1412
|
-
|
|
1508
|
+
import { createRequire } from "module";
|
|
1509
|
+
var manifest = createRequire(import.meta.url)("../package.json");
|
|
1510
|
+
var VERSION = manifest.version;
|
|
1413
1511
|
|
|
1414
1512
|
// src/cli.ts
|
|
1415
1513
|
var program = new Command();
|