@martintrojer/murmur 0.1.3 → 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/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
  }
@@ -854,6 +903,22 @@ function formatTable(rows) {
854
903
  ).map((line) => `${line}
855
904
  `).join("");
856
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
+ }
857
922
  function registerPeer(program2) {
858
923
  const peer = program2.command("peer").description("Manage peers");
859
924
  peer.command("add").description("Add a peer and discover its identity").argument("<name>").argument("[target]").action(async (name, target = name) => {
@@ -866,22 +931,17 @@ function registerPeer(program2) {
866
931
  } catch {
867
932
  envelope = null;
868
933
  }
869
- if (envelope) {
870
- if (envelope.host_id === loadIdentity()?.host_id) {
871
- process.stderr.write(`${target} is this node; not adding it as a peer
872
- `);
873
- process.exitCode = 1;
874
- return;
875
- }
876
- const existing = store.peers().find((candidate) => candidate.host_id === envelope.host_id && candidate.name !== name);
877
- if (existing) {
878
- process.stderr.write(
879
- `${target} is already configured as peer "${existing.name}" (${envelope.display_name}); remove it first to rename
880
- `
881
- );
882
- process.exitCode = 1;
883
- return;
884
- }
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;
885
945
  }
886
946
  store.upsertPeer({
887
947
  name,
@@ -971,6 +1031,21 @@ function terminalText(value) {
971
1031
  function shellQuote(value) {
972
1032
  return `'${value.replaceAll("'", `'\\''`)}'`;
973
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
+ };
974
1049
  function forgetHostReplica(store, hostId) {
975
1050
  try {
976
1051
  const peer = store.peers().find((candidate) => candidate.host_id === hostId);
@@ -1003,10 +1078,10 @@ function forgetOneAgent(store, agent, mux = tmux) {
1003
1078
  }
1004
1079
  forgetReplica(store, agent.agent_id, agent.host_id);
1005
1080
  }
1006
- function jumpToAgent(store, agent) {
1081
+ function jumpToAgent(store, agent, mux = tmux, run = spawnRunner) {
1007
1082
  const identity = loadIdentity();
1008
1083
  if (agent.host_id === identity?.host_id) {
1009
- const live = tmux.liveWindows();
1084
+ const live = mux.liveWindows();
1010
1085
  if (live && !live.has(agent.window)) {
1011
1086
  forgetReplica(store, agent.agent_id, agent.host_id);
1012
1087
  return {
@@ -1015,7 +1090,13 @@ function jumpToAgent(store, agent) {
1015
1090
  message: `${agentLabel(agent)} is gone -- its window no longer exists. Cleared.`
1016
1091
  };
1017
1092
  }
1018
- 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
+ }
1019
1100
  return { ok: true };
1020
1101
  }
1021
1102
  const peer = store.peers().find((candidate) => candidate.host_id === agent.host_id);
@@ -1027,18 +1108,18 @@ function jumpToAgent(store, agent) {
1027
1108
  message: `No peer configured for host ${agent.host_id.slice(0, 8)}. Try: murmur peer add <target>`
1028
1109
  };
1029
1110
  }
1030
- const probe = spawnSync(
1031
- "ssh",
1032
- ["-o", "BatchMode=yes", target, `tmux list-windows -a -F ${shellQuote("#{window_id}")}`],
1033
- { encoding: "utf8", timeout: 1e4 }
1034
- );
1111
+ const probe = run("ssh", [
1112
+ ...SSH_OPTIONS,
1113
+ target,
1114
+ `tmux list-windows -a -F ${shellQuote("#{window_id}")}`
1115
+ ]);
1035
1116
  if (probe.status !== 0) {
1036
- const sshFailed = probe.status === 255 || probe.error !== void 0;
1117
+ const sshFailed = probe.status === 255 || probe.failed;
1037
1118
  if (sshFailed) {
1038
1119
  return {
1039
1120
  ok: false,
1040
1121
  reason: "unreachable",
1041
- 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.`
1042
1123
  };
1043
1124
  }
1044
1125
  forgetHostReplica(store, agent.host_id);
@@ -1048,7 +1129,7 @@ function jumpToAgent(store, agent) {
1048
1129
  message: `${target} has no tmux server running, so its agents are gone. Removed them; they will come back when it reports again.`
1049
1130
  };
1050
1131
  }
1051
- const remoteWindows = new Set((probe.stdout ?? "").split("\n").filter(Boolean));
1132
+ const remoteWindows = new Set(probe.stdout.split("\n").filter(Boolean));
1052
1133
  if (!remoteWindows.has(agent.window)) {
1053
1134
  forgetReplica(store, agent.agent_id, agent.host_id);
1054
1135
  return {
@@ -1061,31 +1142,31 @@ function jumpToAgent(store, agent) {
1061
1142
  if (process.env.TMUX) {
1062
1143
  const command = `ssh -t ${shellQuote(target)} tmux attach -t ${shellQuote(attachTarget)}`;
1063
1144
  const name = `@${peer?.name ?? target}`;
1064
- const existing = tmux.windowNamed(name);
1145
+ const existing = mux.windowNamed(name);
1065
1146
  if (existing) {
1066
- tmux.selectWindow(existing);
1067
- 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
+ };
1068
1152
  }
1069
- spawnSync("tmux", ["new-window", "-n", name, command], { stdio: "ignore" });
1070
- 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
+ };
1071
1158
  }
1072
- spawnSync("ssh", ["-t", target, "tmux", "attach", "-t", attachTarget], { stdio: "inherit" });
1073
- 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
+ };
1074
1165
  }
1075
1166
 
1076
1167
  // src/glance.ts
1077
1168
  import { execFileSync as execFileSync3 } from "child_process";
1078
1169
  var GLANCE_LINES = 40;
1079
- var SSH_OPTIONS2 = [
1080
- "-o",
1081
- "BatchMode=yes",
1082
- "-o",
1083
- "ControlMaster=no",
1084
- "-o",
1085
- "ControlPath=~/.ssh/control/%r@%h:%p",
1086
- "-o",
1087
- "ConnectTimeout=2"
1088
- ];
1089
1170
  function glance(store, agent, lines = GLANCE_LINES) {
1090
1171
  if (agent.host_id === loadIdentity()?.host_id) return tmux.capture(agent.pane, lines);
1091
1172
  const peer = store.peers().find((candidate) => candidate.host_id === agent.host_id);
@@ -1095,7 +1176,7 @@ function glance(store, agent, lines = GLANCE_LINES) {
1095
1176
  return execFileSync3(
1096
1177
  "ssh",
1097
1178
  [
1098
- ...SSH_OPTIONS2,
1179
+ ...SSH_OPTIONS,
1099
1180
  target,
1100
1181
  "tmux",
1101
1182
  "capture-pane",
@@ -1185,9 +1266,9 @@ function status(store, now = Date.now()) {
1185
1266
  }))
1186
1267
  };
1187
1268
  }
1188
- async function statusWithCollect(store, now = Date.now()) {
1269
+ async function statusWithCollect(store, now = Date.now(), channel = ssh) {
1189
1270
  try {
1190
- await collect(store, ssh, now);
1271
+ await collect(store, channel, now);
1191
1272
  } catch (error) {
1192
1273
  process.stderr.write(
1193
1274
  `murmur: status: collect: ${error instanceof Error ? error.message : String(error)}