@martintrojer/murmur 0.1.2 → 0.1.4

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
@@ -223,11 +223,19 @@ latencies. Both are the reader pulling, and OpenSSH `ControlMaster` collapses
223
223
  them, because the persisted control socket *is* the tunnel. Push needs a
224
224
  listener, which is the thing this design does not have.
225
225
 
226
- **The collector never initiates authentication.** A machine that wants a
227
- hardware-token touch per connection makes a background collector intolerable, so
228
- the collector rides an existing warm control socket and fails fast otherwise.
229
- The consequence is correct: remote visibility is a side effect of having worked
230
- on that box, and a cold host shows stale until you connect for any reason.
226
+ **The collector never prompts.** It reuses a warm `ControlMaster` socket when
227
+ there is one and cold-connects when there is not, so with ordinary key auth a
228
+ peer is visible whether or not you have ssh'd there recently — fleet visibility
229
+ is not rationed by a daily chore. `BatchMode=yes` is what makes the cold path
230
+ acceptable: no password, passphrase or host-key prompt, so a peer that cannot
231
+ authenticate silently fails fast and shows stale instead of blocking on a
232
+ human.
233
+
234
+ The unhandled case is a host demanding a hardware-token touch per connection,
235
+ where "cannot authenticate without a human" is not something `BatchMode` can
236
+ detect before the token blinks. `hasWarmSocket` exists for that: gating collect
237
+ on it per peer would restore the strict posture for those hosts only. Not wired
238
+ up, because no peer in use needs it.
231
239
 
232
240
  **Membership is local and asymmetric.** No shared node list, no registry, no
233
241
  join protocol. Reachability is not symmetric: a laptop reaches a server, and
package/CHANGELOG.md ADDED
@@ -0,0 +1,75 @@
1
+ # Changelog
2
+
3
+ Notable changes per release. Written for someone deciding whether to upgrade,
4
+ so it says what changed for a user rather than listing every commit.
5
+
6
+ ## 0.1.4
7
+
8
+ A review pass over the peer-collection code, and the bugs it found.
9
+
10
+ Collection is driven by tmux re-running `murmur status` on a tick, and the peer
11
+ loop was serial: every peer paid the ssh timeout of every peer ahead of it, so
12
+ three sleeping laptops froze the status bar for thirty seconds. Peers are now
13
+ fetched concurrently, with a bound on the whole collect rather than only on each
14
+ peer.
15
+
16
+ Fixed, each with a symptom you could have hit:
17
+
18
+ - **A large peer could never sync.** The ssh export ran into Node's default
19
+ 1 MiB output limit, about 2,600 events. Past that the collect failed, and it
20
+ failed permanently: the watermark only advances on success, so every retry
21
+ re-requested the same oversized range. A reachable peer sat stale forever.
22
+ - **Jumping to an agent claimed success even when it failed.** A failed
23
+ new-window, ssh attach, or select-window all reported success, so the picker
24
+ closed and nothing moved, with no message. Jump now reports the failure.
25
+ - **A recovered host could stay marked "no tmux".** The recovery check counted
26
+ database inserts, which read zero on a retry after a partial write, so the
27
+ host stayed marked dead until it happened to author a new event.
28
+ - **The pi extension leaked a database handle per failed write**, inside a
29
+ process that can run for days.
30
+ - **ssh timeouts are sized to the status-bar tick** and deliberately
31
+ aggressive. A slow node is now rejected rather than allowed to hold up the
32
+ HUD; it shows stale until the next tick.
33
+ - **Retention ran only when peers were configured.** A single-machine node
34
+ never pruned, so its event log grew without bound.
35
+
36
+ Internal, no behaviour change: one shared ssh option list instead of three
37
+ hand-rolled copies, `clear`'s queries moved behind `Store`, and `STALENESS_MS`
38
+ states its value instead of deriving it from a collect interval nothing
39
+ enforced.
40
+
41
+ Tests went 83 to 103. Four existing tests could not fail and were rewritten;
42
+ every new test was verified by breaking the code it covers.
43
+
44
+ ## 0.1.3
45
+
46
+ Both fixes are about the peer columns being unreadable.
47
+
48
+ - `peer list` printed tab-separated fields with no header. Now a header and
49
+ aligned columns.
50
+ - `murmur pick` showed the node's self-reported hostname, which can be a
51
+ container id -- a string that appears nowhere else and cannot be typed at
52
+ `peer remove`. It now shows the peer name you configured.
53
+
54
+ ## 0.1.2
55
+
56
+ - **The picker had a doubled border inside a tmux popup.** `display-popup` draws
57
+ its own, so fzf's sat one character inside it. The popup is the normal way to
58
+ run the picker, so this was the common case.
59
+ - **Documented the focus-clear hooks**, which have to be wired by hand per node.
60
+ Without them a finished agent stays marked `done` forever and the picker fills
61
+ with rows that need nothing.
62
+
63
+ ## 0.1.1
64
+
65
+ - A stale badge is now reconciled, and a shell pane no longer clears the badge
66
+ of the agent pane next to it.
67
+ - Agents are searchable by tmux session, and typing matches substrings rather
68
+ than scattered characters.
69
+ - The delete key drops a stuck row from the picker.
70
+ - `--version` reads the manifest instead of a hardcoded string.
71
+ - Only `$TMUX_PANE` decides whether we are inside tmux.
72
+
73
+ ## 0.1.0
74
+
75
+ First release. Agent state across every machine you work on, in one view.
package/README.md CHANGED
@@ -126,7 +126,7 @@ session, and its host, as literal substrings rather than scattered characters.
126
126
 
127
127
  ## Status
128
128
 
129
- **0.1.2.** In daily use on one machine and verified across two over real ssh.
129
+ **0.1.3.** In daily use on one machine and verified across two over real ssh.
130
130
  It is new and not battle-tested. The known gaps are listed at the end of
131
131
  [ARCHITECTURE.md](ARCHITECTURE.md#known-gaps); the one most likely to annoy you
132
132
  is that jumping to a remote agent nests tmux inside tmux, which every tool in
package/dist/cli.js CHANGED
@@ -3,9 +3,6 @@
3
3
  // src/cli.ts
4
4
  import { Command } from "commander";
5
5
 
6
- // src/cli/clear.ts
7
- import Database2 from "better-sqlite3";
8
-
9
6
  // src/identity.ts
10
7
  import { randomUUID } from "crypto";
11
8
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
@@ -102,7 +99,7 @@ var tmux = {
102
99
  },
103
100
  attach(session, window) {
104
101
  runTmux(["switch-client", "-t", session]);
105
- runTmux(["select-window", "-t", window]);
102
+ return runTmux(["select-window", "-t", window]) !== null;
106
103
  },
107
104
  // Window ids are what the log stores, because they are stable; names are
108
105
  // what a human recognises in a picker. Names are live tmux state, not
@@ -134,7 +131,10 @@ var tmux = {
134
131
  return null;
135
132
  },
136
133
  selectWindow(window) {
137
- runTmux(["select-window", "-t", window]);
134
+ return runTmux(["select-window", "-t", window]) !== null;
135
+ },
136
+ newWindow(name, command) {
137
+ return runTmux(["new-window", "-n", name, command]) !== null;
138
138
  },
139
139
  // The window a pane belongs to, for a pane murmur has no event for. Clearing
140
140
  // a badge is a tmux operation and does not require murmur to own the pane.
@@ -322,6 +322,14 @@ function openStore() {
322
322
  const rows = database.prepare("SELECT * FROM events ORDER BY ts, host_id, seq").all();
323
323
  return rows.map(toEvent);
324
324
  },
325
+ latestForAgent(hostId, agentId) {
326
+ const row = database.prepare(
327
+ `SELECT * FROM events
328
+ WHERE host_id = ? AND agent_id = ?
329
+ ORDER BY seq DESC LIMIT 1`
330
+ ).get(hostId, agentId);
331
+ return row ? toEvent(row) : null;
332
+ },
325
333
  maxSeq(hostId) {
326
334
  return selectMaxSeq.get(hostId).seq;
327
335
  },
@@ -379,23 +387,15 @@ function openStore() {
379
387
  }
380
388
 
381
389
  // src/cli/clear.ts
382
- function windowHasAgent(window, focused, hostId, mux) {
390
+ function windowHasAgent(window, focused, hostId, mux, store) {
383
391
  if (!hostId) return false;
384
392
  const siblings = mux.panesInWindow(window).filter((candidate) => candidate !== focused);
385
393
  if (siblings.length === 0) return false;
394
+ if (!store) return false;
386
395
  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();
396
+ for (const sibling of siblings) {
397
+ const latest = store.latestForAgent(hostId, `${hostId}:${sibling}`);
398
+ if (latest && latest.state !== "cleared") return true;
399
399
  }
400
400
  return false;
401
401
  } catch {
@@ -403,6 +403,7 @@ function windowHasAgent(window, focused, hostId, mux) {
403
403
  }
404
404
  }
405
405
  function clearPane(pane, mux = tmux) {
406
+ let store;
406
407
  try {
407
408
  if (!pane) return;
408
409
  const window = mux.windowForPane(pane);
@@ -410,24 +411,13 @@ function clearPane(pane, mux = tmux) {
410
411
  let owner;
411
412
  if (identity) {
412
413
  try {
413
- const database = new Database2(dbPath(), { readonly: true, fileMustExist: true });
414
- try {
415
- owner = database.prepare(
416
- `SELECT agent_id, session, window, pane, session_name, window_name,
417
- agent_name, pi_session, workstream, role, cli, driver, state
418
- FROM events
419
- WHERE host_id = ? AND agent_id = ?
420
- ORDER BY seq DESC
421
- LIMIT 1`
422
- ).get(identity.host_id, `${identity.host_id}:${pane}`);
423
- } finally {
424
- database.close();
425
- }
414
+ store = openStore();
415
+ owner = store.latestForAgent(identity.host_id, `${identity.host_id}:${pane}`) ?? void 0;
426
416
  } catch {
427
417
  }
428
418
  }
429
419
  if (!owner) {
430
- if (window && !windowHasAgent(window, pane, identity?.host_id, mux)) {
420
+ if (window && !windowHasAgent(window, pane, identity?.host_id, mux, store)) {
431
421
  mux.setState(window, null);
432
422
  }
433
423
  return;
@@ -436,9 +426,8 @@ function clearPane(pane, mux = tmux) {
436
426
  mux.setState(owner.window, null);
437
427
  return;
438
428
  }
439
- const store = openStore();
440
429
  try {
441
- store.append({
430
+ store?.append({
442
431
  agent_id: owner.agent_id,
443
432
  session: owner.session,
444
433
  window: owner.window,
@@ -461,11 +450,15 @@ function clearPane(pane, mux = tmux) {
461
450
  reason: "",
462
451
  extra: {}
463
452
  });
464
- } finally {
465
- store.close();
453
+ } catch {
466
454
  }
467
455
  mux.setState(owner.window, null);
468
456
  } catch {
457
+ } finally {
458
+ try {
459
+ store?.close();
460
+ } catch {
461
+ }
469
462
  }
470
463
  }
471
464
  function registerClear(program2) {
@@ -477,8 +470,8 @@ import { execFile, execFileSync as execFileSync2 } from "child_process";
477
470
  import { promisify } from "util";
478
471
  var execFileAsync = promisify(execFile);
479
472
  var CONTROL_PATH = "~/.ssh/control/%r@%h:%p";
480
- var CONNECT_TIMEOUT_S = 2;
481
- var EXEC_TIMEOUT_MS = 1e4;
473
+ var CONNECT_TIMEOUT_S = 1;
474
+ var EXEC_TIMEOUT_MS = 3e3;
482
475
  var SSH_OPTIONS = [
483
476
  "-o",
484
477
  "BatchMode=yes",
@@ -489,11 +482,13 @@ var SSH_OPTIONS = [
489
482
  "-o",
490
483
  `ConnectTimeout=${CONNECT_TIMEOUT_S}`
491
484
  ];
485
+ var MAX_EXPORT_BYTES = 64 * 1024 * 1024;
492
486
  var ssh = {
493
487
  async exec(target, argv) {
494
488
  const { stdout } = await execFileAsync("ssh", [...SSH_OPTIONS, target, ...argv], {
495
489
  encoding: "utf8",
496
- timeout: EXEC_TIMEOUT_MS
490
+ timeout: EXEC_TIMEOUT_MS,
491
+ maxBuffer: MAX_EXPORT_BYTES
497
492
  });
498
493
  return stdout;
499
494
  }
@@ -693,8 +688,30 @@ function exportJsonl(store, since, isAlive, live) {
693
688
  }
694
689
 
695
690
  // src/collector.ts
696
- var COLLECT_INTERVAL_MS = 3e4;
697
- var STALENESS_MS = 2 * COLLECT_INTERVAL_MS;
691
+ var STALENESS_MS = 6e4;
692
+ var MAX_CONCURRENT_PEERS = 8;
693
+ var COLLECT_DEADLINE_MS = 4e3;
694
+ async function mapSettled(items, limit, task, deadline) {
695
+ const results = new Array(items.length);
696
+ let cursor = 0;
697
+ let expired = false;
698
+ const stop = deadline?.then(() => {
699
+ expired = true;
700
+ });
701
+ const worker = async () => {
702
+ while (cursor < items.length && !expired) {
703
+ const index = cursor++;
704
+ try {
705
+ results[index] = { status: "fulfilled", value: await task(items[index]) };
706
+ } catch (reason) {
707
+ results[index] = { status: "rejected", reason };
708
+ }
709
+ }
710
+ };
711
+ const pool = Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
712
+ await (stop ? Promise.race([pool, stop]) : pool);
713
+ return results;
714
+ }
698
715
  function parseJsonl(output) {
699
716
  const lines = output.trim().split("\n");
700
717
  const envelope = JSON.parse(lines.shift() ?? "");
@@ -708,20 +725,35 @@ function parseJsonl(output) {
708
725
  events: lines.map((line) => eventFromWire(JSON.parse(line)))
709
726
  };
710
727
  }
711
- async function collect(store, channel, now = Date.now()) {
728
+ async function collect(store, channel, now = Date.now(), deadline) {
712
729
  const results = [];
730
+ let timer;
713
731
  try {
714
- for (const peer of store.peers()) {
732
+ const peers = store.peers();
733
+ const bounded = deadline ?? new Promise((resolve) => {
734
+ timer = setTimeout(resolve, COLLECT_DEADLINE_MS);
735
+ timer.unref?.();
736
+ });
737
+ const fetches = await mapSettled(
738
+ peers,
739
+ MAX_CONCURRENT_PEERS,
740
+ async (peer) => parseJsonl(
741
+ await channel.exec(peer.target, ["murmur", "export", "--since", String(peer.watermark)])
742
+ ),
743
+ bounded
744
+ );
745
+ for (const [index, peer] of peers.entries()) {
746
+ const fetch = fetches[index];
715
747
  try {
716
- const output = await channel.exec(peer.target, [
717
- "murmur",
718
- "export",
719
- "--since",
720
- String(peer.watermark)
721
- ]);
722
- const { envelope, events } = parseJsonl(output);
748
+ if (!fetch) throw new Error("collect deadline passed before this peer answered");
749
+ if (fetch.status === "rejected") throw fetch.reason;
750
+ const { envelope, events } = fetch.value;
723
751
  const ingested = store.ingest(events);
724
- const watermark = events.filter((event) => event.host_id === envelope.host_id).reduce((highest, event) => Math.max(highest, event.seq), peer.watermark);
752
+ const origin = events.filter((event) => event.host_id === envelope.host_id);
753
+ const watermark = origin.reduce(
754
+ (highest, event) => Math.max(highest, event.seq),
755
+ peer.watermark
756
+ );
725
757
  store.upsertPeer({
726
758
  name: peer.name,
727
759
  target: peer.target,
@@ -734,9 +766,16 @@ async function collect(store, channel, now = Date.now()) {
734
766
  // events: an export that returns nothing proves the binary ran, not
735
767
  // that tmux is back, which is the distinction that let a dead host
736
768
  // look healthy for three hours.
737
- tmux_down_at: ingested > 0 ? null : peer.tmux_down_at
769
+ //
770
+ // Keyed on the watermark advancing, not on ingest's insert count.
771
+ // Two reasons the count was wrong. Ingest is INSERT OR IGNORE, so a
772
+ // retry after a partial apply re-sees the same events and reports
773
+ // zero -- leaving a recovered host marked down until it happened to
774
+ // author again. And the count includes rows from other origins that
775
+ // this peer merely relayed, which say nothing about whether this
776
+ // peer's tmux is back.
777
+ tmux_down_at: watermark > peer.watermark ? null : peer.tmux_down_at
738
778
  });
739
- store.prune();
740
779
  results.push({ peer: peer.name, ok: true, ingested });
741
780
  } catch (error) {
742
781
  const message = error instanceof Error ? error.message : String(error);
@@ -748,6 +787,16 @@ async function collect(store, channel, now = Date.now()) {
748
787
  } catch (error) {
749
788
  process.stderr.write(
750
789
  `murmur: collect: ${error instanceof Error ? error.message : String(error)}
790
+ `
791
+ );
792
+ } finally {
793
+ clearTimeout(timer);
794
+ }
795
+ try {
796
+ store.prune();
797
+ } catch (error) {
798
+ process.stderr.write(
799
+ `murmur: collect: prune: ${error instanceof Error ? error.message : String(error)}
751
800
  `
752
801
  );
753
802
  }
@@ -842,6 +891,34 @@ function sshHosts() {
842
891
  return [];
843
892
  }
844
893
  }
894
+ function formatTable(rows) {
895
+ const widths = [];
896
+ for (const row of rows) {
897
+ row.forEach((cell, index) => {
898
+ widths[index] = Math.max(widths[index] ?? 0, cell.length);
899
+ });
900
+ }
901
+ return rows.map(
902
+ (row) => row.map((cell, index) => index === row.length - 1 ? cell : cell.padEnd(widths[index] ?? 0)).join(" ").trimEnd()
903
+ ).map((line) => `${line}
904
+ `).join("");
905
+ }
906
+ function peerAddDecision(input) {
907
+ const { name, target, envelope, selfHostId, peers } = input;
908
+ if (!envelope) return null;
909
+ if (envelope.host_id === selfHostId) {
910
+ return `${target} is this node; not adding it as a peer
911
+ `;
912
+ }
913
+ const existing = peers.find(
914
+ (candidate) => candidate.host_id === envelope.host_id && candidate.name !== name
915
+ );
916
+ if (existing) {
917
+ return `${target} is already configured as peer "${existing.name}" (${envelope.display_name}); remove it first to rename
918
+ `;
919
+ }
920
+ return null;
921
+ }
845
922
  function registerPeer(program2) {
846
923
  const peer = program2.command("peer").description("Manage peers");
847
924
  peer.command("add").description("Add a peer and discover its identity").argument("<name>").argument("[target]").action(async (name, target = name) => {
@@ -854,22 +931,17 @@ function registerPeer(program2) {
854
931
  } catch {
855
932
  envelope = null;
856
933
  }
857
- if (envelope) {
858
- if (envelope.host_id === loadIdentity()?.host_id) {
859
- process.stderr.write(`${target} is this node; not adding it as a peer
860
- `);
861
- process.exitCode = 1;
862
- return;
863
- }
864
- const existing = store.peers().find((candidate) => candidate.host_id === envelope.host_id && candidate.name !== name);
865
- if (existing) {
866
- process.stderr.write(
867
- `${target} is already configured as peer "${existing.name}" (${envelope.display_name}); remove it first to rename
868
- `
869
- );
870
- process.exitCode = 1;
871
- return;
872
- }
934
+ const refusal = peerAddDecision({
935
+ name,
936
+ target,
937
+ envelope,
938
+ selfHostId: loadIdentity()?.host_id ?? null,
939
+ peers: store.peers()
940
+ });
941
+ if (refusal) {
942
+ process.stderr.write(refusal);
943
+ process.exitCode = 1;
944
+ return;
873
945
  }
874
946
  store.upsertPeer({
875
947
  name,
@@ -904,16 +976,26 @@ function registerPeer(program2) {
904
976
  const store = openStore();
905
977
  try {
906
978
  const peers = store.peers();
907
- if (options.json) process.stdout.write(`${JSON.stringify(peers)}
979
+ if (options.json) {
980
+ process.stdout.write(`${JSON.stringify(peers)}
908
981
  `);
909
- else {
910
- for (const configured of peers) {
911
- process.stdout.write(
912
- `${configured.name} ${configured.target} ${configured.display_name ?? "unknown"}
913
- `
914
- );
915
- }
982
+ return;
916
983
  }
984
+ if (peers.length === 0) {
985
+ process.stdout.write("no peers configured\n");
986
+ return;
987
+ }
988
+ const rows = [
989
+ // HOSTNAME, not HOST: this is what the node reported about itself,
990
+ // which is not the handle any other command takes. NAME is.
991
+ ["NAME", "TARGET", "HOSTNAME"],
992
+ ...peers.map((configured) => [
993
+ configured.name,
994
+ configured.target,
995
+ configured.display_name ?? "unknown"
996
+ ])
997
+ ];
998
+ process.stdout.write(formatTable(rows));
917
999
  } finally {
918
1000
  store.close();
919
1001
  }
@@ -949,6 +1031,21 @@ function terminalText(value) {
949
1031
  function shellQuote(value) {
950
1032
  return `'${value.replaceAll("'", `'\\''`)}'`;
951
1033
  }
1034
+ var spawnRunner = (file, args, inherit = false) => {
1035
+ const result = spawnSync(file, args, {
1036
+ encoding: "utf8",
1037
+ timeout: 1e4,
1038
+ ...inherit ? { stdio: "inherit" } : {}
1039
+ });
1040
+ return {
1041
+ status: result.status,
1042
+ stdout: result.stdout ?? "",
1043
+ // spawnSync reports a failure to even start the child in `error`, leaving
1044
+ // status null. Collapsing both here keeps the decision table below reading
1045
+ // as one question rather than two.
1046
+ failed: result.error !== void 0
1047
+ };
1048
+ };
952
1049
  function forgetHostReplica(store, hostId) {
953
1050
  try {
954
1051
  const peer = store.peers().find((candidate) => candidate.host_id === hostId);
@@ -981,10 +1078,10 @@ function forgetOneAgent(store, agent, mux = tmux) {
981
1078
  }
982
1079
  forgetReplica(store, agent.agent_id, agent.host_id);
983
1080
  }
984
- function jumpToAgent(store, agent) {
1081
+ function jumpToAgent(store, agent, mux = tmux, run = spawnRunner) {
985
1082
  const identity = loadIdentity();
986
1083
  if (agent.host_id === identity?.host_id) {
987
- const live = tmux.liveWindows();
1084
+ const live = mux.liveWindows();
988
1085
  if (live && !live.has(agent.window)) {
989
1086
  forgetReplica(store, agent.agent_id, agent.host_id);
990
1087
  return {
@@ -993,7 +1090,13 @@ function jumpToAgent(store, agent) {
993
1090
  message: `${agentLabel(agent)} is gone -- its window no longer exists. Cleared.`
994
1091
  };
995
1092
  }
996
- tmux.attach(agent.session, agent.window);
1093
+ if (!mux.attach(agent.session, agent.window)) {
1094
+ return {
1095
+ ok: false,
1096
+ reason: "attach_failed",
1097
+ message: `could not attach to ${agentLabel(agent)} (tmux select-window failed).`
1098
+ };
1099
+ }
997
1100
  return { ok: true };
998
1101
  }
999
1102
  const peer = store.peers().find((candidate) => candidate.host_id === agent.host_id);
@@ -1005,18 +1108,18 @@ function jumpToAgent(store, agent) {
1005
1108
  message: `No peer configured for host ${agent.host_id.slice(0, 8)}. Try: murmur peer add <target>`
1006
1109
  };
1007
1110
  }
1008
- const probe = spawnSync(
1009
- "ssh",
1010
- ["-o", "BatchMode=yes", target, `tmux list-windows -a -F ${shellQuote("#{window_id}")}`],
1011
- { encoding: "utf8", timeout: 1e4 }
1012
- );
1111
+ const probe = run("ssh", [
1112
+ ...SSH_OPTIONS,
1113
+ target,
1114
+ `tmux list-windows -a -F ${shellQuote("#{window_id}")}`
1115
+ ]);
1013
1116
  if (probe.status !== 0) {
1014
- const sshFailed = probe.status === 255 || probe.error !== void 0;
1117
+ const sshFailed = probe.status === 255 || probe.failed;
1015
1118
  if (sshFailed) {
1016
1119
  return {
1017
1120
  ok: false,
1018
1121
  reason: "unreachable",
1019
- message: `cannot reach ${target} over ssh. The collector never prompts for auth, so connect once by hand to warm the connection, then retry.`
1122
+ 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.`
1020
1123
  };
1021
1124
  }
1022
1125
  forgetHostReplica(store, agent.host_id);
@@ -1026,7 +1129,7 @@ function jumpToAgent(store, agent) {
1026
1129
  message: `${target} has no tmux server running, so its agents are gone. Removed them; they will come back when it reports again.`
1027
1130
  };
1028
1131
  }
1029
- const remoteWindows = new Set((probe.stdout ?? "").split("\n").filter(Boolean));
1132
+ const remoteWindows = new Set(probe.stdout.split("\n").filter(Boolean));
1030
1133
  if (!remoteWindows.has(agent.window)) {
1031
1134
  forgetReplica(store, agent.agent_id, agent.host_id);
1032
1135
  return {
@@ -1038,32 +1141,32 @@ function jumpToAgent(store, agent) {
1038
1141
  const attachTarget = shellQuote(`${agent.session}:${agent.window}`);
1039
1142
  if (process.env.TMUX) {
1040
1143
  const command = `ssh -t ${shellQuote(target)} tmux attach -t ${shellQuote(attachTarget)}`;
1041
- const name = `@${peer?.display_name ?? target}`;
1042
- const existing = tmux.windowNamed(name);
1144
+ const name = `@${peer?.name ?? target}`;
1145
+ const existing = mux.windowNamed(name);
1043
1146
  if (existing) {
1044
- tmux.selectWindow(existing);
1045
- return { ok: true };
1147
+ return mux.selectWindow(existing) ? { ok: true } : {
1148
+ ok: false,
1149
+ reason: "attach_failed",
1150
+ message: `could not switch to the existing ${name} window.`
1151
+ };
1046
1152
  }
1047
- spawnSync("tmux", ["new-window", "-n", name, command], { stdio: "ignore" });
1048
- return { ok: true };
1153
+ return mux.newWindow(name, command) ? { ok: true } : {
1154
+ ok: false,
1155
+ reason: "attach_failed",
1156
+ message: `could not open a window to attach to ${target}.`
1157
+ };
1049
1158
  }
1050
- spawnSync("ssh", ["-t", target, "tmux", "attach", "-t", attachTarget], { stdio: "inherit" });
1051
- return { ok: true };
1159
+ const attach = run("ssh", ["-t", target, "tmux", "attach", "-t", attachTarget], true);
1160
+ return attach.status === 0 && !attach.failed ? { ok: true } : {
1161
+ ok: false,
1162
+ reason: "attach_failed",
1163
+ message: `ssh attach to ${target} failed.`
1164
+ };
1052
1165
  }
1053
1166
 
1054
1167
  // src/glance.ts
1055
1168
  import { execFileSync as execFileSync3 } from "child_process";
1056
1169
  var GLANCE_LINES = 40;
1057
- var SSH_OPTIONS2 = [
1058
- "-o",
1059
- "BatchMode=yes",
1060
- "-o",
1061
- "ControlMaster=no",
1062
- "-o",
1063
- "ControlPath=~/.ssh/control/%r@%h:%p",
1064
- "-o",
1065
- "ConnectTimeout=2"
1066
- ];
1067
1170
  function glance(store, agent, lines = GLANCE_LINES) {
1068
1171
  if (agent.host_id === loadIdentity()?.host_id) return tmux.capture(agent.pane, lines);
1069
1172
  const peer = store.peers().find((candidate) => candidate.host_id === agent.host_id);
@@ -1073,7 +1176,7 @@ function glance(store, agent, lines = GLANCE_LINES) {
1073
1176
  return execFileSync3(
1074
1177
  "ssh",
1075
1178
  [
1076
- ...SSH_OPTIONS2,
1179
+ ...SSH_OPTIONS,
1077
1180
  target,
1078
1181
  "tmux",
1079
1182
  "capture-pane",
@@ -1137,7 +1240,13 @@ function status(store, now = Date.now()) {
1137
1240
  // A jump proved this host's tmux was down and nothing has authored since.
1138
1241
  // Stronger than staleness: the host answers, its agents are just gone.
1139
1242
  tmux_down: peer?.tmux_down_at != null,
1140
- host: peer?.display_name ?? peer?.name ?? (agent.host_id === identity?.host_id ? identity.display_name : agent.host_id)
1243
+ // The name the human typed, not the machine's self-reported hostname. A
1244
+ // peer added as `linuxpc` reported `18c04d69b860` (a container hostname)
1245
+ // and that is what the picker showed — a string that appears nowhere
1246
+ // else in the tool and cannot be typed at `peer remove` or searched for.
1247
+ // Only the local node, which has no peer row, falls back to its own
1248
+ // discovered display_name.
1249
+ host: peer?.name ?? (agent.host_id === identity?.host_id ? identity.display_name : agent.host_id)
1141
1250
  };
1142
1251
  });
1143
1252
  return {
@@ -1157,9 +1266,9 @@ function status(store, now = Date.now()) {
1157
1266
  }))
1158
1267
  };
1159
1268
  }
1160
- async function statusWithCollect(store, now = Date.now()) {
1269
+ async function statusWithCollect(store, now = Date.now(), channel = ssh) {
1161
1270
  try {
1162
- await collect(store, ssh, now);
1271
+ await collect(store, channel, now);
1163
1272
  } catch (error) {
1164
1273
  process.stderr.write(
1165
1274
  `murmur: status: collect: ${error instanceof Error ? error.message : String(error)}