@martintrojer/murmur 0.2.0 → 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
@@ -27,6 +27,10 @@ function runTmux(args) {
27
27
  return null;
28
28
  }
29
29
  }
30
+ function chosenWindowName(name, autoRename) {
31
+ if (autoRename === "1") return null;
32
+ return name || null;
33
+ }
30
34
  function exactSession(session) {
31
35
  return `=${session}`;
32
36
  }
@@ -46,16 +50,16 @@ var tmux = {
46
50
  "-t",
47
51
  pane,
48
52
  "-p",
49
- "#{session_id} #{window_id} #{session_name} #{window_name}"
53
+ "#{session_id} #{window_id} #{session_name} #{window_name} #{?automatic-rename,1,0}"
50
54
  ]);
51
- const [session, window, sessionName, windowName] = fields?.split(" ") ?? [];
55
+ const [session, window, sessionName, windowName, autoRename] = fields?.split(" ") ?? [];
52
56
  if (!session || !window) return null;
53
57
  return {
54
58
  session: asSessionId(session),
55
59
  window: asWindowId(window),
56
60
  pane,
57
61
  session_name: sessionName || null,
58
- window_name: windowName || null
62
+ window_name: chosenWindowName(windowName, autoRename)
59
63
  };
60
64
  },
61
65
  // Which of this host's PANES still exist. The only liveness question tmux is
@@ -911,6 +915,8 @@ function parseSnapshot(input) {
911
915
  // src/collector.ts
912
916
  var MAX_CONCURRENT_PEERS = 8;
913
917
  var COLLECT_DEADLINE_MS = 4e3;
918
+ var COLLECT_FLOOR_MS = 3e4;
919
+ var COLLECT_JITTER_MS = 2e4;
914
920
  async function mapSettled(items, limit, task, deadline) {
915
921
  const results = new Array(items.length);
916
922
  let cursor = 0;
@@ -932,6 +938,14 @@ async function mapSettled(items, limit, task, deadline) {
932
938
  await (stop ? Promise.race([pool, stop]) : pool);
933
939
  return results;
934
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
+ }
935
949
  function isUnreachable(message) {
936
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(
937
951
  message
@@ -955,11 +969,12 @@ function describeFailure(peer, message) {
955
969
  const detail = collapsed.length > 160 ? `${collapsed.slice(0, 157)}...` : collapsed;
956
970
  return `${peer}: ${detail}`;
957
971
  }
958
- async function collect(store, channel, now = Date.now(), deadline) {
972
+ async function collect(store, channel, now = Date.now(), options = {}) {
973
+ const { deadline, mux = tmux, floorMs = 0, random = Math.random } = options;
959
974
  const results = [];
960
975
  let timer;
961
976
  try {
962
- const peers = store.peers();
977
+ const peers = duePeers(store.peers(), now, floorMs, random);
963
978
  const bounded = deadline ?? new Promise((resolve) => {
964
979
  timer = setTimeout(resolve, COLLECT_DEADLINE_MS);
965
980
  timer.unref?.();
@@ -1002,7 +1017,7 @@ async function collect(store, channel, now = Date.now(), deadline) {
1002
1017
  clearTimeout(timer);
1003
1018
  }
1004
1019
  try {
1005
- store.reconcileLocal({ panes: tmux.livePanes(), now });
1020
+ store.reconcileLocal({ panes: mux.livePanes(), now });
1006
1021
  } catch {
1007
1022
  }
1008
1023
  return results;
@@ -1073,216 +1088,10 @@ function registerCollect(program2) {
1073
1088
  });
1074
1089
  }
1075
1090
 
1076
- // src/cli/export.ts
1077
- function registerExport(program2) {
1078
- program2.command("export").description("Print this node's current-state snapshot").action(() => {
1079
- const identity = requireIdentity();
1080
- if (!identity) return;
1081
- const store = openStore();
1082
- try {
1083
- const snapshot = store.buildLocalSnapshot(identity, { panes: tmux.livePanes() });
1084
- process.stdout.write(`${JSON.stringify(snapshot)}
1085
- `);
1086
- } finally {
1087
- store.close();
1088
- }
1089
- });
1090
- }
1091
-
1092
- // src/cli/init.ts
1093
- function registerInit(program2) {
1094
- program2.command("init").description("Initialize this node's identity").option("--name <name>", "display name").action((opts) => {
1095
- const existing = loadIdentity();
1096
- const identity = existing ? opts.name ? setDisplayName(opts.name) : existing : createIdentity(opts.name);
1097
- console.log(`host_id: ${identity.host_id}`);
1098
- console.log(`display_name: ${identity.display_name}`);
1099
- });
1100
- }
1101
-
1102
- // src/cli/link.ts
1103
- import { mkdirSync as mkdirSync3, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
1104
- import { homedir as homedir2 } from "os";
1105
- import { dirname as dirname2, join as join3 } from "path";
1106
- import { fileURLToPath } from "url";
1107
- var SHIM_MARKER = "// murmur:shim";
1108
- function shim(entry, storePath) {
1109
- return `${SHIM_MARKER}
1110
- // Generated by \`murmur link pi\`. Do not edit.
1111
- //
1112
- // A re-export, not a copy: the extension code lives in the murmur install, so
1113
- // upgrading murmur upgrades the extension with no reinstall step. Re-run
1114
- // \`murmur link pi\` only if the install path itself moves.
1115
- //
1116
- // The store path is set here rather than resolved by the extension. A bare
1117
- // specifier cannot resolve from ~/.pi/agent/extensions, and the failure is
1118
- // silent: the import throws, the extension swallows it, and every state report
1119
- // is dropped while the tmux badge still paints.
1120
- //
1121
- // A dynamic import, not \`export ... from\`: ESM hoists static re-exports above
1122
- // this assignment, so the extension loaded before the variable was set and read
1123
- // undefined. Verified -- the static form printed \`undefined\` in the target.
1124
- process.env.MURMUR_STORE_MODULE ??= ${JSON.stringify(storePath)};
1125
-
1126
- const { default: extension } = await import(${JSON.stringify(entry)});
1127
- export default extension;
1128
- `;
1129
- }
1130
- function registerLink(program2) {
1131
- program2.command("link").description("Install a murmur integration").argument("<target>", "integration to install").option(
1132
- "--copy",
1133
- "inline the extension instead of re-exporting it (pins to this version; needs re-linking after an upgrade)"
1134
- ).action((target, options) => {
1135
- if (target !== "pi") throw new Error(`unsupported link target: ${target}`);
1136
- const destination = join3(
1137
- process.env.MURMUR_PI_HOME ?? homedir2(),
1138
- ".pi",
1139
- "agent",
1140
- "extensions",
1141
- "murmur.ts"
1142
- );
1143
- mkdirSync3(dirname2(destination), { recursive: true });
1144
- const entry = fileURLToPath(new URL("./extension/murmur-pi.js", import.meta.url));
1145
- const storePath = fileURLToPath(new URL("./extension/store.js", import.meta.url));
1146
- const identityMissing = loadIdentity() === null;
1147
- if (!options.copy) {
1148
- let replacedCopy = false;
1149
- try {
1150
- const existing = readFileSync2(destination, "utf8");
1151
- replacedCopy = !existing.includes(SHIM_MARKER);
1152
- } catch {
1153
- }
1154
- writeFileSync2(destination, shim(entry, storePath));
1155
- console.log(destination);
1156
- if (replacedCopy) {
1157
- console.log(
1158
- "Replaced an inlined copy from an older murmur. That copy was pinned to the version that wrote it, so it had stopped picking up fixes; running agents keep the old code until they restart."
1159
- );
1160
- }
1161
- if (identityMissing) {
1162
- console.log(
1163
- "This node has no identity yet, so the extension will record nothing. Run: murmur init"
1164
- );
1165
- }
1166
- return;
1167
- }
1168
- const source = readFileSync2(entry, "utf8");
1169
- const pinned = source.replace(
1170
- /"@martintrojer\/murmur\/extension-store"/,
1171
- JSON.stringify(storePath)
1172
- );
1173
- if (pinned === source) {
1174
- throw new Error("link pi: could not pin the store import; extension build changed");
1175
- }
1176
- writeFileSync2(destination, pinned);
1177
- console.log(destination);
1178
- if (identityMissing) {
1179
- console.log(
1180
- "This node has no identity yet, so the extension will record nothing. Run: murmur init"
1181
- );
1182
- }
1183
- });
1184
- }
1185
-
1186
- // src/cli/notify.ts
1187
- function notifyFields(input, payload = {}) {
1188
- const field = (key, flag) => {
1189
- if (flag) return clean(flag);
1190
- const value = payload[key];
1191
- return typeof value === "string" ? clean(value) : "";
1192
- };
1193
- const source = field("source", input.source) || "agent";
1194
- const title = field("title", input.title);
1195
- const eventType = field("type", input.eventType);
1196
- const message = field("message", input.message) || title || eventType || "attention";
1197
- return { source, message };
1198
- }
1199
- function clean(value) {
1200
- const flattened = [...value].map((character) => {
1201
- const code = character.charCodeAt(0);
1202
- const control = code < 32 || code === 127 || code >= 128 && code <= 159;
1203
- return control ? " " : character;
1204
- }).join("");
1205
- return flattened.replace(/\s+/g, " ").trim();
1206
- }
1207
- function parsePayload(raw) {
1208
- if (!raw.trim()) return {};
1209
- try {
1210
- const parsed = JSON.parse(raw);
1211
- return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed : {};
1212
- } catch {
1213
- return {};
1214
- }
1215
- }
1216
- function runNotify(store, input, payload = {}, mux = tmux) {
1217
- const location = resolveLocation(input.pane, mux);
1218
- if (!location) return false;
1219
- const { source, message } = notifyFields(input, payload);
1220
- store.requestAttention({
1221
- kind: "blocked",
1222
- location,
1223
- message,
1224
- // The harness name goes here, not in `driver`. `driver` answers "who is
1225
- // waiting on this agent" -- a human, or a supervisor consuming the result --
1226
- // and a codex agent driven by a human is `human` on exactly that question.
1227
- // `source` answers "who asked", which is the free-text field a new harness
1228
- // needs no schema change for.
1229
- source
1230
- });
1231
- mux.setWindowBadge(location.window, "blocked");
1232
- return true;
1233
- }
1234
- function resolveLocation(pane, mux) {
1235
- const here = mux.currentWindow();
1236
- if (!pane) return here;
1237
- const target = asPaneId(pane);
1238
- if (here && here.pane === target) return here;
1239
- if (here && mux.panesInWindow(here.window).includes(target)) {
1240
- return { ...here, pane: target };
1241
- }
1242
- return null;
1243
- }
1244
- function registerNotify(program2) {
1245
- program2.command("notify").description("Record an attention request for a harness that cannot report itself").option("--source <name>", "harness name, e.g. codex or opencode").option("--event-type <type>", "why attention is wanted").option("--title <title>", "harness display title").option("--message <message>", "the text to show").option("--pane <pane>", "pane to notify about (default: $TMUX_PANE)").action(
1246
- async (options) => {
1247
- const payload = parsePayload(await readStdin());
1248
- const store = openStore();
1249
- try {
1250
- runNotify(store, options, payload);
1251
- } finally {
1252
- store.close();
1253
- }
1254
- }
1255
- );
1256
- }
1257
- var STDIN_DEADLINE_MS = 250;
1258
- async function readStdin() {
1259
- if (process.stdin.isTTY) return "";
1260
- const chunks = [];
1261
- return new Promise((resolve) => {
1262
- const onData = (chunk) => chunks.push(chunk);
1263
- const done = () => {
1264
- process.stdin.off("data", onData);
1265
- process.stdin.unref?.();
1266
- resolve(Buffer.concat(chunks).toString("utf8"));
1267
- };
1268
- const timer = setTimeout(done, STDIN_DEADLINE_MS);
1269
- timer.unref?.();
1270
- process.stdin.on("data", onData);
1271
- process.stdin.once("end", () => {
1272
- clearTimeout(timer);
1273
- done();
1274
- });
1275
- process.stdin.once("error", () => {
1276
- clearTimeout(timer);
1277
- done();
1278
- });
1279
- });
1280
- }
1281
-
1282
1091
  // src/cli/peer.ts
1283
- import { readFileSync as readFileSync3 } from "fs";
1284
- import { homedir as homedir3 } from "os";
1285
- import { join as join4 } from "path";
1092
+ import { readFileSync as readFileSync2 } from "fs";
1093
+ import { homedir as homedir2 } from "os";
1094
+ import { join as join3 } from "path";
1286
1095
  var SNAPSHOT_VERSION = 1;
1287
1096
  function parseSshHosts(config) {
1288
1097
  const hosts = [];
@@ -1297,7 +1106,7 @@ function parseSshHosts(config) {
1297
1106
  }
1298
1107
  function sshHosts() {
1299
1108
  try {
1300
- return parseSshHosts(readFileSync3(join4(homedir3(), ".ssh", "config"), "utf8"));
1109
+ return parseSshHosts(readFileSync2(join3(homedir2(), ".ssh", "config"), "utf8"));
1301
1110
  } catch {
1302
1111
  return [];
1303
1112
  }
@@ -1503,41 +1312,789 @@ ${addable} host${addable === 1 ? "" : "s"} not yet a peer. Add one with: murmur
1503
1312
  });
1504
1313
  }
1505
1314
 
1506
- // src/cli/pick.ts
1507
- import { spawnSync as spawnSync2 } from "child_process";
1508
-
1509
- // src/agents.ts
1510
- import { spawnSync } from "child_process";
1511
- function agentLabel(agent) {
1512
- const name = agent.agent_name ?? agent.pi_session ?? agent.window_name ?? agent.session_name;
1513
- return terminalText(name ?? agent.window);
1514
- }
1515
- function agentLocation(agent) {
1516
- const session = agent.session_name ?? agent.session;
1517
- const window = agent.window_name ?? agent.window;
1518
- return terminalText(session === window ? session : `${session}:${window}`);
1519
- }
1520
- function terminalText(value) {
1521
- return [...value].map((character) => {
1522
- const code = character.charCodeAt(0);
1523
- return code < 32 || code === 127 || code >= 128 && code <= 159 ? "\uFFFD" : character;
1524
- }).join("");
1525
- }
1526
- function shellQuote(value) {
1527
- return `'${value.replaceAll("'", `'\\''`)}'`;
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
+ });
1528
1344
  }
1529
- function remoteSessionName(peerName) {
1530
- return `${peerName.replace(/^[@$%=]+/, "")}~`;
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
+ }
1531
1371
  }
1532
- var spawnRunner = (file, args, inherit = false) => {
1533
- const result = spawnSync(file, args, {
1534
- encoding: "utf8",
1535
- timeout: 1e4,
1536
- ...inherit ? { stdio: "inherit" } : {}
1537
- });
1538
- return {
1539
- status: result.status,
1540
- stdout: result.stdout ?? "",
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;
1813
+ const store = openStore();
1814
+ try {
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));
1830
+ }
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));
1845
+ }
1846
+ process.exitCode = exitCodeFor(findings);
1847
+ } finally {
1848
+ store.close();
1849
+ }
1850
+ });
1851
+ }
1852
+
1853
+ // src/cli/export.ts
1854
+ function registerExport(program2) {
1855
+ program2.command("export").description("Print this node's current-state snapshot").action(() => {
1856
+ const identity = requireIdentity();
1857
+ if (!identity) return;
1858
+ const store = openStore();
1859
+ try {
1860
+ const snapshot = store.buildLocalSnapshot(identity, { panes: tmux.livePanes() });
1861
+ process.stdout.write(`${JSON.stringify(snapshot)}
1862
+ `);
1863
+ } finally {
1864
+ store.close();
1865
+ }
1866
+ });
1867
+ }
1868
+
1869
+ // src/cli/init.ts
1870
+ function registerInit(program2) {
1871
+ program2.command("init").description("Initialize this node's identity").option("--name <name>", "display name").action((opts) => {
1872
+ const existing = loadIdentity();
1873
+ const identity = existing ? opts.name ? setDisplayName(opts.name) : existing : createIdentity(opts.name);
1874
+ console.log(`host_id: ${identity.host_id}`);
1875
+ console.log(`display_name: ${identity.display_name}`);
1876
+ });
1877
+ }
1878
+
1879
+ // src/cli/link.ts
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";
1883
+ import { fileURLToPath } from "url";
1884
+ var SHIM_MARKER = "// murmur:shim";
1885
+ function shim(entry, storePath) {
1886
+ return `${SHIM_MARKER}
1887
+ // Generated by \`murmur link pi\`. Do not edit.
1888
+ //
1889
+ // A re-export, not a copy: the extension code lives in the murmur install, so
1890
+ // upgrading murmur upgrades the extension with no reinstall step. Re-run
1891
+ // \`murmur link pi\` only if the install path itself moves.
1892
+ //
1893
+ // The store path is set here rather than resolved by the extension. A bare
1894
+ // specifier cannot resolve from ~/.pi/agent/extensions, and the failure is
1895
+ // silent: the import throws, the extension swallows it, and every state report
1896
+ // is dropped while the tmux badge still paints.
1897
+ //
1898
+ // A dynamic import, not \`export ... from\`: ESM hoists static re-exports above
1899
+ // this assignment, so the extension loaded before the variable was set and read
1900
+ // undefined. Verified -- the static form printed \`undefined\` in the target.
1901
+ process.env.MURMUR_STORE_MODULE ??= ${JSON.stringify(storePath)};
1902
+
1903
+ const { default: extension } = await import(${JSON.stringify(entry)});
1904
+ export default extension;
1905
+ `;
1906
+ }
1907
+ function registerLink(program2) {
1908
+ program2.command("link").description("Install a murmur integration").argument("<target>", "integration to install").option(
1909
+ "--copy",
1910
+ "inline the extension instead of re-exporting it (pins to this version; needs re-linking after an upgrade)"
1911
+ ).action((target, options) => {
1912
+ if (target !== "pi") throw new Error(`unsupported link target: ${target}`);
1913
+ const destination = join4(
1914
+ process.env.MURMUR_PI_HOME ?? homedir3(),
1915
+ ".pi",
1916
+ "agent",
1917
+ "extensions",
1918
+ "murmur.ts"
1919
+ );
1920
+ mkdirSync3(dirname2(destination), { recursive: true });
1921
+ const entry = fileURLToPath(new URL("./extension/murmur-pi.js", import.meta.url));
1922
+ const storePath = fileURLToPath(new URL("./extension/store.js", import.meta.url));
1923
+ const identityMissing = loadIdentity() === null;
1924
+ if (!options.copy) {
1925
+ let replacedCopy = false;
1926
+ try {
1927
+ const existing = readFileSync3(destination, "utf8");
1928
+ replacedCopy = !existing.includes(SHIM_MARKER);
1929
+ } catch {
1930
+ }
1931
+ writeFileSync2(destination, shim(entry, storePath));
1932
+ console.log(destination);
1933
+ if (replacedCopy) {
1934
+ console.log(
1935
+ "Replaced an inlined copy from an older murmur. That copy was pinned to the version that wrote it, so it had stopped picking up fixes; running agents keep the old code until they restart."
1936
+ );
1937
+ }
1938
+ if (identityMissing) {
1939
+ console.log(
1940
+ "This node has no identity yet, so the extension will record nothing. Run: murmur init"
1941
+ );
1942
+ }
1943
+ return;
1944
+ }
1945
+ const source = readFileSync3(entry, "utf8");
1946
+ const pinned = source.replace(
1947
+ /"@martintrojer\/murmur\/extension-store"/,
1948
+ JSON.stringify(storePath)
1949
+ );
1950
+ if (pinned === source) {
1951
+ throw new Error("link pi: could not pin the store import; extension build changed");
1952
+ }
1953
+ writeFileSync2(destination, pinned);
1954
+ console.log(destination);
1955
+ if (identityMissing) {
1956
+ console.log(
1957
+ "This node has no identity yet, so the extension will record nothing. Run: murmur init"
1958
+ );
1959
+ }
1960
+ });
1961
+ }
1962
+
1963
+ // src/cli/notify.ts
1964
+ function notifyFields(input, payload = {}) {
1965
+ const field = (key, flag) => {
1966
+ if (flag) return clean(flag);
1967
+ const value = payload[key];
1968
+ return typeof value === "string" ? clean(value) : "";
1969
+ };
1970
+ const source = field("source", input.source) || "agent";
1971
+ const title = field("title", input.title);
1972
+ const eventType = field("type", input.eventType);
1973
+ const message = field("message", input.message) || title || eventType || "attention";
1974
+ return { source, message };
1975
+ }
1976
+ function clean(value) {
1977
+ const flattened = [...value].map((character) => {
1978
+ const code = character.charCodeAt(0);
1979
+ const control = code < 32 || code === 127 || code >= 128 && code <= 159;
1980
+ return control ? " " : character;
1981
+ }).join("");
1982
+ return flattened.replace(/\s+/g, " ").trim();
1983
+ }
1984
+ function parsePayload(raw) {
1985
+ if (!raw.trim()) return {};
1986
+ try {
1987
+ const parsed = JSON.parse(raw);
1988
+ return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed : {};
1989
+ } catch {
1990
+ return {};
1991
+ }
1992
+ }
1993
+ function runNotify(store, input, payload = {}, mux = tmux) {
1994
+ const location = resolveLocation(input.pane, mux);
1995
+ if (!location) return false;
1996
+ const { source, message } = notifyFields(input, payload);
1997
+ store.requestAttention({
1998
+ kind: "blocked",
1999
+ location,
2000
+ message,
2001
+ // The harness name goes here, not in `driver`. `driver` answers "who is
2002
+ // waiting on this agent" -- a human, or a supervisor consuming the result --
2003
+ // and a codex agent driven by a human is `human` on exactly that question.
2004
+ // `source` answers "who asked", which is the free-text field a new harness
2005
+ // needs no schema change for.
2006
+ source
2007
+ });
2008
+ mux.setWindowBadge(location.window, "blocked");
2009
+ return true;
2010
+ }
2011
+ function resolveLocation(pane, mux) {
2012
+ const here = mux.currentWindow();
2013
+ if (!pane) return here;
2014
+ const target = asPaneId(pane);
2015
+ if (here && here.pane === target) return here;
2016
+ if (here && mux.panesInWindow(here.window).includes(target)) {
2017
+ return { ...here, pane: target };
2018
+ }
2019
+ return null;
2020
+ }
2021
+ function registerNotify(program2) {
2022
+ program2.command("notify").description("Record an attention request for a harness that cannot report itself").option("--source <name>", "harness name, e.g. codex or opencode").option("--event-type <type>", "why attention is wanted").option("--title <title>", "harness display title").option("--message <message>", "the text to show").option("--pane <pane>", "pane to notify about (default: $TMUX_PANE)").action(
2023
+ async (options) => {
2024
+ const payload = parsePayload(await readStdin());
2025
+ const store = openStore();
2026
+ try {
2027
+ runNotify(store, options, payload);
2028
+ } finally {
2029
+ store.close();
2030
+ }
2031
+ }
2032
+ );
2033
+ }
2034
+ var STDIN_DEADLINE_MS = 250;
2035
+ async function readStdin() {
2036
+ if (process.stdin.isTTY) return "";
2037
+ const chunks = [];
2038
+ return new Promise((resolve) => {
2039
+ const onData = (chunk) => chunks.push(chunk);
2040
+ const done = () => {
2041
+ process.stdin.off("data", onData);
2042
+ process.stdin.unref?.();
2043
+ resolve(Buffer.concat(chunks).toString("utf8"));
2044
+ };
2045
+ const timer = setTimeout(done, STDIN_DEADLINE_MS);
2046
+ timer.unref?.();
2047
+ process.stdin.on("data", onData);
2048
+ process.stdin.once("end", () => {
2049
+ clearTimeout(timer);
2050
+ done();
2051
+ });
2052
+ process.stdin.once("error", () => {
2053
+ clearTimeout(timer);
2054
+ done();
2055
+ });
2056
+ });
2057
+ }
2058
+
2059
+ // src/cli/pick.ts
2060
+ import { spawnSync as spawnSync2 } from "child_process";
2061
+
2062
+ // src/agents.ts
2063
+ import { spawnSync } from "child_process";
2064
+ function sessionLeaf(session) {
2065
+ const leaf = session.split("/").filter(Boolean).at(-1);
2066
+ return leaf ?? session;
2067
+ }
2068
+ function agentLabel(agent) {
2069
+ const name = agent.agent_name ?? agent.pi_session ?? agent.window_name ?? (agent.session_name === null ? null : sessionLeaf(agent.session_name));
2070
+ return terminalText(name ?? agent.window);
2071
+ }
2072
+ function agentLocation(agent) {
2073
+ const session = agent.session_name ?? agent.session;
2074
+ const window = agent.window_name ?? agent.window;
2075
+ return terminalText(session === window ? session : `${session}:${window}`);
2076
+ }
2077
+ function terminalText(value) {
2078
+ return [...value].map((character) => {
2079
+ const code = character.charCodeAt(0);
2080
+ return code < 32 || code === 127 || code >= 128 && code <= 159 ? "\uFFFD" : character;
2081
+ }).join("");
2082
+ }
2083
+ function shellQuote(value) {
2084
+ return `'${value.replaceAll("'", `'\\''`)}'`;
2085
+ }
2086
+ function remoteSessionName(peerName) {
2087
+ return `${peerName.replace(/^[@$%=]+/, "")}~`;
2088
+ }
2089
+ var spawnRunner = (file, args, inherit = false) => {
2090
+ const result = spawnSync(file, args, {
2091
+ encoding: "utf8",
2092
+ timeout: 1e4,
2093
+ ...inherit ? { stdio: "inherit" } : {}
2094
+ });
2095
+ return {
2096
+ status: result.status,
2097
+ stdout: result.stdout ?? "",
1541
2098
  // spawnSync reports a failure to even start the child in `error`, leaving
1542
2099
  // status null. Collapsing both here keeps the decision table below reading
1543
2100
  // as one question rather than two.
@@ -1708,9 +2265,9 @@ function status(store, identity, now = Date.now()) {
1708
2265
  }))
1709
2266
  };
1710
2267
  }
1711
- async function statusWithCollect(store, identity, now = Date.now(), channel = ssh) {
2268
+ async function statusWithCollect(store, identity, now = Date.now(), channel = ssh, options = {}) {
1712
2269
  try {
1713
- await collect(store, channel, now);
2270
+ await collect(store, channel, now, options);
1714
2271
  } catch {
1715
2272
  }
1716
2273
  return status(store, identity, now);
@@ -1748,8 +2305,8 @@ var ANSI_AT_START = new RegExp(`^${ANSI_PATTERN}`);
1748
2305
  var ANSI_AT_END = new RegExp(`(?:${ANSI_PATTERN})+$`);
1749
2306
  var REMOTE = "\x1B[36m";
1750
2307
  var BOLD = "\x1B[1m";
1751
- var DIM = "\x1B[2m";
1752
- var RESET = "\x1B[0m";
2308
+ var DIM2 = "\x1B[2m";
2309
+ var RESET2 = "\x1B[0m";
1753
2310
  var CREW_MARK = "crew ";
1754
2311
  function isVisible(agent) {
1755
2312
  return agent.driver === "human" || NEEDS_HUMAN.some((kind) => agent.attention.includes(kind));
@@ -1820,11 +2377,11 @@ function pickerRow(agent, showHost, current, local = agent.local) {
1820
2377
  const state = renderState(agent);
1821
2378
  const colour = COLOUR[state] ?? "";
1822
2379
  const glyph = GLYPH[state] ?? "?";
1823
- const marker = current ? `${BOLD}\u25C6${RESET}` : " ";
1824
- const name = agent.agent_name ?? agent.pi_session ?? agentLabel(agent);
1825
- 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}` : "";
1826
2383
  const group = agent.workstream ?? agent.session_name;
1827
- const workstream = group ? `${DIM}${terminalText(group)}${RESET}` : "";
2384
+ const workstream = group && group !== name ? `${DIM2}${terminalText(group)}${RESET2}` : "";
1828
2385
  const extra = agent.attention.filter((kind) => kind !== state);
1829
2386
  const flags = [
1830
2387
  agent.driver === "orchestrated" ? "crew" : "",
@@ -1837,12 +2394,14 @@ function pickerRow(agent, showHost, current, local = agent.local) {
1837
2394
  age(agent.updated_at === null ? null : Date.now() - agent.updated_at)
1838
2395
  ].filter(Boolean).join(" ");
1839
2396
  const label = [
1840
- `${marker} ${colour}${glyph}${RESET}`,
1841
- `${colour}${pad(state, COLUMNS.state)}${RESET}`,
1842
- 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),
1843
2402
  pad(workstream, showHost ? COLUMNS.stream : COLUMNS.streamWide),
1844
2403
  showHost ? pad(host, COLUMNS.host) : "",
1845
- flags ? `${DIM}${flags}${RESET}` : ""
2404
+ flags ? `${DIM2}${flags}${RESET2}` : ""
1846
2405
  ].filter(Boolean).join(" ");
1847
2406
  return `${agent.host_id} ${agent.pane} ${label}`;
1848
2407
  }
@@ -1850,11 +2409,16 @@ function previewText(store, agent) {
1850
2409
  const state = renderState(agent);
1851
2410
  const colour = COLOUR[state] ?? "";
1852
2411
  const head = [
1853
- `${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}`,
1854
2418
  // Says where, and whether "where" is this machine. The glance below is a
1855
2419
  // local capture-pane or an ssh depending on this one fact, so it belongs in
1856
2420
  // the header rather than being inferred from a hostname.
1857
- 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}`
1858
2422
  ];
1859
2423
  const facts = [
1860
2424
  `activity ${agent.activity ?? "none (attention only)"}`,
@@ -1868,15 +2432,15 @@ function previewText(store, agent) {
1868
2432
  // three-hour-old fact, and collapsing them is how that read as fresh.
1869
2433
  agent.updated_at === null ? "" : `said ${timestamp2(agent.updated_at)}`,
1870
2434
  agent.local ? "" : `fetched ${agent.fetched_at === null ? "never" : timestamp2(agent.fetched_at)}`,
1871
- 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}` : ""
1872
2436
  ].filter(Boolean);
1873
2437
  const pane = glance(store, agent);
1874
2438
  const live = pane?.trimEnd() ? [
1875
- `${DIM}\u2500\u2500 pane \u2500\u2500${RESET}`,
2439
+ `${DIM2}\u2500\u2500 pane \u2500\u2500${RESET2}`,
1876
2440
  pane.trimEnd().slice(-PREVIEW_MESSAGE_MAX * 20)
1877
2441
  ] : [
1878
- `${DIM}\u2500\u2500 pane \u2500\u2500${RESET}`,
1879
- `${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}`
1880
2444
  ];
1881
2445
  return [...head, "", ...facts, "", ...live].join("\n");
1882
2446
  }
@@ -1888,7 +2452,7 @@ function runPreview(store, paneId, hostId) {
1888
2452
  );
1889
2453
  process.stdout.write(
1890
2454
  agent ? `${previewText(store, agent)}
1891
- ` : `${DIM}${paneId} is no longer here.${RESET}
2455
+ ` : `${DIM2}${paneId} is no longer here.${RESET2}
1892
2456
  `
1893
2457
  );
1894
2458
  }
@@ -1897,7 +2461,9 @@ async function runPick(store, options = {}, deps = {}) {
1897
2461
  const jumpTo = deps.jump ?? jumpToAgent;
1898
2462
  const identity = requireIdentity();
1899
2463
  if (!identity) return;
1900
- const view = await statusWithCollect(store, identity);
2464
+ const view = await statusWithCollect(store, identity, Date.now(), ssh, {
2465
+ mux: deps.mux ?? tmux
2466
+ });
1901
2467
  const agents = view.panes.filter((agent2) => options.all || isVisible(agent2));
1902
2468
  const hidden = view.panes.length - agents.length;
1903
2469
  if (agents.length === 0) {
@@ -1915,7 +2481,7 @@ async function runPick(store, options = {}, deps = {}) {
1915
2481
  const state = renderState(agent2);
1916
2482
  counts.set(state, (counts.get(state) ?? 0) + 1);
1917
2483
  }
1918
- 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(" ");
1919
2485
  const basePrompt = `${prompt}${prompt ? " " : ""}`;
1920
2486
  const self = process.argv[1] ?? "murmur";
1921
2487
  const allFlag = options.all ? " --all" : "";
@@ -2075,7 +2641,9 @@ function registerStatus(program2) {
2075
2641
  if (!identity) return;
2076
2642
  const store = openStore();
2077
2643
  try {
2078
- const view = await statusWithCollect(store, identity);
2644
+ const view = await statusWithCollect(store, identity, Date.now(), ssh, {
2645
+ floorMs: COLLECT_FLOOR_MS
2646
+ });
2079
2647
  process.stdout.write(
2080
2648
  options.json ? `${JSON.stringify(view, null, 2)}
2081
2649
  ` : tmuxStatus(view)
@@ -2096,6 +2664,7 @@ registerCollect(program);
2096
2664
  registerClear(program);
2097
2665
  registerNotify(program);
2098
2666
  registerPeer(program);
2667
+ registerDoctor(program);
2099
2668
  registerStatus(program);
2100
2669
  registerPick(program);
2101
2670
  program.parse();