@martintrojer/murmur 0.2.1 → 0.2.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/dist/cli.js CHANGED
@@ -915,6 +915,8 @@ function parseSnapshot(input) {
915
915
  // src/collector.ts
916
916
  var MAX_CONCURRENT_PEERS = 8;
917
917
  var COLLECT_DEADLINE_MS = 4e3;
918
+ var COLLECT_FLOOR_MS = 3e4;
919
+ var COLLECT_JITTER_MS = 2e4;
918
920
  async function mapSettled(items, limit, task, deadline) {
919
921
  const results = new Array(items.length);
920
922
  let cursor = 0;
@@ -936,6 +938,14 @@ async function mapSettled(items, limit, task, deadline) {
936
938
  await (stop ? Promise.race([pool, stop]) : pool);
937
939
  return results;
938
940
  }
941
+ function duePeers(peers, now, floorMs, random) {
942
+ if (floorMs <= 0) return [...peers];
943
+ return peers.filter((peer) => {
944
+ if (peer.last_attempt_at === null) return true;
945
+ const jitter = (random() - 0.5) * COLLECT_JITTER_MS;
946
+ return now - peer.last_attempt_at >= floorMs + jitter;
947
+ });
948
+ }
939
949
  function isUnreachable(message) {
940
950
  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(
941
951
  message
@@ -959,11 +969,12 @@ function describeFailure(peer, message) {
959
969
  const detail = collapsed.length > 160 ? `${collapsed.slice(0, 157)}...` : collapsed;
960
970
  return `${peer}: ${detail}`;
961
971
  }
962
- async function collect(store, channel, now = Date.now(), deadline, mux = tmux) {
972
+ async function collect(store, channel, now = Date.now(), options = {}) {
973
+ const { deadline, mux = tmux, floorMs = 0, random = Math.random } = options;
963
974
  const results = [];
964
975
  let timer;
965
976
  try {
966
- const peers = store.peers();
977
+ const peers = duePeers(store.peers(), now, floorMs, random);
967
978
  const bounded = deadline ?? new Promise((resolve) => {
968
979
  timer = setTimeout(resolve, COLLECT_DEADLINE_MS);
969
980
  timer.unref?.();
@@ -1054,23 +1065,785 @@ function requireIdentity() {
1054
1065
  process.exitCode = 1;
1055
1066
  return null;
1056
1067
  }
1057
-
1058
- // src/cli/collect.ts
1059
- function registerCollect(program2) {
1060
- program2.command("collect").description("Fetch each peer's snapshot").option("-q, --quiet", "report nothing, not even unreachable peers").action(async (options) => {
1061
- if (!requireIdentity()) return;
1068
+
1069
+ // src/cli/collect.ts
1070
+ function registerCollect(program2) {
1071
+ program2.command("collect").description("Fetch each peer's snapshot").option("-q, --quiet", "report nothing, not even unreachable peers").action(async (options) => {
1072
+ if (!requireIdentity()) return;
1073
+ const store = openStore();
1074
+ try {
1075
+ const results = await collect(store, ssh);
1076
+ if (options.quiet) return;
1077
+ for (const result of results) {
1078
+ if (result.ok || !result.error) continue;
1079
+ process.stderr.write(`murmur: ${describeFailure(result.peer, result.error)}
1080
+ `);
1081
+ }
1082
+ if (results.some((result) => !result.ok && !result.unreachable)) {
1083
+ process.exitCode = 1;
1084
+ }
1085
+ } finally {
1086
+ store.close();
1087
+ }
1088
+ });
1089
+ }
1090
+
1091
+ // src/cli/peer.ts
1092
+ import { readFileSync as readFileSync2 } from "fs";
1093
+ import { homedir as homedir2 } from "os";
1094
+ import { join as join3 } from "path";
1095
+ var SNAPSHOT_VERSION = 1;
1096
+ function parseSshHosts(config) {
1097
+ const hosts = [];
1098
+ for (const line of config.split("\n")) {
1099
+ const tokens = line.replace(/#.*$/, "").trim().split(/\s+/);
1100
+ if (tokens[0]?.toLowerCase() !== "host") continue;
1101
+ for (const host of tokens.slice(1)) {
1102
+ if (!/[*?!]/.test(host)) hosts.push(host);
1103
+ }
1104
+ }
1105
+ return hosts;
1106
+ }
1107
+ function sshHosts() {
1108
+ try {
1109
+ return parseSshHosts(readFileSync2(join3(homedir2(), ".ssh", "config"), "utf8"));
1110
+ } catch {
1111
+ return [];
1112
+ }
1113
+ }
1114
+ function lastSeen(fetchedAt, now) {
1115
+ if (fetchedAt === null) return "never";
1116
+ if (freshness(fetchedAt, now, STALENESS_MS) === "fresh") return "just now";
1117
+ return `${age(now - fetchedAt)} ago`;
1118
+ }
1119
+ function versionCell(peer, ours = SNAPSHOT_VERSION) {
1120
+ if (peer.murmur_version === null && peer.snapshot_version === null) {
1121
+ return { text: "unknown", incompatible: false };
1122
+ }
1123
+ const version = peer.murmur_version ?? "unreported";
1124
+ const incompatible = peer.snapshot_version !== null && peer.snapshot_version !== ours;
1125
+ return {
1126
+ text: incompatible ? `${version} (snapshot ${peer.snapshot_version} \u2260 ${ours})` : version,
1127
+ incompatible
1128
+ };
1129
+ }
1130
+ function formatTable(rows) {
1131
+ const widths = [];
1132
+ for (const row of rows) {
1133
+ row.forEach((cell, index) => {
1134
+ widths[index] = Math.max(widths[index] ?? 0, cell.length);
1135
+ });
1136
+ }
1137
+ return rows.map(
1138
+ (row) => row.map((cell, index) => index === row.length - 1 ? cell : cell.padEnd(widths[index] ?? 0)).join(" ").trimEnd()
1139
+ ).map((line) => `${line}
1140
+ `).join("");
1141
+ }
1142
+ function peerAddDecision(input) {
1143
+ const { name, target, snapshot, selfHostId, peers } = input;
1144
+ if (!snapshot) return null;
1145
+ if (snapshot.host_id === selfHostId) {
1146
+ return `${target} is this node; not adding it as a peer
1147
+ `;
1148
+ }
1149
+ const existing = peers.find(
1150
+ (candidate) => candidate.host_id === snapshot.host_id && candidate.name !== name
1151
+ );
1152
+ if (existing) {
1153
+ return `${target} is already configured as peer "${existing.name}" (${snapshot.display_name}); remove it first to rename
1154
+ `;
1155
+ }
1156
+ return null;
1157
+ }
1158
+ function registerPeer(program2) {
1159
+ const peer = program2.command("peer").description("Manage peers");
1160
+ peer.command("add").description("Add a peer and discover its identity").argument("<name>").argument("[target]").action(async (name, target = name) => {
1161
+ const store = openStore();
1162
+ try {
1163
+ let snapshot = null;
1164
+ try {
1165
+ snapshot = parseSnapshot(await ssh.exec(target, ["murmur", "export"]));
1166
+ } catch {
1167
+ snapshot = null;
1168
+ }
1169
+ const refusal = peerAddDecision({
1170
+ name,
1171
+ target,
1172
+ snapshot,
1173
+ selfHostId: loadIdentity()?.host_id ?? null,
1174
+ peers: store.peers()
1175
+ });
1176
+ if (refusal) {
1177
+ process.stderr.write(refusal);
1178
+ process.exitCode = 1;
1179
+ return;
1180
+ }
1181
+ store.addPeer(name, target);
1182
+ if (snapshot) {
1183
+ store.replacePeerSnapshot(name, { ok: true, snapshot, at: Date.now() });
1184
+ }
1185
+ process.stdout.write(
1186
+ snapshot ? `Added ${name} (${snapshot.display_name})
1187
+ ` : `Added ${name} (identity pending)
1188
+ `
1189
+ );
1190
+ } finally {
1191
+ store.close();
1192
+ }
1193
+ });
1194
+ peer.command("remove").description("Remove a peer").argument("<name>", "peer to remove").action((name) => {
1195
+ const store = openStore();
1196
+ try {
1197
+ if (store.removePeer(name)) process.stdout.write(`Removed ${name}
1198
+ `);
1199
+ else {
1200
+ process.stderr.write(`no such peer: ${name}
1201
+ `);
1202
+ process.exitCode = 1;
1203
+ }
1204
+ } finally {
1205
+ store.close();
1206
+ }
1207
+ });
1208
+ peer.command("list").description("List peers; --all adds SSH hosts that could become peers").option("--json", "print JSON").option("-a, --all", "also show SSH hosts that are not peers yet").action((options) => {
1209
+ const store = openStore();
1210
+ try {
1211
+ const peers = store.peers();
1212
+ const configured = new Map(peers.map((entry) => [entry.target, entry]));
1213
+ const discovered = options.all ? sshHosts().filter((host) => !configured.has(host)) : [];
1214
+ const now = Date.now();
1215
+ const rows = [...configured.keys(), ...discovered].map((target) => {
1216
+ const entry = configured.get(target);
1217
+ return {
1218
+ // The handle other commands take: a peer's name, or for a host that
1219
+ // is not one yet, the ssh host `peer add` wants.
1220
+ name: entry?.name ?? target,
1221
+ target,
1222
+ peer: entry !== void 0,
1223
+ // What the node called itself. Shown, never typed: it can be a
1224
+ // container id.
1225
+ hostname: entry?.display_name ?? null,
1226
+ // Being a peer is not the same as being reachable, and the old
1227
+ // output said only the first. A node asleep for twelve hours read
1228
+ // exactly like one polled a second ago.
1229
+ last_seen: entry === void 0 ? null : lastSeen(entry.fetched_at, now),
1230
+ // A warm ControlMaster socket makes a collect ~10ms instead of
1231
+ // ~170ms, and is the only path that works on a host demanding a
1232
+ // hardware-token touch per connection. A speed hint, never a
1233
+ // requirement -- which is why the old bare `[x]` / `[ ]` was
1234
+ // unreadable: it never said what was being checked.
1235
+ //
1236
+ // Safe for every row: `ssh -O check` talks to a local socket and
1237
+ // never dials, so a host that is down or does not exist answers in
1238
+ // ~16ms. Measured.
1239
+ ssh: hasWarmSocket(target) ? "warm" : "cold",
1240
+ // What it is running, or undefined when nothing is known -- either
1241
+ // because the host is not a peer yet, or because it is a peer that
1242
+ // has never answered. Undefined is what drops the column, so the
1243
+ // test is "has anything told us", not "is this configured": a fleet
1244
+ // of asleep peers must not buy a column of "unknown".
1245
+ version: entry === void 0 || entry.murmur_version === null && entry.snapshot_version === null ? void 0 : versionCell(entry),
1246
+ // Named where it can be acted on: a peer that answered with a bad
1247
+ // document is reachable but broken, which is an operator task and
1248
+ // reads nothing like a sleeping laptop.
1249
+ error: entry?.last_error ?? null
1250
+ };
1251
+ });
1252
+ if (options.json) {
1253
+ process.stdout.write(`${JSON.stringify(rows)}
1254
+ `);
1255
+ return;
1256
+ }
1257
+ if (rows.length === 0) {
1258
+ process.stdout.write(
1259
+ options.all ? "no peers configured, and no hosts in ~/.ssh/config\n" : "no peers configured. See what could be added with: murmur peer list --all\n"
1260
+ );
1261
+ return;
1262
+ }
1263
+ const showPeerColumn = rows.some((row) => !row.peer);
1264
+ const showVersionColumn = rows.some((row) => row.version !== void 0);
1265
+ process.stdout.write(
1266
+ formatTable([
1267
+ [
1268
+ "NAME",
1269
+ "TARGET",
1270
+ ...showPeerColumn ? ["PEER"] : [],
1271
+ "HOSTNAME",
1272
+ ...showVersionColumn ? ["VERSION"] : [],
1273
+ "LAST SEEN",
1274
+ "SSH"
1275
+ ],
1276
+ ...rows.map((row) => [
1277
+ row.name,
1278
+ row.target,
1279
+ ...showPeerColumn ? [row.peer ? "yes" : "-"] : [],
1280
+ row.hostname ?? "unknown",
1281
+ ...showVersionColumn ? [row.version?.text ?? "-"] : [],
1282
+ row.last_seen ?? "-",
1283
+ row.ssh
1284
+ ])
1285
+ ])
1286
+ );
1287
+ const incompatible = rows.filter((row) => row.version?.incompatible);
1288
+ if (incompatible.length > 0) {
1289
+ process.stdout.write(
1290
+ `
1291
+ ${incompatible.length} peer${incompatible.length === 1 ? "" : "s"} speak an incompatible snapshot version; state will not sync until murmur versions match: ${incompatible.map((row) => row.name).join(", ")}
1292
+ `
1293
+ );
1294
+ }
1295
+ const broken = rows.filter((row) => row.error);
1296
+ for (const row of broken) {
1297
+ process.stdout.write(`
1298
+ ${row.name}: last attempt failed -- ${row.error}
1299
+ `);
1300
+ }
1301
+ const addable = rows.filter((row) => !row.peer).length;
1302
+ if (addable > 0) {
1303
+ process.stdout.write(
1304
+ `
1305
+ ${addable} host${addable === 1 ? "" : "s"} not yet a peer. Add one with: murmur peer add <name>
1306
+ `
1307
+ );
1308
+ }
1309
+ } finally {
1310
+ store.close();
1311
+ }
1312
+ });
1313
+ }
1314
+
1315
+ // src/doctor.ts
1316
+ var DOCTOR_DEADLINE_MS = 15e3;
1317
+ var OUTDATED = /\berror: unknown (?:option|command)\b/i;
1318
+ function failure(target, reason, error) {
1319
+ const message = error instanceof Error ? error.message : String(error);
1320
+ return { ok: false, target, reason, detail: describeFailure(target, message) };
1321
+ }
1322
+ function parseRoster(input) {
1323
+ let parsed;
1324
+ try {
1325
+ parsed = JSON.parse(input);
1326
+ } catch {
1327
+ throw new Error("peer list did not answer with JSON");
1328
+ }
1329
+ if (!Array.isArray(parsed)) throw new Error("peer list: expected an array of peers");
1330
+ return parsed.map((row, index) => {
1331
+ if (typeof row !== "object" || row === null || Array.isArray(row)) {
1332
+ throw new Error(`peer list[${index}]: expected an object`);
1333
+ }
1334
+ const record = row;
1335
+ const { name, target, hostname: hostname2 } = record;
1336
+ if (typeof name !== "string" || name === "") {
1337
+ throw new Error(`peer list[${index}]: expected a non-empty name`);
1338
+ }
1339
+ if (typeof target !== "string" || target === "") {
1340
+ throw new Error(`peer list[${index}]: expected a non-empty target`);
1341
+ }
1342
+ return { name, target, hostname: typeof hostname2 === "string" ? hostname2 : null };
1343
+ });
1344
+ }
1345
+ async function surveyPeer(channel, target) {
1346
+ let host_id;
1347
+ let display_name;
1348
+ let murmur_version;
1349
+ try {
1350
+ const snapshot = parseSnapshot(await channel.exec(target, ["murmur", "export"]));
1351
+ ({ host_id, display_name, murmur_version } = snapshot);
1352
+ } catch (error) {
1353
+ return failure(target, "identity-unavailable", error);
1354
+ }
1355
+ let output;
1356
+ try {
1357
+ output = await channel.exec(target, ["murmur", "peer", "list", "--json"]);
1358
+ } catch (error) {
1359
+ const message = error instanceof Error ? error.message : String(error);
1360
+ return failure(
1361
+ target,
1362
+ OUTDATED.test(message) ? "roster-unsupported" : "roster-unavailable",
1363
+ error
1364
+ );
1365
+ }
1366
+ try {
1367
+ return { ok: true, target, host_id, display_name, murmur_version, roster: parseRoster(output) };
1368
+ } catch (error) {
1369
+ return failure(target, "roster-invalid", error);
1370
+ }
1371
+ }
1372
+ function withoutTargetPrefix(target, detail) {
1373
+ return detail.startsWith(`${target}: `) ? detail.slice(target.length + 2) : detail;
1374
+ }
1375
+ function rowIsAbout(row, host) {
1376
+ if (host.display_name === null) return false;
1377
+ if (row.hostname !== null) return row.hostname === host.display_name;
1378
+ return row.name === host.display_name || row.target === host.display_name;
1379
+ }
1380
+ function diagnose(local, surveys) {
1381
+ const findings = [];
1382
+ const answered = surveys.filter((survey) => survey.ok);
1383
+ const byTarget = new Map(answered.map((survey) => [survey.target, survey]));
1384
+ const nameOf = (target) => local.peers.find((peer) => peer.target === target)?.name ?? target;
1385
+ const self = {
1386
+ host_id: local.host_id,
1387
+ display_name: local.display_name,
1388
+ localName: null,
1389
+ target: null
1390
+ };
1391
+ const hosts = [self];
1392
+ for (const peer of local.peers) {
1393
+ const survey = byTarget.get(peer.target);
1394
+ const host_id = survey?.host_id ?? peer.host_id;
1395
+ if (host_id === null) continue;
1396
+ hosts.push({
1397
+ host_id,
1398
+ display_name: survey?.display_name ?? peer.display_name,
1399
+ localName: peer.name,
1400
+ target: peer.target
1401
+ });
1402
+ }
1403
+ const seen = /* @__PURE__ */ new Map();
1404
+ for (const host of hosts) {
1405
+ if (host.localName === null) continue;
1406
+ const first = seen.get(host.host_id);
1407
+ if (first === void 0) {
1408
+ seen.set(host.host_id, host);
1409
+ continue;
1410
+ }
1411
+ const label = host.display_name ?? host.host_id;
1412
+ findings.push({
1413
+ kind: "duplicate-host-id",
1414
+ severity: "problem",
1415
+ subject: host.localName,
1416
+ message: `${first.localName} and ${host.localName} are the same machine (${label}), so every command reaches it twice`,
1417
+ detail: `same machine as ${first.localName} (${label}); every command reaches it twice`,
1418
+ remedy: `murmur peer remove ${host.localName}`
1419
+ });
1420
+ }
1421
+ for (const peer of local.peers) {
1422
+ const cell = versionCell(peer);
1423
+ if (!cell.incompatible) continue;
1424
+ findings.push({
1425
+ kind: "snapshot-skew",
1426
+ severity: "problem",
1427
+ subject: peer.name,
1428
+ message: `${peer.name} speaks an incompatible snapshot version (${cell.text}); state will not sync until murmur versions match`,
1429
+ detail: `incompatible snapshot version (${cell.text}); state will not sync`,
1430
+ remedy: `ssh ${peer.target} npm i -g @martintrojer/murmur`
1431
+ });
1432
+ }
1433
+ const peersThisNode = answered.filter(
1434
+ (survey) => survey.roster.some((row) => rowIsAbout(row, self))
1435
+ );
1436
+ for (const survey of answered) {
1437
+ if (peersThisNode.includes(survey)) continue;
1438
+ const name = nameOf(survey.target);
1439
+ findings.push({
1440
+ kind: "asymmetry",
1441
+ severity: "observation",
1442
+ subject: name,
1443
+ message: `${name} does not peer this node (${local.display_name}), so its picker cannot see this node's agents`,
1444
+ // The consequence is identical on every asymmetric row, so it belongs in
1445
+ // the column header once rather than in each cell N times.
1446
+ detail: `does not peer ${local.display_name}`,
1447
+ // display_name is what the operator would type, but it is not guaranteed
1448
+ // to resolve from THAT host's ssh config, which murmur cannot see. So it
1449
+ // is a command to check, and `peer add` tolerates a target that does not
1450
+ // answer.
1451
+ remedy: `ssh ${survey.target} murmur peer add ${local.display_name}`
1452
+ });
1453
+ }
1454
+ if (answered.length > 0 && peersThisNode.length === 0) {
1455
+ findings.push({
1456
+ kind: "island",
1457
+ severity: "observation",
1458
+ subject: local.display_name,
1459
+ message: `no peer that this node surveyed peers this host (${local.display_name}); ${answered.length} of ${answered.length} surveyed peers cannot see this node's agents`,
1460
+ // "surveyed" is load-bearing and survives the shortening: this is a
1461
+ // one-hop claim, and dropping the word would turn it into a statement
1462
+ // about machines never contacted.
1463
+ detail: `none of the ${answered.length} surveyed peers can see this node`,
1464
+ remedy: null
1465
+ });
1466
+ }
1467
+ for (const host of hosts) {
1468
+ const names = /* @__PURE__ */ new Map();
1469
+ const record = (name, where) => {
1470
+ const nodes = names.get(name);
1471
+ if (nodes) nodes.push(where);
1472
+ else names.set(name, [where]);
1473
+ };
1474
+ if (host.localName !== null) record(host.localName, "here");
1475
+ for (const survey of answered) {
1476
+ if (survey.host_id === host.host_id) continue;
1477
+ for (const row of survey.roster) {
1478
+ if (rowIsAbout(row, host)) record(row.name, nameOf(survey.target));
1479
+ }
1480
+ }
1481
+ if (names.size < 2) continue;
1482
+ const label = host.display_name ?? host.host_id;
1483
+ const spelled = [...names].map(([name, nodes]) => `"${name}" on ${nodes.join(", ")}`).join("; ");
1484
+ findings.push({
1485
+ kind: "naming-drift",
1486
+ severity: "observation",
1487
+ subject: host.localName ?? local.display_name,
1488
+ message: `one machine (${label}) is configured under different names: ${spelled}`,
1489
+ detail: `one machine (${label}) under different names: ${spelled}`,
1490
+ remedy: null
1491
+ });
1492
+ }
1493
+ for (const survey of surveys) {
1494
+ if (survey.ok) continue;
1495
+ const name = nameOf(survey.target);
1496
+ const detail = withoutTargetPrefix(survey.target, survey.detail);
1497
+ findings.push({
1498
+ kind: "unsurveyable",
1499
+ severity: "observation",
1500
+ subject: name,
1501
+ message: survey.reason === "roster-unsupported" ? `${name} runs a murmur too old to report its roster, so it could not be checked -- upgrade that host` : `${name} could not be surveyed -- ${detail}`,
1502
+ detail: survey.reason === "roster-unsupported" ? "murmur too old to report its roster" : detail,
1503
+ remedy: survey.reason === "roster-unsupported" ? `ssh ${survey.target} npm i -g @martintrojer/murmur` : null
1504
+ });
1505
+ }
1506
+ return findings;
1507
+ }
1508
+ var TOPOLOGY_DEADLINE_MS = 3e4;
1509
+ var UNRESOLVED = /could not resolve hostname|name or service not known|nodename nor servname/i;
1510
+ function isNameResolutionFailure(detail) {
1511
+ return detail !== null && UNRESOLVED.test(detail);
1512
+ }
1513
+ async function probeReach(channel, from, to) {
1514
+ const inner = ["ssh", "-o", "BatchMode=yes", `-o`, "ConnectTimeout=1", to.target, "true"];
1515
+ try {
1516
+ await channel.exec(from.self ? to.target : from.target, from.self ? ["true"] : inner);
1517
+ return { ok: true, detail: null };
1518
+ } catch (error) {
1519
+ const message = error instanceof Error ? error.message : String(error);
1520
+ return { ok: false, detail: describeFailure(to.name, message) };
1521
+ }
1522
+ }
1523
+ function reachableFromHere(surveys) {
1524
+ return new Set(surveys.filter((survey) => survey.ok).map((survey) => survey.target));
1525
+ }
1526
+ async function buildTopology(channel, nodes, upFromHere, deadline) {
1527
+ const isUp = (node) => node.self || upFromHere.has(node.target);
1528
+ const pairs = [];
1529
+ for (const from of nodes) {
1530
+ for (const to of nodes) {
1531
+ if (from.name !== to.name) pairs.push({ from, to });
1532
+ }
1533
+ }
1534
+ const dialled = pairs.filter((pair) => isUp(pair.from));
1535
+ let timer;
1536
+ try {
1537
+ const bounded = deadline ?? new Promise((resolve) => {
1538
+ timer = setTimeout(resolve, TOPOLOGY_DEADLINE_MS);
1539
+ timer.unref?.();
1540
+ });
1541
+ const settled = await mapSettled(
1542
+ dialled,
1543
+ MAX_CONCURRENT_PEERS,
1544
+ async (pair) => probeReach(channel, pair.from, pair.to),
1545
+ bounded
1546
+ );
1547
+ const outcomes = /* @__PURE__ */ new Map();
1548
+ let probes = 0;
1549
+ for (const [index, pair] of dialled.entries()) {
1550
+ const result = settled[index];
1551
+ if (result !== void 0) probes += 1;
1552
+ outcomes.set(
1553
+ `${pair.from.name}\0${pair.to.name}`,
1554
+ result?.status === "fulfilled" ? result.value : void 0
1555
+ );
1556
+ }
1557
+ const edges = pairs.map(({ from, to }) => {
1558
+ const outcome = outcomes.get(`${from.name}\0${to.name}`);
1559
+ if (outcome === void 0) {
1560
+ return {
1561
+ from: from.name,
1562
+ to: to.name,
1563
+ reach: "unknown",
1564
+ detail: isUp(from) ? "not probed within the deadline" : `${from.name} did not answer`
1565
+ };
1566
+ }
1567
+ if (outcome.ok) return { from: from.name, to: to.name, reach: "reaches", detail: null };
1568
+ return {
1569
+ from: from.name,
1570
+ to: to.name,
1571
+ reach: isUp(to) ? "unreachable" : "unknown",
1572
+ detail: outcome.detail
1573
+ };
1574
+ });
1575
+ return { nodes, edges, probes };
1576
+ } finally {
1577
+ if (timer) clearTimeout(timer);
1578
+ }
1579
+ }
1580
+ function hubCandidates(topology) {
1581
+ const { nodes, edges } = topology;
1582
+ const lookup = new Map(edges.map((edge) => [`${edge.from}\0${edge.to}`, edge.reach]));
1583
+ const reachOf = (from, to) => from === to ? "reaches" : lookup.get(`${from}\0${to}`) ?? "unknown";
1584
+ return nodes.map((hub) => {
1585
+ const others = nodes.filter((node) => node.name !== hub.name);
1586
+ const reaches = others.filter((node) => reachOf(hub.name, node.name) === "reaches");
1587
+ return {
1588
+ node: hub.name,
1589
+ reaches: reaches.map((node) => node.name),
1590
+ cannotReach: others.filter((node) => reachOf(hub.name, node.name) === "unreachable").map((node) => node.name),
1591
+ unknown: others.filter((node) => reachOf(hub.name, node.name) === "unknown").map((node) => node.name),
1592
+ // Proven in both directions, and `unknown` never counts as proven: a star
1593
+ // built on a guess is the thing that cannot be allowed to look computed.
1594
+ star: [
1595
+ hub.name,
1596
+ ...reaches.filter((node) => reachOf(node.name, hub.name) === "reaches").map((node) => node.name)
1597
+ ]
1598
+ };
1599
+ });
1600
+ }
1601
+ function bestHub(candidates) {
1602
+ let best = null;
1603
+ for (const candidate of candidates) {
1604
+ if (candidate.star.length < 2) continue;
1605
+ if (best === null || candidate.star.length > best.star.length) best = candidate;
1606
+ }
1607
+ return best;
1608
+ }
1609
+
1610
+ // src/cli/doctor.ts
1611
+ async function surveyFleet(channel, peers, deadline) {
1612
+ let timer;
1613
+ try {
1614
+ const bounded = deadline ?? new Promise((resolve) => {
1615
+ timer = setTimeout(resolve, DOCTOR_DEADLINE_MS);
1616
+ timer.unref?.();
1617
+ });
1618
+ const settled = await mapSettled(
1619
+ peers,
1620
+ MAX_CONCURRENT_PEERS,
1621
+ async (peer) => surveyPeer(channel, peer.target),
1622
+ bounded
1623
+ );
1624
+ return peers.map((peer, index) => {
1625
+ const result = settled[index];
1626
+ if (result?.status === "fulfilled") return result.value;
1627
+ const detail = result === void 0 ? `not surveyed within ${DOCTOR_DEADLINE_MS / 1e3}s` : result.reason instanceof Error ? result.reason.message : String(result.reason);
1628
+ return { ok: false, target: peer.target, reason: "identity-unavailable", detail };
1629
+ });
1630
+ } finally {
1631
+ if (timer) clearTimeout(timer);
1632
+ }
1633
+ }
1634
+ var DIM = "\x1B[2m";
1635
+ var RESET = "\x1B[0m";
1636
+ function indent(block) {
1637
+ return block.split("\n").map((line) => line ? ` ${line}` : line).join("\n");
1638
+ }
1639
+ var GROUP = {
1640
+ "duplicate-host-id": {
1641
+ heading: "Duplicate peers",
1642
+ because: "One machine configured twice. Every command reaches it twice."
1643
+ },
1644
+ "snapshot-skew": {
1645
+ heading: "Incompatible versions",
1646
+ because: "State will not sync with these until murmur versions match."
1647
+ },
1648
+ asymmetry: {
1649
+ heading: "One-way peering",
1650
+ because: "These do not peer this node, so their pickers cannot see its agents."
1651
+ },
1652
+ island: {
1653
+ heading: "Not visible to the fleet",
1654
+ because: null
1655
+ },
1656
+ "naming-drift": {
1657
+ heading: "Naming drift",
1658
+ because: "Harmless to murmur, confusing to read: one machine, several names."
1659
+ },
1660
+ unsurveyable: {
1661
+ heading: "Could not be surveyed",
1662
+ because: "Normal for a sleeping laptop or a box switched off."
1663
+ }
1664
+ };
1665
+ var GROUP_ORDER = [
1666
+ "duplicate-host-id",
1667
+ "snapshot-skew",
1668
+ "island",
1669
+ "asymmetry",
1670
+ "naming-drift",
1671
+ "unsurveyable"
1672
+ ];
1673
+ function render(local, surveys, findings) {
1674
+ const answered = surveys.filter((survey) => survey.ok).length;
1675
+ const out = [];
1676
+ if (surveys.length === 0) return "No peers configured, so there is nothing to survey.\n";
1677
+ out.push(
1678
+ `Surveyed ${surveys.length} peer${surveys.length === 1 ? "" : "s"}, ${answered} answered.
1679
+ `
1680
+ );
1681
+ if (findings.length === 0) {
1682
+ out.push("\nNo problems found.\n");
1683
+ return out.join("");
1684
+ }
1685
+ const problems = findings.filter((finding) => finding.severity === "problem").length;
1686
+ const counts = [
1687
+ problems > 0 ? `${problems} problem${problems === 1 ? "" : "s"}` : "",
1688
+ findings.length - problems > 0 ? `${findings.length - problems} observation${findings.length - problems === 1 ? "" : "s"}` : ""
1689
+ ].filter(Boolean);
1690
+ out.push(
1691
+ problems > 0 ? `${counts.join(", ")}. See "Do this" below.
1692
+ ` : `${counts.join(", ")}, nothing broken.
1693
+ `
1694
+ );
1695
+ for (const kind of GROUP_ORDER) {
1696
+ const group = findings.filter((finding) => finding.kind === kind);
1697
+ if (group.length === 0) continue;
1698
+ const { heading, because } = GROUP[kind] ?? { heading: kind, because: null };
1699
+ const marked = group.some((finding) => finding.severity === "problem");
1700
+ const rows = group.map(
1701
+ (finding) => marked ? [finding.severity === "problem" ? "!" : " ", finding.subject, finding.detail] : [finding.subject, finding.detail]
1702
+ );
1703
+ out.push(`
1704
+ ${heading}
1705
+ `);
1706
+ if (because) out.push(` ${DIM}${because}${RESET}
1707
+ `);
1708
+ out.push(indent(formatTable(rows)));
1709
+ }
1710
+ const remedies = findings.filter(
1711
+ (finding) => finding.remedy !== null
1712
+ );
1713
+ if (remedies.length > 0) {
1714
+ out.push("\nDo this\n");
1715
+ const seen = /* @__PURE__ */ new Set();
1716
+ for (const finding of remedies) {
1717
+ if (seen.has(finding.remedy)) continue;
1718
+ seen.add(finding.remedy);
1719
+ out.push(` ${finding.remedy}
1720
+ `);
1721
+ }
1722
+ if (remedies.some((f) => f.remedy.includes(`murmur peer add ${local.display_name}`))) {
1723
+ out.push(
1724
+ `
1725
+ ${DIM}These name this node "${local.display_name}". Whether a peer resolves that
1726
+ depends on its own ssh config, which murmur cannot see -- so check, do not
1727
+ trust. \`peer add\` accepts a target that does not answer yet.${RESET}
1728
+ `
1729
+ );
1730
+ }
1731
+ }
1732
+ return out.join("");
1733
+ }
1734
+ function renderTopology(topology) {
1735
+ const candidates = hubCandidates(topology);
1736
+ const hub = bestHub(candidates);
1737
+ const out = [];
1738
+ const all = topology.nodes.length - 1;
1739
+ out.push(
1740
+ `
1741
+ Reachability ${DIM}${topology.probes} ordered pair${topology.probes === 1 ? "" : "s"} probed across ${topology.nodes.length} nodes${RESET}
1742
+ `
1743
+ );
1744
+ const anyUnknown = candidates.some((candidate) => candidate.unknown.length > 0);
1745
+ const rows = [["", "REACHES", "CANNOT REACH", ...anyUnknown ? ["UNKNOWN"] : []]];
1746
+ for (const candidate of candidates) {
1747
+ rows.push([
1748
+ candidate.node,
1749
+ candidate.reaches.length === all && all > 0 ? `all ${all}` : candidate.reaches.length > 0 ? candidate.reaches.join(" ") : "-",
1750
+ candidate.cannotReach.length > 0 ? candidate.cannotReach.join(" ") : "-",
1751
+ ...anyUnknown ? [candidate.unknown.length > 0 ? candidate.unknown.join(" ") : "-"] : []
1752
+ ]);
1753
+ }
1754
+ out.push(indent(formatTable(rows)));
1755
+ const unresolved = topology.edges.filter((edge) => isNameResolutionFailure(edge.detail));
1756
+ if (unresolved.length > 0) {
1757
+ const targets = [...new Set(unresolved.map((edge) => edge.to))];
1758
+ const from = [...new Set(unresolved.map((edge) => edge.from))];
1759
+ out.push(
1760
+ `
1761
+ ${DIM}${targets.join(", ")} is not resolvable by name from ${from.join(", ")} -- a naming
1762
+ problem, not a network one. It may be reachable under another address.${RESET}
1763
+ `
1764
+ );
1765
+ }
1766
+ if (hub === null) {
1767
+ out.push(
1768
+ `
1769
+ Hub ${DIM}none possible${RESET}
1770
+ A hub must reach every spoke and be reachable from each in turn.
1771
+ No node here does both, so none is recommended.
1772
+ `
1773
+ );
1774
+ return out.join("");
1775
+ }
1776
+ const spokes = hub.star.filter((name) => name !== hub.node);
1777
+ const excluded = topology.nodes.map((node) => node.name).filter((name) => !hub.star.includes(name));
1778
+ out.push(
1779
+ excluded.length === 0 ? `
1780
+ Hub ${hub.node} ${DIM}serves the whole fleet${RESET}
1781
+ ` : `
1782
+ Hub ${hub.node} ${DIM}serves {${hub.star.join(", ")}}, leaves out ${excluded.join(", ")}${RESET}
1783
+ `
1784
+ );
1785
+ out.push(`
1786
+ To build that star
1787
+ `);
1788
+ for (const spoke of spokes) out.push(` ssh ${spoke} murmur peer add ${hub.node}
1789
+ `);
1790
+ out.push(
1791
+ `
1792
+ ${DIM}Cost: spokes would see ${hub.node}'s agents and it would see theirs, but
1793
+ SPOKES WOULD NOT SEE EACH OTHER. \`murmur export\` publishes a node's own
1794
+ panes only, so a hub cannot re-serve what it learned. A star is not a mesh.${RESET}
1795
+ `
1796
+ );
1797
+ return out.join("");
1798
+ }
1799
+ function jsonReport(surveys, findings) {
1800
+ return {
1801
+ surveyed: surveys.length,
1802
+ answered: surveys.filter((survey) => survey.ok).length,
1803
+ findings
1804
+ };
1805
+ }
1806
+ function exitCodeFor(findings) {
1807
+ return findings.some((finding) => finding.severity === "problem") ? 1 : 0;
1808
+ }
1809
+ function registerDoctor(program2) {
1810
+ program2.command("doctor").description("Survey peers over ssh and report what only a fleet-wide view can see").option("--json", "print the finding list").option("--topology", "also probe who can reach whom, and compute hub options").action(async (options) => {
1811
+ const identity = requireIdentity();
1812
+ if (!identity) return;
1062
1813
  const store = openStore();
1063
1814
  try {
1064
- const results = await collect(store, ssh);
1065
- if (options.quiet) return;
1066
- for (const result of results) {
1067
- if (result.ok || !result.error) continue;
1068
- process.stderr.write(`murmur: ${describeFailure(result.peer, result.error)}
1069
- `);
1815
+ const peers = store.peers();
1816
+ const surveys = await surveyFleet(ssh, peers);
1817
+ const local = {
1818
+ host_id: identity.host_id,
1819
+ display_name: identity.display_name,
1820
+ peers
1821
+ };
1822
+ const findings = diagnose(local, surveys);
1823
+ let topology = null;
1824
+ if (options.topology) {
1825
+ const nodes = [
1826
+ { name: identity.display_name, target: identity.display_name, self: true },
1827
+ ...peers.map((peer) => ({ name: peer.name, target: peer.target, self: false }))
1828
+ ];
1829
+ topology = await buildTopology(ssh, nodes, reachableFromHere(surveys));
1070
1830
  }
1071
- if (results.some((result) => !result.ok && !result.unreachable)) {
1072
- process.exitCode = 1;
1831
+ if (options.json) {
1832
+ process.stdout.write(
1833
+ `${JSON.stringify({
1834
+ ...jsonReport(surveys, findings),
1835
+ // Omitted entirely rather than null when the phase did not run: a
1836
+ // consumer must not have to tell "no topology" apart from "a
1837
+ // topology with nothing in it".
1838
+ ...topology ? { topology, hubs: hubCandidates(topology) } : {}
1839
+ })}
1840
+ `
1841
+ );
1842
+ } else {
1843
+ process.stdout.write(render(local, surveys, findings));
1844
+ if (topology) process.stdout.write(renderTopology(topology));
1073
1845
  }
1846
+ process.exitCode = exitCodeFor(findings);
1074
1847
  } finally {
1075
1848
  store.close();
1076
1849
  }
@@ -1104,9 +1877,9 @@ function registerInit(program2) {
1104
1877
  }
1105
1878
 
1106
1879
  // src/cli/link.ts
1107
- import { mkdirSync as mkdirSync3, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
1108
- import { homedir as homedir2 } from "os";
1109
- import { dirname as dirname2, join as join3 } from "path";
1880
+ import { mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
1881
+ import { homedir as homedir3 } from "os";
1882
+ import { dirname as dirname2, join as join4 } from "path";
1110
1883
  import { fileURLToPath } from "url";
1111
1884
  var SHIM_MARKER = "// murmur:shim";
1112
1885
  function shim(entry, storePath) {
@@ -1137,8 +1910,8 @@ function registerLink(program2) {
1137
1910
  "inline the extension instead of re-exporting it (pins to this version; needs re-linking after an upgrade)"
1138
1911
  ).action((target, options) => {
1139
1912
  if (target !== "pi") throw new Error(`unsupported link target: ${target}`);
1140
- const destination = join3(
1141
- process.env.MURMUR_PI_HOME ?? homedir2(),
1913
+ const destination = join4(
1914
+ process.env.MURMUR_PI_HOME ?? homedir3(),
1142
1915
  ".pi",
1143
1916
  "agent",
1144
1917
  "extensions",
@@ -1151,7 +1924,7 @@ function registerLink(program2) {
1151
1924
  if (!options.copy) {
1152
1925
  let replacedCopy = false;
1153
1926
  try {
1154
- const existing = readFileSync2(destination, "utf8");
1927
+ const existing = readFileSync3(destination, "utf8");
1155
1928
  replacedCopy = !existing.includes(SHIM_MARKER);
1156
1929
  } catch {
1157
1930
  }
@@ -1169,7 +1942,7 @@ function registerLink(program2) {
1169
1942
  }
1170
1943
  return;
1171
1944
  }
1172
- const source = readFileSync2(entry, "utf8");
1945
+ const source = readFileSync3(entry, "utf8");
1173
1946
  const pinned = source.replace(
1174
1947
  /"@martintrojer\/murmur\/extension-store"/,
1175
1948
  JSON.stringify(storePath)
@@ -1283,237 +2056,17 @@ async function readStdin() {
1283
2056
  });
1284
2057
  }
1285
2058
 
1286
- // src/cli/peer.ts
1287
- import { readFileSync as readFileSync3 } from "fs";
1288
- import { homedir as homedir3 } from "os";
1289
- import { join as join4 } from "path";
1290
- var SNAPSHOT_VERSION = 1;
1291
- function parseSshHosts(config) {
1292
- const hosts = [];
1293
- for (const line of config.split("\n")) {
1294
- const tokens = line.replace(/#.*$/, "").trim().split(/\s+/);
1295
- if (tokens[0]?.toLowerCase() !== "host") continue;
1296
- for (const host of tokens.slice(1)) {
1297
- if (!/[*?!]/.test(host)) hosts.push(host);
1298
- }
1299
- }
1300
- return hosts;
1301
- }
1302
- function sshHosts() {
1303
- try {
1304
- return parseSshHosts(readFileSync3(join4(homedir3(), ".ssh", "config"), "utf8"));
1305
- } catch {
1306
- return [];
1307
- }
1308
- }
1309
- function lastSeen(fetchedAt, now) {
1310
- if (fetchedAt === null) return "never";
1311
- if (freshness(fetchedAt, now, STALENESS_MS) === "fresh") return "just now";
1312
- return `${age(now - fetchedAt)} ago`;
1313
- }
1314
- function versionCell(peer, ours = SNAPSHOT_VERSION) {
1315
- if (peer.murmur_version === null && peer.snapshot_version === null) {
1316
- return { text: "unknown", incompatible: false };
1317
- }
1318
- const version = peer.murmur_version ?? "unreported";
1319
- const incompatible = peer.snapshot_version !== null && peer.snapshot_version !== ours;
1320
- return {
1321
- text: incompatible ? `${version} (snapshot ${peer.snapshot_version} \u2260 ${ours})` : version,
1322
- incompatible
1323
- };
1324
- }
1325
- function formatTable(rows) {
1326
- const widths = [];
1327
- for (const row of rows) {
1328
- row.forEach((cell, index) => {
1329
- widths[index] = Math.max(widths[index] ?? 0, cell.length);
1330
- });
1331
- }
1332
- return rows.map(
1333
- (row) => row.map((cell, index) => index === row.length - 1 ? cell : cell.padEnd(widths[index] ?? 0)).join(" ").trimEnd()
1334
- ).map((line) => `${line}
1335
- `).join("");
1336
- }
1337
- function peerAddDecision(input) {
1338
- const { name, target, snapshot, selfHostId, peers } = input;
1339
- if (!snapshot) return null;
1340
- if (snapshot.host_id === selfHostId) {
1341
- return `${target} is this node; not adding it as a peer
1342
- `;
1343
- }
1344
- const existing = peers.find(
1345
- (candidate) => candidate.host_id === snapshot.host_id && candidate.name !== name
1346
- );
1347
- if (existing) {
1348
- return `${target} is already configured as peer "${existing.name}" (${snapshot.display_name}); remove it first to rename
1349
- `;
1350
- }
1351
- return null;
1352
- }
1353
- function registerPeer(program2) {
1354
- const peer = program2.command("peer").description("Manage peers");
1355
- peer.command("add").description("Add a peer and discover its identity").argument("<name>").argument("[target]").action(async (name, target = name) => {
1356
- const store = openStore();
1357
- try {
1358
- let snapshot = null;
1359
- try {
1360
- snapshot = parseSnapshot(await ssh.exec(target, ["murmur", "export"]));
1361
- } catch {
1362
- snapshot = null;
1363
- }
1364
- const refusal = peerAddDecision({
1365
- name,
1366
- target,
1367
- snapshot,
1368
- selfHostId: loadIdentity()?.host_id ?? null,
1369
- peers: store.peers()
1370
- });
1371
- if (refusal) {
1372
- process.stderr.write(refusal);
1373
- process.exitCode = 1;
1374
- return;
1375
- }
1376
- store.addPeer(name, target);
1377
- if (snapshot) {
1378
- store.replacePeerSnapshot(name, { ok: true, snapshot, at: Date.now() });
1379
- }
1380
- process.stdout.write(
1381
- snapshot ? `Added ${name} (${snapshot.display_name})
1382
- ` : `Added ${name} (identity pending)
1383
- `
1384
- );
1385
- } finally {
1386
- store.close();
1387
- }
1388
- });
1389
- peer.command("remove").description("Remove a peer").argument("<name>", "peer to remove").action((name) => {
1390
- const store = openStore();
1391
- try {
1392
- if (store.removePeer(name)) process.stdout.write(`Removed ${name}
1393
- `);
1394
- else {
1395
- process.stderr.write(`no such peer: ${name}
1396
- `);
1397
- process.exitCode = 1;
1398
- }
1399
- } finally {
1400
- store.close();
1401
- }
1402
- });
1403
- peer.command("list").description("List peers; --all adds SSH hosts that could become peers").option("--json", "print JSON").option("-a, --all", "also show SSH hosts that are not peers yet").action((options) => {
1404
- const store = openStore();
1405
- try {
1406
- const peers = store.peers();
1407
- const configured = new Map(peers.map((entry) => [entry.target, entry]));
1408
- const discovered = options.all ? sshHosts().filter((host) => !configured.has(host)) : [];
1409
- const now = Date.now();
1410
- const rows = [...configured.keys(), ...discovered].map((target) => {
1411
- const entry = configured.get(target);
1412
- return {
1413
- // The handle other commands take: a peer's name, or for a host that
1414
- // is not one yet, the ssh host `peer add` wants.
1415
- name: entry?.name ?? target,
1416
- target,
1417
- peer: entry !== void 0,
1418
- // What the node called itself. Shown, never typed: it can be a
1419
- // container id.
1420
- hostname: entry?.display_name ?? null,
1421
- // Being a peer is not the same as being reachable, and the old
1422
- // output said only the first. A node asleep for twelve hours read
1423
- // exactly like one polled a second ago.
1424
- last_seen: entry === void 0 ? null : lastSeen(entry.fetched_at, now),
1425
- // A warm ControlMaster socket makes a collect ~10ms instead of
1426
- // ~170ms, and is the only path that works on a host demanding a
1427
- // hardware-token touch per connection. A speed hint, never a
1428
- // requirement -- which is why the old bare `[x]` / `[ ]` was
1429
- // unreadable: it never said what was being checked.
1430
- //
1431
- // Safe for every row: `ssh -O check` talks to a local socket and
1432
- // never dials, so a host that is down or does not exist answers in
1433
- // ~16ms. Measured.
1434
- ssh: hasWarmSocket(target) ? "warm" : "cold",
1435
- // What it is running, or undefined when nothing is known -- either
1436
- // because the host is not a peer yet, or because it is a peer that
1437
- // has never answered. Undefined is what drops the column, so the
1438
- // test is "has anything told us", not "is this configured": a fleet
1439
- // of asleep peers must not buy a column of "unknown".
1440
- version: entry === void 0 || entry.murmur_version === null && entry.snapshot_version === null ? void 0 : versionCell(entry),
1441
- // Named where it can be acted on: a peer that answered with a bad
1442
- // document is reachable but broken, which is an operator task and
1443
- // reads nothing like a sleeping laptop.
1444
- error: entry?.last_error ?? null
1445
- };
1446
- });
1447
- if (options.json) {
1448
- process.stdout.write(`${JSON.stringify(rows)}
1449
- `);
1450
- return;
1451
- }
1452
- if (rows.length === 0) {
1453
- process.stdout.write(
1454
- options.all ? "no peers configured, and no hosts in ~/.ssh/config\n" : "no peers configured. See what could be added with: murmur peer list --all\n"
1455
- );
1456
- return;
1457
- }
1458
- const showPeerColumn = rows.some((row) => !row.peer);
1459
- const showVersionColumn = rows.some((row) => row.version !== void 0);
1460
- process.stdout.write(
1461
- formatTable([
1462
- [
1463
- "NAME",
1464
- "TARGET",
1465
- ...showPeerColumn ? ["PEER"] : [],
1466
- "HOSTNAME",
1467
- ...showVersionColumn ? ["VERSION"] : [],
1468
- "LAST SEEN",
1469
- "SSH"
1470
- ],
1471
- ...rows.map((row) => [
1472
- row.name,
1473
- row.target,
1474
- ...showPeerColumn ? [row.peer ? "yes" : "-"] : [],
1475
- row.hostname ?? "unknown",
1476
- ...showVersionColumn ? [row.version?.text ?? "-"] : [],
1477
- row.last_seen ?? "-",
1478
- row.ssh
1479
- ])
1480
- ])
1481
- );
1482
- const incompatible = rows.filter((row) => row.version?.incompatible);
1483
- if (incompatible.length > 0) {
1484
- process.stdout.write(
1485
- `
1486
- ${incompatible.length} peer${incompatible.length === 1 ? "" : "s"} speak an incompatible snapshot version; state will not sync until murmur versions match: ${incompatible.map((row) => row.name).join(", ")}
1487
- `
1488
- );
1489
- }
1490
- const broken = rows.filter((row) => row.error);
1491
- for (const row of broken) {
1492
- process.stdout.write(`
1493
- ${row.name}: last attempt failed -- ${row.error}
1494
- `);
1495
- }
1496
- const addable = rows.filter((row) => !row.peer).length;
1497
- if (addable > 0) {
1498
- process.stdout.write(
1499
- `
1500
- ${addable} host${addable === 1 ? "" : "s"} not yet a peer. Add one with: murmur peer add <name>
1501
- `
1502
- );
1503
- }
1504
- } finally {
1505
- store.close();
1506
- }
1507
- });
1508
- }
1509
-
1510
2059
  // src/cli/pick.ts
1511
2060
  import { spawnSync as spawnSync2 } from "child_process";
1512
2061
 
1513
2062
  // src/agents.ts
1514
2063
  import { spawnSync } from "child_process";
2064
+ function sessionLeaf(session) {
2065
+ const leaf = session.split("/").filter(Boolean).at(-1);
2066
+ return leaf ?? session;
2067
+ }
1515
2068
  function agentLabel(agent) {
1516
- const name = agent.agent_name ?? agent.pi_session ?? agent.window_name ?? agent.session_name;
2069
+ const name = agent.agent_name ?? agent.pi_session ?? agent.window_name ?? (agent.session_name === null ? null : sessionLeaf(agent.session_name));
1517
2070
  return terminalText(name ?? agent.window);
1518
2071
  }
1519
2072
  function agentLocation(agent) {
@@ -1712,9 +2265,9 @@ function status(store, identity, now = Date.now()) {
1712
2265
  }))
1713
2266
  };
1714
2267
  }
1715
- async function statusWithCollect(store, identity, now = Date.now(), channel = ssh, mux = tmux) {
2268
+ async function statusWithCollect(store, identity, now = Date.now(), channel = ssh, options = {}) {
1716
2269
  try {
1717
- await collect(store, channel, now, void 0, mux);
2270
+ await collect(store, channel, now, options);
1718
2271
  } catch {
1719
2272
  }
1720
2273
  return status(store, identity, now);
@@ -1752,8 +2305,8 @@ var ANSI_AT_START = new RegExp(`^${ANSI_PATTERN}`);
1752
2305
  var ANSI_AT_END = new RegExp(`(?:${ANSI_PATTERN})+$`);
1753
2306
  var REMOTE = "\x1B[36m";
1754
2307
  var BOLD = "\x1B[1m";
1755
- var DIM = "\x1B[2m";
1756
- var RESET = "\x1B[0m";
2308
+ var DIM2 = "\x1B[2m";
2309
+ var RESET2 = "\x1B[0m";
1757
2310
  var CREW_MARK = "crew ";
1758
2311
  function isVisible(agent) {
1759
2312
  return agent.driver === "human" || NEEDS_HUMAN.some((kind) => agent.attention.includes(kind));
@@ -1824,11 +2377,11 @@ function pickerRow(agent, showHost, current, local = agent.local) {
1824
2377
  const state = renderState(agent);
1825
2378
  const colour = COLOUR[state] ?? "";
1826
2379
  const glyph = GLYPH[state] ?? "?";
1827
- const marker = current ? `${BOLD}\u25C6${RESET}` : " ";
1828
- const name = agent.agent_name ?? agent.pi_session ?? agentLabel(agent);
1829
- const host = showHost ? local ? `${DIM} here${RESET}` : `${REMOTE}\u2192 ${terminalText(agent.host)}${RESET}` : "";
2380
+ const marker = current ? `${BOLD}\u25C6${RESET2}` : " ";
2381
+ const name = agentLabel(agent);
2382
+ const host = showHost ? local ? `${DIM2} here${RESET2}` : `${REMOTE}\u2192 ${terminalText(agent.host)}${RESET2}` : "";
1830
2383
  const group = agent.workstream ?? agent.session_name;
1831
- const workstream = group && group !== name ? `${DIM}${terminalText(group)}${RESET}` : "";
2384
+ const workstream = group && group !== name ? `${DIM2}${terminalText(group)}${RESET2}` : "";
1832
2385
  const extra = agent.attention.filter((kind) => kind !== state);
1833
2386
  const flags = [
1834
2387
  agent.driver === "orchestrated" ? "crew" : "",
@@ -1841,12 +2394,14 @@ function pickerRow(agent, showHost, current, local = agent.local) {
1841
2394
  age(agent.updated_at === null ? null : Date.now() - agent.updated_at)
1842
2395
  ].filter(Boolean).join(" ");
1843
2396
  const label = [
1844
- `${marker} ${colour}${glyph}${RESET}`,
1845
- `${colour}${pad(state, COLUMNS.state)}${RESET}`,
1846
- pad(`${BOLD}${terminalText(name)}${RESET}`, COLUMNS.name),
2397
+ `${marker} ${colour}${glyph}${RESET2}`,
2398
+ `${colour}${pad(state, COLUMNS.state)}${RESET2}`,
2399
+ // No `terminalText` here: `agentLabel` already sanitised it, and wrapping it
2400
+ // again implied this value was raw.
2401
+ pad(`${BOLD}${name}${RESET2}`, COLUMNS.name),
1847
2402
  pad(workstream, showHost ? COLUMNS.stream : COLUMNS.streamWide),
1848
2403
  showHost ? pad(host, COLUMNS.host) : "",
1849
- flags ? `${DIM}${flags}${RESET}` : ""
2404
+ flags ? `${DIM2}${flags}${RESET2}` : ""
1850
2405
  ].filter(Boolean).join(" ");
1851
2406
  return `${agent.host_id} ${agent.pane} ${label}`;
1852
2407
  }
@@ -1854,11 +2409,16 @@ function previewText(store, agent) {
1854
2409
  const state = renderState(agent);
1855
2410
  const colour = COLOUR[state] ?? "";
1856
2411
  const head = [
1857
- `${colour}${GLYPH[state] ?? "?"} ${state}${RESET} ${BOLD}${agent.agent_name ? terminalText(agent.agent_name) : agentLabel(agent)}${RESET}`,
2412
+ // Same one chain the row uses. This was
2413
+ // `agent.agent_name ? terminalText(agent.agent_name) : agentLabel(agent)`,
2414
+ // whose true branch is exactly what `agentLabel` does first anyway, down to
2415
+ // the `terminalText` -- a no-op fork that existed only to fall out of step
2416
+ // with the row above it.
2417
+ `${colour}${GLYPH[state] ?? "?"} ${state}${RESET2} ${BOLD}${agentLabel(agent)}${RESET2}`,
1858
2418
  // Says where, and whether "where" is this machine. The glance below is a
1859
2419
  // local capture-pane or an ssh depending on this one fact, so it belongs in
1860
2420
  // the header rather than being inferred from a hostname.
1861
- agent.local ? `${DIM}here ${agentLocation(agent)}${RESET}` : `${REMOTE}\u2192 ${terminalText(agent.host)}${RESET} ${DIM}${agentLocation(agent)}${RESET}`
2421
+ agent.local ? `${DIM2}here ${agentLocation(agent)}${RESET2}` : `${REMOTE}\u2192 ${terminalText(agent.host)}${RESET2} ${DIM2}${agentLocation(agent)}${RESET2}`
1862
2422
  ];
1863
2423
  const facts = [
1864
2424
  `activity ${agent.activity ?? "none (attention only)"}`,
@@ -1872,15 +2432,15 @@ function previewText(store, agent) {
1872
2432
  // three-hour-old fact, and collapsing them is how that read as fresh.
1873
2433
  agent.updated_at === null ? "" : `said ${timestamp2(agent.updated_at)}`,
1874
2434
  agent.local ? "" : `fetched ${agent.fetched_at === null ? "never" : timestamp2(agent.fetched_at)}`,
1875
- agent.freshness === "stale" ? `${DIM}host is stale: fields below are last-known${RESET}` : ""
2435
+ agent.freshness === "stale" ? `${DIM2}host is stale: fields below are last-known${RESET2}` : ""
1876
2436
  ].filter(Boolean);
1877
2437
  const pane = glance(store, agent);
1878
2438
  const live = pane?.trimEnd() ? [
1879
- `${DIM}\u2500\u2500 pane \u2500\u2500${RESET}`,
2439
+ `${DIM2}\u2500\u2500 pane \u2500\u2500${RESET2}`,
1880
2440
  pane.trimEnd().slice(-PREVIEW_MESSAGE_MAX * 20)
1881
2441
  ] : [
1882
- `${DIM}\u2500\u2500 pane \u2500\u2500${RESET}`,
1883
- `${DIM}unavailable (host unreachable, or pane gone)${RESET}`
2442
+ `${DIM2}\u2500\u2500 pane \u2500\u2500${RESET2}`,
2443
+ `${DIM2}unavailable (host unreachable, or pane gone)${RESET2}`
1884
2444
  ];
1885
2445
  return [...head, "", ...facts, "", ...live].join("\n");
1886
2446
  }
@@ -1892,7 +2452,7 @@ function runPreview(store, paneId, hostId) {
1892
2452
  );
1893
2453
  process.stdout.write(
1894
2454
  agent ? `${previewText(store, agent)}
1895
- ` : `${DIM}${paneId} is no longer here.${RESET}
2455
+ ` : `${DIM2}${paneId} is no longer here.${RESET2}
1896
2456
  `
1897
2457
  );
1898
2458
  }
@@ -1901,7 +2461,9 @@ async function runPick(store, options = {}, deps = {}) {
1901
2461
  const jumpTo = deps.jump ?? jumpToAgent;
1902
2462
  const identity = requireIdentity();
1903
2463
  if (!identity) return;
1904
- const view = await statusWithCollect(store, identity, Date.now(), ssh, deps.mux ?? tmux);
2464
+ const view = await statusWithCollect(store, identity, Date.now(), ssh, {
2465
+ mux: deps.mux ?? tmux
2466
+ });
1905
2467
  const agents = view.panes.filter((agent2) => options.all || isVisible(agent2));
1906
2468
  const hidden = view.panes.length - agents.length;
1907
2469
  if (agents.length === 0) {
@@ -1919,7 +2481,7 @@ async function runPick(store, options = {}, deps = {}) {
1919
2481
  const state = renderState(agent2);
1920
2482
  counts.set(state, (counts.get(state) ?? 0) + 1);
1921
2483
  }
1922
- const prompt = RENDER_PRIORITY.filter((state) => counts.get(state)).map((state) => `${COLOUR[state]}${GLYPH[state]}${counts.get(state)}${RESET}`).join(" ");
2484
+ const prompt = RENDER_PRIORITY.filter((state) => counts.get(state)).map((state) => `${COLOUR[state]}${GLYPH[state]}${counts.get(state)}${RESET2}`).join(" ");
1923
2485
  const basePrompt = `${prompt}${prompt ? " " : ""}`;
1924
2486
  const self = process.argv[1] ?? "murmur";
1925
2487
  const allFlag = options.all ? " --all" : "";
@@ -2079,7 +2641,9 @@ function registerStatus(program2) {
2079
2641
  if (!identity) return;
2080
2642
  const store = openStore();
2081
2643
  try {
2082
- const view = await statusWithCollect(store, identity);
2644
+ const view = await statusWithCollect(store, identity, Date.now(), ssh, {
2645
+ floorMs: COLLECT_FLOOR_MS
2646
+ });
2083
2647
  process.stdout.write(
2084
2648
  options.json ? `${JSON.stringify(view, null, 2)}
2085
2649
  ` : tmuxStatus(view)
@@ -2100,6 +2664,7 @@ registerCollect(program);
2100
2664
  registerClear(program);
2101
2665
  registerNotify(program);
2102
2666
  registerPeer(program);
2667
+ registerDoctor(program);
2103
2668
  registerStatus(program);
2104
2669
  registerPick(program);
2105
2670
  program.parse();