@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/ARCHITECTURE.md +129 -81
- package/CHANGELOG.md +120 -0
- package/README.md +105 -70
- package/dist/cli.js +840 -271
- package/dist/cli.js.map +1 -1
- package/dist/extension/murmur-pi.js +7 -3
- package/dist/extension/murmur-pi.js.map +1 -1
- package/dist/extension/store.js.map +1 -1
- package/dist/index.d.ts +54 -2
- package/dist/index.js +27 -9
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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
|
|
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(),
|
|
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:
|
|
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
|
|
1284
|
-
import { homedir as
|
|
1285
|
-
import { join as
|
|
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(
|
|
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/
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
}
|
|
1520
|
-
|
|
1521
|
-
return
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
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
|
|
1530
|
-
|
|
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
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
return
|
|
1539
|
-
|
|
1540
|
-
|
|
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
|
|
1752
|
-
var
|
|
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${
|
|
1824
|
-
const name =
|
|
1825
|
-
const host = showHost ? local ? `${
|
|
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 ? `${
|
|
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}${
|
|
1841
|
-
`${colour}${pad(state, COLUMNS.state)}${
|
|
1842
|
-
|
|
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 ? `${
|
|
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
|
-
|
|
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 ? `${
|
|
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" ? `${
|
|
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
|
-
`${
|
|
2439
|
+
`${DIM2}\u2500\u2500 pane \u2500\u2500${RESET2}`,
|
|
1876
2440
|
pane.trimEnd().slice(-PREVIEW_MESSAGE_MAX * 20)
|
|
1877
2441
|
] : [
|
|
1878
|
-
`${
|
|
1879
|
-
`${
|
|
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
|
-
` : `${
|
|
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)}${
|
|
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();
|