@martintrojer/murmur 0.1.4 → 0.2.1
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 +740 -225
- package/CHANGELOG.md +119 -0
- package/README.md +136 -27
- package/dist/cli.js +1312 -839
- package/dist/cli.js.map +1 -1
- package/dist/extension/murmur-pi.js +196 -110
- package/dist/extension/murmur-pi.js.map +1 -1
- package/dist/extension/store.js +413 -198
- package/dist/extension/store.js.map +1 -1
- package/dist/index.d.ts +480 -188
- package/dist/index.js +856 -611
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
package/dist/index.js
CHANGED
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
// src/index.ts
|
|
2
|
-
import { createRequire } from "module";
|
|
3
|
-
|
|
4
1
|
// src/agents.ts
|
|
5
2
|
import { spawnSync } from "child_process";
|
|
6
3
|
|
|
@@ -41,38 +38,15 @@ function hasWarmSocket(target) {
|
|
|
41
38
|
}
|
|
42
39
|
}
|
|
43
40
|
|
|
44
|
-
// src/
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
import { hostname } from "os";
|
|
48
|
-
import { join as join2 } from "path";
|
|
49
|
-
|
|
50
|
-
// src/paths.ts
|
|
51
|
-
import { homedir } from "os";
|
|
52
|
-
import { join } from "path";
|
|
53
|
-
function stateDir() {
|
|
54
|
-
return process.env.MURMUR_STATE_DIR ?? join(process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state"), "murmur");
|
|
55
|
-
}
|
|
56
|
-
function configDir() {
|
|
57
|
-
return process.env.MURMUR_CONFIG_DIR ?? join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "murmur");
|
|
41
|
+
// src/ids.ts
|
|
42
|
+
function asSessionId(raw) {
|
|
43
|
+
return raw;
|
|
58
44
|
}
|
|
59
|
-
function
|
|
60
|
-
return
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
// src/identity.ts
|
|
64
|
-
function loadIdentity() {
|
|
65
|
-
const path = join2(stateDir(), "identity.json");
|
|
66
|
-
return existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) : null;
|
|
45
|
+
function asWindowId(raw) {
|
|
46
|
+
return raw;
|
|
67
47
|
}
|
|
68
|
-
function
|
|
69
|
-
|
|
70
|
-
if (existing) return existing;
|
|
71
|
-
const identity = { host_id: randomUUID(), display_name: displayName };
|
|
72
|
-
mkdirSync(stateDir(), { recursive: true });
|
|
73
|
-
writeFileSync(join2(stateDir(), "identity.json"), `${JSON.stringify(identity, null, 2)}
|
|
74
|
-
`);
|
|
75
|
-
return identity;
|
|
48
|
+
function asPaneId(raw) {
|
|
49
|
+
return raw;
|
|
76
50
|
}
|
|
77
51
|
|
|
78
52
|
// src/mux.ts
|
|
@@ -88,52 +62,59 @@ function runTmux(args) {
|
|
|
88
62
|
return null;
|
|
89
63
|
}
|
|
90
64
|
}
|
|
65
|
+
function chosenWindowName(name, autoRename) {
|
|
66
|
+
if (autoRename === "1") return null;
|
|
67
|
+
return name || null;
|
|
68
|
+
}
|
|
69
|
+
function exactSession(session) {
|
|
70
|
+
return `=${session}`;
|
|
71
|
+
}
|
|
72
|
+
function exactPaneTarget(session) {
|
|
73
|
+
return `=${session}:`;
|
|
74
|
+
}
|
|
75
|
+
function tmuxBadgeState(state) {
|
|
76
|
+
return state === "running" ? "working" : state;
|
|
77
|
+
}
|
|
91
78
|
var tmux = {
|
|
92
79
|
currentWindow() {
|
|
93
|
-
const
|
|
94
|
-
if (!
|
|
80
|
+
const raw = process.env.TMUX_PANE;
|
|
81
|
+
if (!raw) return null;
|
|
82
|
+
const pane = asPaneId(raw);
|
|
95
83
|
const fields = runTmux([
|
|
96
84
|
"display-message",
|
|
97
85
|
"-t",
|
|
98
86
|
pane,
|
|
99
87
|
"-p",
|
|
100
|
-
"#{session_id} #{window_id} #{session_name} #{window_name}"
|
|
88
|
+
"#{session_id} #{window_id} #{session_name} #{window_name} #{?automatic-rename,1,0}"
|
|
101
89
|
]);
|
|
102
|
-
const [session, window, sessionName, windowName] = fields?.split(" ") ?? [];
|
|
90
|
+
const [session, window, sessionName, windowName, autoRename] = fields?.split(" ") ?? [];
|
|
103
91
|
if (!session || !window) return null;
|
|
104
92
|
return {
|
|
105
|
-
session,
|
|
106
|
-
window,
|
|
93
|
+
session: asSessionId(session),
|
|
94
|
+
window: asWindowId(window),
|
|
107
95
|
pane,
|
|
108
96
|
session_name: sessionName || null,
|
|
109
|
-
window_name: windowName
|
|
97
|
+
window_name: chosenWindowName(windowName, autoRename)
|
|
110
98
|
};
|
|
111
99
|
},
|
|
112
|
-
// Which of this host's
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
// null means "could not tell" (no tmux server, tmux missing) and is
|
|
118
|
-
// deliberately distinct from an empty set, which means "tmux answered, and
|
|
119
|
-
// there are no windows". Treating the first as the second would clear every
|
|
120
|
-
// agent on the host the moment tmux was unreachable.
|
|
100
|
+
// Which of this host's PANES still exist. The only liveness question tmux is
|
|
101
|
+
// ever asked, and the one that matches how an agent is addressed: a pane keeps
|
|
102
|
+
// its id when it moves between windows, so a recorded window id can be gone
|
|
103
|
+
// while the agent is very much alive.
|
|
121
104
|
//
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
liveWindows() {
|
|
128
|
-
const out = runTmux(["list-windows", "-a", "-F", "#{window_id}"]);
|
|
105
|
+
// null means tmux could not answer; an empty set means it did and there are
|
|
106
|
+
// none. Conflating the two would delete every agent on the host the moment
|
|
107
|
+
// tmux was briefly unreachable.
|
|
108
|
+
livePanes() {
|
|
109
|
+
const out = runTmux(["list-panes", "-a", "-F", "#{pane_id}"]);
|
|
129
110
|
if (out === null) return null;
|
|
130
|
-
return new Set(out.split("\n").filter(Boolean));
|
|
111
|
+
return new Set(out.split("\n").filter(Boolean).map(asPaneId));
|
|
131
112
|
},
|
|
132
|
-
|
|
113
|
+
setWindowBadge(window, state) {
|
|
133
114
|
if (state === null) {
|
|
134
115
|
runTmux(["set-window-option", "-qu", "-t", window, "@agent_state"]);
|
|
135
116
|
} else {
|
|
136
|
-
runTmux(["set-window-option", "-q", "-t", window, "@agent_state", state]);
|
|
117
|
+
runTmux(["set-window-option", "-q", "-t", window, "@agent_state", tmuxBadgeState(state)]);
|
|
137
118
|
runTmux(["set-window-option", "-q", "-t", window, "@pane_agent", "1"]);
|
|
138
119
|
}
|
|
139
120
|
runTmux(["refresh-client", "-S"]);
|
|
@@ -142,45 +123,51 @@ var tmux = {
|
|
|
142
123
|
runTmux(["switch-client", "-t", session]);
|
|
143
124
|
return runTmux(["select-window", "-t", window]) !== null;
|
|
144
125
|
},
|
|
145
|
-
// Window ids are what the log stores, because they are stable; names are
|
|
146
|
-
// what a human recognises in a picker. Names are live tmux state, not
|
|
147
|
-
// history, so they are resolved at render time rather than recorded.
|
|
148
|
-
windowNames() {
|
|
149
|
-
const out = runTmux(["list-windows", "-a", "-F", "#{window_id} #{window_name}"]);
|
|
150
|
-
const names = /* @__PURE__ */ new Map();
|
|
151
|
-
for (const line of out?.split("\n") ?? []) {
|
|
152
|
-
const [id, name] = line.split(" ");
|
|
153
|
-
if (id && name) names.set(id, name);
|
|
154
|
-
}
|
|
155
|
-
return names;
|
|
156
|
-
},
|
|
157
|
-
// First window carrying this exact name, or null. Used to reuse a per-host
|
|
158
|
-
// ssh window instead of opening another one.
|
|
159
126
|
// Sibling panes, for deciding whether an unowned pane may clear the window's
|
|
160
127
|
// badge. A window holding an agent and a shell must not lose the badge when
|
|
161
128
|
// you focus the shell.
|
|
162
129
|
panesInWindow(window) {
|
|
163
130
|
const out = runTmux(["list-panes", "-t", window, "-F", "#{pane_id}"]);
|
|
164
|
-
return out?.split("\n").filter(Boolean) ?? [];
|
|
131
|
+
return out?.split("\n").filter(Boolean).map(asPaneId) ?? [];
|
|
165
132
|
},
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
}
|
|
172
|
-
return null;
|
|
133
|
+
// Which client to send home when the remote attach exits. `switch-client`
|
|
134
|
+
// with no -c moves whichever client tmux considers current, and `murmur pick`
|
|
135
|
+
// usually runs in a popup -- a client of its own, which dies with the popup.
|
|
136
|
+
// Naming the real client is what lets the return outlive the picker.
|
|
137
|
+
clientName() {
|
|
138
|
+
return runTmux(["display-message", "-p", "#{client_name}"]) || null;
|
|
173
139
|
},
|
|
174
|
-
|
|
175
|
-
|
|
140
|
+
// Where the jump started, as a switch-client target. Window-level, not just
|
|
141
|
+
// the session: coming back to the right session but the wrong window is
|
|
142
|
+
// still the wrong place. The window id is stable where its index is not,
|
|
143
|
+
// since renumber-windows renumbers on every close.
|
|
144
|
+
currentTarget() {
|
|
145
|
+
return runTmux(["display-message", "-p", "#{session_name}:#{window_id}"]) || null;
|
|
146
|
+
},
|
|
147
|
+
// Whether a wrapper session for this host already exists. Deliberately not
|
|
148
|
+
// returning an id: a session is addressed by name, so a `#{session_id}` would
|
|
149
|
+
// only have to be turned back into one.
|
|
150
|
+
sessionNamed(name) {
|
|
151
|
+
const out = runTmux(["list-sessions", "-F", "#{session_name}"]);
|
|
152
|
+
if (out === null) return false;
|
|
153
|
+
return out.split("\n").includes(name);
|
|
176
154
|
},
|
|
177
|
-
|
|
178
|
-
return runTmux(["new-
|
|
155
|
+
newSession(name, command) {
|
|
156
|
+
return runTmux(["new-session", "-d", "-s", name, command]) !== null;
|
|
179
157
|
},
|
|
180
|
-
|
|
158
|
+
setSessionOption(session, option, value) {
|
|
159
|
+
runTmux(["set-option", "-t", exactPaneTarget(session), option, value]);
|
|
160
|
+
},
|
|
161
|
+
switchClient(client, session) {
|
|
162
|
+
const target = exactSession(session);
|
|
163
|
+
const args = client ? ["switch-client", "-c", client, "-t", target] : ["switch-client", "-t", target];
|
|
164
|
+
return runTmux(args) !== null;
|
|
165
|
+
},
|
|
166
|
+
// The window a pane belongs to, for a pane murmur holds no row for. Clearing
|
|
181
167
|
// a badge is a tmux operation and does not require murmur to own the pane.
|
|
182
168
|
windowForPane(pane) {
|
|
183
|
-
|
|
169
|
+
const out = runTmux(["display-message", "-t", pane, "-p", "#{window_id}"]);
|
|
170
|
+
return out ? asWindowId(out) : null;
|
|
184
171
|
},
|
|
185
172
|
capture(pane, lines) {
|
|
186
173
|
const args = ["capture-pane", "-p", "-t", pane];
|
|
@@ -216,6 +203,9 @@ function terminalText(value) {
|
|
|
216
203
|
function shellQuote(value) {
|
|
217
204
|
return `'${value.replaceAll("'", `'\\''`)}'`;
|
|
218
205
|
}
|
|
206
|
+
function remoteSessionName(peerName) {
|
|
207
|
+
return `${peerName.replace(/^[@$%=]+/, "")}~`;
|
|
208
|
+
}
|
|
219
209
|
var spawnRunner = (file, args, inherit = false) => {
|
|
220
210
|
const result = spawnSync(file, args, {
|
|
221
211
|
encoding: "utf8",
|
|
@@ -231,38 +221,14 @@ var spawnRunner = (file, args, inherit = false) => {
|
|
|
231
221
|
failed: result.error !== void 0
|
|
232
222
|
};
|
|
233
223
|
};
|
|
234
|
-
function forgetHostReplica(store, hostId) {
|
|
235
|
-
try {
|
|
236
|
-
const peer = store.peers().find((candidate) => candidate.host_id === hostId);
|
|
237
|
-
store.forgetHost(hostId);
|
|
238
|
-
if (peer) {
|
|
239
|
-
store.upsertPeer({
|
|
240
|
-
name: peer.name,
|
|
241
|
-
target: peer.target,
|
|
242
|
-
tmux_down_at: Date.now()
|
|
243
|
-
});
|
|
244
|
-
}
|
|
245
|
-
} catch {
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
function forgetReplica(store, agentId, hostId) {
|
|
249
|
-
try {
|
|
250
|
-
store.forgetAgent(agentId);
|
|
251
|
-
const peer = store.peers().find((candidate) => candidate.host_id === hostId);
|
|
252
|
-
if (peer) store.upsertPeer({ name: peer.name, target: peer.target, watermark: 0 });
|
|
253
|
-
} catch {
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
224
|
function jumpToAgent(store, agent, mux = tmux, run = spawnRunner) {
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
if (live && !live.has(agent.window)) {
|
|
261
|
-
forgetReplica(store, agent.agent_id, agent.host_id);
|
|
225
|
+
if (agent.local) {
|
|
226
|
+
const panes = mux.livePanes();
|
|
227
|
+
if (panes && !panes.has(agent.pane)) {
|
|
262
228
|
return {
|
|
263
229
|
ok: false,
|
|
264
|
-
reason: "
|
|
265
|
-
message: `${agentLabel(agent)} is gone -- its
|
|
230
|
+
reason: "pane_gone",
|
|
231
|
+
message: `${agentLabel(agent)} is gone -- its pane no longer exists.`
|
|
266
232
|
};
|
|
267
233
|
}
|
|
268
234
|
if (!mux.attach(agent.session, agent.window)) {
|
|
@@ -286,7 +252,7 @@ function jumpToAgent(store, agent, mux = tmux, run = spawnRunner) {
|
|
|
286
252
|
const probe = run("ssh", [
|
|
287
253
|
...SSH_OPTIONS,
|
|
288
254
|
target,
|
|
289
|
-
`tmux list-
|
|
255
|
+
`tmux list-panes -a -F ${shellQuote("#{pane_id}")}`
|
|
290
256
|
]);
|
|
291
257
|
if (probe.status !== 0) {
|
|
292
258
|
const sshFailed = probe.status === 255 || probe.failed;
|
|
@@ -297,38 +263,48 @@ function jumpToAgent(store, agent, mux = tmux, run = spawnRunner) {
|
|
|
297
263
|
message: `cannot reach ${target} over ssh. Nothing here ever prompts for auth, so check the host is awake and reachable, or connect once by hand to see the real error.`
|
|
298
264
|
};
|
|
299
265
|
}
|
|
300
|
-
forgetHostReplica(store, agent.host_id);
|
|
301
266
|
return {
|
|
302
267
|
ok: false,
|
|
303
268
|
reason: "no_tmux",
|
|
304
|
-
message: `${target} has no tmux server running, so its agents are gone.
|
|
269
|
+
message: `${target} has no tmux server running, so its agents are gone. They will disappear on the next collect.`
|
|
305
270
|
};
|
|
306
271
|
}
|
|
307
|
-
const
|
|
308
|
-
if (!
|
|
309
|
-
forgetReplica(store, agent.agent_id, agent.host_id);
|
|
272
|
+
const remotePanes = new Set(probe.stdout.split("\n").filter(Boolean).map(asPaneId));
|
|
273
|
+
if (!remotePanes.has(agent.pane)) {
|
|
310
274
|
return {
|
|
311
275
|
ok: false,
|
|
312
|
-
reason: "
|
|
313
|
-
message: `${agentLabel(agent)} is gone -- ${target} no longer has that
|
|
276
|
+
reason: "pane_gone",
|
|
277
|
+
message: `${agentLabel(agent)} is gone -- ${target} no longer has that pane.`
|
|
314
278
|
};
|
|
315
279
|
}
|
|
316
280
|
const attachTarget = shellQuote(`${agent.session}:${agent.window}`);
|
|
317
281
|
if (process.env.TMUX) {
|
|
318
|
-
const
|
|
319
|
-
const
|
|
320
|
-
const
|
|
321
|
-
if (
|
|
322
|
-
return mux.
|
|
282
|
+
const client = mux.clientName();
|
|
283
|
+
const origin = mux.currentTarget();
|
|
284
|
+
const name = remoteSessionName(peer?.name ?? target);
|
|
285
|
+
if (mux.sessionNamed(name)) {
|
|
286
|
+
return mux.switchClient(client, name) ? { ok: true } : {
|
|
287
|
+
ok: false,
|
|
288
|
+
reason: "attach_failed",
|
|
289
|
+
message: `could not switch to the existing ${name} session.`
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
const attach2 = `ssh -t ${shellQuote(target)} tmux attach -t ${shellQuote(attachTarget)}`;
|
|
293
|
+
const restore = origin ? `; tmux switch-client ${client ? `-c ${shellQuote(client)} ` : ""}-t ${shellQuote(`=${origin}`)}` : "";
|
|
294
|
+
if (!mux.newSession(name, `${attach2}${restore}`)) {
|
|
295
|
+
return {
|
|
323
296
|
ok: false,
|
|
324
297
|
reason: "attach_failed",
|
|
325
|
-
message: `could not
|
|
298
|
+
message: `could not open a session to attach to ${target}.`
|
|
326
299
|
};
|
|
327
300
|
}
|
|
328
|
-
|
|
301
|
+
mux.setSessionOption(name, "status", "off");
|
|
302
|
+
mux.setSessionOption(name, "prefix", "None");
|
|
303
|
+
mux.setSessionOption(name, "detach-on-destroy", "previous");
|
|
304
|
+
return mux.switchClient(client, name) ? { ok: true } : {
|
|
329
305
|
ok: false,
|
|
330
306
|
reason: "attach_failed",
|
|
331
|
-
message: `
|
|
307
|
+
message: `attached to ${target} in session ${name}, but could not switch to it.`
|
|
332
308
|
};
|
|
333
309
|
}
|
|
334
310
|
const attach = run("ssh", ["-t", target, "tmux", "attach", "-t", attachTarget], true);
|
|
@@ -339,193 +315,269 @@ function jumpToAgent(store, agent, mux = tmux, run = spawnRunner) {
|
|
|
339
315
|
};
|
|
340
316
|
}
|
|
341
317
|
|
|
342
|
-
// src/
|
|
343
|
-
var
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
const event = events[index];
|
|
349
|
-
if (!event) continue;
|
|
350
|
-
switch (event.state) {
|
|
351
|
-
case "blocked":
|
|
352
|
-
case "done":
|
|
353
|
-
case "crashed":
|
|
354
|
-
return { state: event.state, event };
|
|
355
|
-
case "cleared":
|
|
356
|
-
return { state: null, event: null };
|
|
357
|
-
case "working":
|
|
358
|
-
return {
|
|
359
|
-
state: event.pid !== null && event.pid > 0 && isAlive(event.pid) ? "working" : "crashed",
|
|
360
|
-
event
|
|
361
|
-
};
|
|
362
|
-
}
|
|
318
|
+
// src/snapshot.ts
|
|
319
|
+
var SnapshotInvalidError = class extends Error {
|
|
320
|
+
constructor(path, detail) {
|
|
321
|
+
super(path === "" ? detail : `${path}: ${detail}`);
|
|
322
|
+
this.path = path;
|
|
323
|
+
this.name = "SnapshotInvalidError";
|
|
363
324
|
}
|
|
364
|
-
|
|
325
|
+
path;
|
|
326
|
+
};
|
|
327
|
+
function fail(path, detail) {
|
|
328
|
+
throw new SnapshotInvalidError(path, detail);
|
|
365
329
|
}
|
|
366
|
-
function
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
const agentEvents = byAgent.get(event.agent_id);
|
|
370
|
-
if (agentEvents) agentEvents.push(event);
|
|
371
|
-
else byAgent.set(event.agent_id, [event]);
|
|
330
|
+
function object(value, path, keys) {
|
|
331
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
332
|
+
fail(path, "expected an object");
|
|
372
333
|
}
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
if (!
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
host_id: source.host_id,
|
|
380
|
-
state: folded.state,
|
|
381
|
-
event: folded.event,
|
|
382
|
-
workstream: source.workstream,
|
|
383
|
-
role: source.role,
|
|
384
|
-
cli: source.cli,
|
|
385
|
-
driver: source.driver ?? DEFAULT_DRIVER,
|
|
386
|
-
session: source.session,
|
|
387
|
-
window: source.window,
|
|
388
|
-
pane: source.pane,
|
|
389
|
-
session_name: source.session_name,
|
|
390
|
-
window_name: source.window_name,
|
|
391
|
-
agent_name: source.agent_name,
|
|
392
|
-
pi_session: source.pi_session,
|
|
393
|
-
fetched_at: null
|
|
394
|
-
};
|
|
395
|
-
});
|
|
334
|
+
const record = value;
|
|
335
|
+
for (const key of keys) if (!(key in record)) fail(path, `missing key ${key}`);
|
|
336
|
+
for (const key of Object.keys(record)) {
|
|
337
|
+
if (!keys.includes(key)) fail(path, `unknown key ${key}`);
|
|
338
|
+
}
|
|
339
|
+
return record;
|
|
396
340
|
}
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
crashed: 2,
|
|
401
|
-
working: 3,
|
|
402
|
-
cleared: 4
|
|
403
|
-
};
|
|
404
|
-
function attentionSort(views) {
|
|
405
|
-
return [...views].sort((left, right) => {
|
|
406
|
-
const stateOrder = (left.state === null ? 4 : ATTENTION_ORDER[left.state]) - (right.state === null ? 4 : ATTENTION_ORDER[right.state]);
|
|
407
|
-
if (stateOrder !== 0) return stateOrder;
|
|
408
|
-
return (right.event?.ts ?? 0) - (left.event?.ts ?? 0);
|
|
409
|
-
});
|
|
341
|
+
function text(value, path) {
|
|
342
|
+
if (typeof value !== "string" || value === "") fail(path, "expected a non-empty string");
|
|
343
|
+
return value;
|
|
410
344
|
}
|
|
411
|
-
function
|
|
412
|
-
|
|
345
|
+
function textOrNull(value, path) {
|
|
346
|
+
if (value === null) return null;
|
|
347
|
+
if (typeof value !== "string") fail(path, "expected a string or null");
|
|
348
|
+
return value;
|
|
413
349
|
}
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
350
|
+
function anyText(value, path) {
|
|
351
|
+
if (typeof value !== "string") fail(path, "expected a string");
|
|
352
|
+
return value;
|
|
353
|
+
}
|
|
354
|
+
function timestamp(value, path) {
|
|
355
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 0) {
|
|
356
|
+
fail(path, "expected a non-negative integer");
|
|
357
|
+
}
|
|
358
|
+
return value;
|
|
359
|
+
}
|
|
360
|
+
function member(value, path, allowed) {
|
|
361
|
+
if (typeof value !== "string" || !allowed.includes(value)) {
|
|
362
|
+
fail(path, `expected one of ${allowed.join(", ")}`);
|
|
363
|
+
}
|
|
364
|
+
return value;
|
|
365
|
+
}
|
|
366
|
+
var ACTIVITIES = ["running", "stopped"];
|
|
367
|
+
var DRIVERS = ["human", "orchestrated"];
|
|
368
|
+
var KINDS = ["done", "blocked", "crashed"];
|
|
369
|
+
var TOP_KEYS = [
|
|
370
|
+
"murmur_snapshot",
|
|
418
371
|
"host_id",
|
|
419
|
-
"
|
|
420
|
-
"
|
|
421
|
-
"
|
|
372
|
+
"display_name",
|
|
373
|
+
"murmur_version",
|
|
374
|
+
"generated_at",
|
|
375
|
+
"panes"
|
|
376
|
+
];
|
|
377
|
+
var PANE_KEYS = [
|
|
378
|
+
"pane",
|
|
422
379
|
"session",
|
|
423
380
|
"window",
|
|
424
|
-
"pane",
|
|
425
381
|
"session_name",
|
|
426
382
|
"window_name",
|
|
383
|
+
"agent",
|
|
384
|
+
"attention"
|
|
385
|
+
];
|
|
386
|
+
var AGENT_KEYS = [
|
|
387
|
+
"agent_id",
|
|
388
|
+
"activity",
|
|
427
389
|
"agent_name",
|
|
428
390
|
"pi_session",
|
|
429
391
|
"workstream",
|
|
430
392
|
"role",
|
|
431
393
|
"cli",
|
|
432
394
|
"driver",
|
|
433
|
-
"
|
|
434
|
-
"
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
function eventToWire(event) {
|
|
441
|
-
const { extra, ...known } = event;
|
|
442
|
-
return { ...extra, ...known };
|
|
443
|
-
}
|
|
444
|
-
function eventFromWire(wire) {
|
|
445
|
-
const extra = Object.fromEntries(Object.entries(wire).filter(([key]) => !EVENT_FIELDS.has(key)));
|
|
395
|
+
"claimed_at",
|
|
396
|
+
"updated_at"
|
|
397
|
+
];
|
|
398
|
+
var ATTENTION_KEYS = ["kind", "message", "source", "requested_at"];
|
|
399
|
+
function parseAgent(value, path) {
|
|
400
|
+
if (value === null) return null;
|
|
401
|
+
const row = object(value, path, AGENT_KEYS);
|
|
446
402
|
return {
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
pi_session: wire.pi_session ?? null,
|
|
458
|
-
workstream: wire.workstream ?? null,
|
|
459
|
-
role: wire.role ?? null,
|
|
460
|
-
cli: wire.cli ?? null,
|
|
461
|
-
driver: wire.driver ?? null,
|
|
462
|
-
kind: wire.kind,
|
|
463
|
-
state: wire.state,
|
|
464
|
-
message: wire.message,
|
|
465
|
-
pid: wire.pid ?? null,
|
|
466
|
-
synthetic: wire.synthetic,
|
|
467
|
-
reason: wire.reason,
|
|
468
|
-
extra
|
|
403
|
+
agent_id: text(row.agent_id, `${path}.agent_id`),
|
|
404
|
+
activity: member(row.activity, `${path}.activity`, ACTIVITIES),
|
|
405
|
+
agent_name: textOrNull(row.agent_name, `${path}.agent_name`),
|
|
406
|
+
pi_session: textOrNull(row.pi_session, `${path}.pi_session`),
|
|
407
|
+
workstream: textOrNull(row.workstream, `${path}.workstream`),
|
|
408
|
+
role: textOrNull(row.role, `${path}.role`),
|
|
409
|
+
cli: text(row.cli, `${path}.cli`),
|
|
410
|
+
driver: member(row.driver, `${path}.driver`, DRIVERS),
|
|
411
|
+
claimed_at: timestamp(row.claimed_at, `${path}.claimed_at`),
|
|
412
|
+
updated_at: timestamp(row.updated_at, `${path}.updated_at`)
|
|
469
413
|
};
|
|
470
414
|
}
|
|
471
|
-
function
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
const
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
}
|
|
415
|
+
function parseAttention(value, path) {
|
|
416
|
+
if (!Array.isArray(value)) fail(path, "expected an array");
|
|
417
|
+
const seen = /* @__PURE__ */ new Set();
|
|
418
|
+
return value.map((entry, index) => {
|
|
419
|
+
const at = `${path}[${index}]`;
|
|
420
|
+
const row = object(entry, at, ATTENTION_KEYS);
|
|
421
|
+
const kind = member(row.kind, `${at}.kind`, KINDS);
|
|
422
|
+
if (seen.has(kind)) fail(`${at}.kind`, `duplicate kind ${kind} for this pane`);
|
|
423
|
+
seen.add(kind);
|
|
424
|
+
return {
|
|
425
|
+
kind,
|
|
426
|
+
message: anyText(row.message, `${at}.message`),
|
|
427
|
+
source: anyText(row.source, `${at}.source`),
|
|
428
|
+
requested_at: timestamp(row.requested_at, `${at}.requested_at`)
|
|
429
|
+
};
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
function parsePane(value, path) {
|
|
433
|
+
const row = object(value, path, PANE_KEYS);
|
|
434
|
+
const agent = parseAgent(row.agent, `${path}.agent`);
|
|
435
|
+
const attention = parseAttention(row.attention, `${path}.attention`);
|
|
436
|
+
if (agent === null && attention.length === 0) {
|
|
437
|
+
fail(path, "a pane with no agent and no attention must not be emitted");
|
|
486
438
|
}
|
|
439
|
+
return {
|
|
440
|
+
pane: asPaneId(text(row.pane, `${path}.pane`)),
|
|
441
|
+
session: asSessionId(text(row.session, `${path}.session`)),
|
|
442
|
+
window: asWindowId(text(row.window, `${path}.window`)),
|
|
443
|
+
session_name: textOrNull(row.session_name, `${path}.session_name`),
|
|
444
|
+
window_name: textOrNull(row.window_name, `${path}.window_name`),
|
|
445
|
+
agent,
|
|
446
|
+
attention
|
|
447
|
+
};
|
|
487
448
|
}
|
|
488
|
-
function
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
449
|
+
function parseSnapshot(input) {
|
|
450
|
+
let parsed;
|
|
451
|
+
try {
|
|
452
|
+
parsed = JSON.parse(input);
|
|
453
|
+
} catch (error) {
|
|
454
|
+
fail("", `not JSON (${error instanceof Error ? error.message : String(error)})`);
|
|
455
|
+
}
|
|
456
|
+
const top = object(parsed, "", TOP_KEYS);
|
|
457
|
+
if (top.murmur_snapshot !== 1) {
|
|
458
|
+
fail("murmur_snapshot", `expected 1, got ${JSON.stringify(top.murmur_snapshot)}`);
|
|
459
|
+
}
|
|
460
|
+
if (!Array.isArray(top.panes)) fail("panes", "expected an array");
|
|
461
|
+
const panes = top.panes.map((entry, index) => parsePane(entry, `panes[${index}]`));
|
|
462
|
+
const seen = /* @__PURE__ */ new Set();
|
|
463
|
+
for (const pane of panes) {
|
|
464
|
+
if (seen.has(pane.pane)) fail("panes", `duplicate pane ${pane.pane}`);
|
|
465
|
+
seen.add(pane.pane);
|
|
466
|
+
}
|
|
467
|
+
return {
|
|
468
|
+
murmur_snapshot: 1,
|
|
469
|
+
host_id: text(top.host_id, "host_id"),
|
|
470
|
+
display_name: text(top.display_name, "display_name"),
|
|
471
|
+
murmur_version: text(top.murmur_version, "murmur_version"),
|
|
472
|
+
generated_at: timestamp(top.generated_at, "generated_at"),
|
|
473
|
+
panes
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// src/types.ts
|
|
478
|
+
var DEFAULT_DRIVER = "human";
|
|
479
|
+
|
|
480
|
+
// src/view.ts
|
|
481
|
+
var RENDER_PRIORITY = [
|
|
482
|
+
"crashed",
|
|
483
|
+
"blocked",
|
|
484
|
+
"done",
|
|
485
|
+
"running",
|
|
486
|
+
"idle"
|
|
487
|
+
];
|
|
488
|
+
var NEEDS_HUMAN = ["blocked", "crashed"];
|
|
489
|
+
var STALENESS_MS = 6e4;
|
|
490
|
+
function age(ms) {
|
|
491
|
+
if (ms === null || ms < 6e4) return "";
|
|
492
|
+
if (ms < 36e5) return `${Math.floor(ms / 6e4)}m`;
|
|
493
|
+
if (ms < 864e5) return `${Math.floor(ms / 36e5)}h`;
|
|
494
|
+
return `${Math.floor(ms / 864e5)}d`;
|
|
495
|
+
}
|
|
496
|
+
function freshness(fetchedAt, now, thresholdMs = STALENESS_MS) {
|
|
497
|
+
return fetchedAt !== null && now - fetchedAt <= thresholdMs ? "fresh" : "stale";
|
|
498
|
+
}
|
|
499
|
+
function renderState(view) {
|
|
500
|
+
for (const kind of ["crashed", "blocked", "done"]) {
|
|
501
|
+
if (view.attention.includes(kind)) return kind;
|
|
502
|
+
}
|
|
503
|
+
return view.activity === "running" ? "running" : "idle";
|
|
504
|
+
}
|
|
505
|
+
function newestAttention(pane) {
|
|
506
|
+
let newest = null;
|
|
507
|
+
for (const entry of pane.attention) {
|
|
508
|
+
if (newest === null || entry.requested_at > newest) newest = entry.requested_at;
|
|
507
509
|
}
|
|
510
|
+
return newest;
|
|
508
511
|
}
|
|
509
|
-
function
|
|
510
|
-
const
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
512
|
+
function paneView(pane, source) {
|
|
513
|
+
const agent = pane.agent;
|
|
514
|
+
return {
|
|
515
|
+
host_id: source.host_id,
|
|
516
|
+
host: source.host,
|
|
517
|
+
local: source.local,
|
|
518
|
+
pane: pane.pane,
|
|
519
|
+
session: pane.session,
|
|
520
|
+
window: pane.window,
|
|
521
|
+
session_name: pane.session_name,
|
|
522
|
+
window_name: pane.window_name,
|
|
523
|
+
activity: agent?.activity ?? null,
|
|
524
|
+
attention: pane.attention.map((entry) => entry.kind),
|
|
525
|
+
freshness: source.freshness,
|
|
526
|
+
agent_id: agent?.agent_id ?? null,
|
|
527
|
+
agent_name: agent?.agent_name ?? null,
|
|
528
|
+
pi_session: agent?.pi_session ?? null,
|
|
529
|
+
workstream: agent?.workstream ?? null,
|
|
530
|
+
role: agent?.role ?? null,
|
|
531
|
+
cli: agent?.cli ?? null,
|
|
532
|
+
driver: agent?.driver ?? DEFAULT_DRIVER,
|
|
533
|
+
updated_at: agent?.updated_at ?? newestAttention(pane),
|
|
534
|
+
snapshot_at: source.snapshot_at,
|
|
535
|
+
fetched_at: source.fetched_at
|
|
518
536
|
};
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
537
|
+
}
|
|
538
|
+
function paneViews(store, identity, now = Date.now()) {
|
|
539
|
+
const views = store.localPanes().map(
|
|
540
|
+
(pane) => paneView(pane, {
|
|
541
|
+
host_id: identity.host_id,
|
|
542
|
+
host: identity.display_name,
|
|
543
|
+
local: true,
|
|
544
|
+
// Local panes are always fresh: we are the node that authored them.
|
|
545
|
+
freshness: "fresh",
|
|
546
|
+
snapshot_at: null,
|
|
547
|
+
fetched_at: null
|
|
548
|
+
})
|
|
549
|
+
);
|
|
550
|
+
for (const peer of store.peers()) {
|
|
551
|
+
const snapshot = peer.snapshot;
|
|
552
|
+
if (!snapshot) continue;
|
|
553
|
+
const source = {
|
|
554
|
+
host_id: snapshot.host_id,
|
|
555
|
+
// The name the human typed, not the machine's self-reported hostname: a
|
|
556
|
+
// peer added as `linuxpc` can report a container id, which appears
|
|
557
|
+
// nowhere else in the tool and cannot be typed at `peer remove`.
|
|
558
|
+
host: peer.name,
|
|
559
|
+
local: false,
|
|
560
|
+
freshness: freshness(peer.fetched_at, now),
|
|
561
|
+
snapshot_at: peer.snapshot_at,
|
|
562
|
+
fetched_at: peer.fetched_at
|
|
563
|
+
};
|
|
564
|
+
for (const pane of snapshot.panes) views.push(paneView(pane, source));
|
|
565
|
+
}
|
|
566
|
+
return views;
|
|
567
|
+
}
|
|
568
|
+
var ORDER = new Map(RENDER_PRIORITY.map((state, index) => [state, index]));
|
|
569
|
+
function viewSort(views) {
|
|
570
|
+
return [...views].sort((left, right) => {
|
|
571
|
+
const byState = (ORDER.get(renderState(left)) ?? 99) - (ORDER.get(renderState(right)) ?? 99);
|
|
572
|
+
if (byState !== 0) return byState;
|
|
573
|
+
const byAge = (right.updated_at ?? 0) - (left.updated_at ?? 0);
|
|
574
|
+
if (byAge !== 0) return byAge;
|
|
575
|
+
const byHost = left.host.localeCompare(right.host);
|
|
576
|
+
return byHost !== 0 ? byHost : left.pane.localeCompare(right.pane);
|
|
577
|
+
});
|
|
525
578
|
}
|
|
526
579
|
|
|
527
580
|
// src/collector.ts
|
|
528
|
-
var STALENESS_MS = 6e4;
|
|
529
581
|
var MAX_CONCURRENT_PEERS = 8;
|
|
530
582
|
var COLLECT_DEADLINE_MS = 4e3;
|
|
531
583
|
async function mapSettled(items, limit, task, deadline) {
|
|
@@ -549,20 +601,21 @@ async function mapSettled(items, limit, task, deadline) {
|
|
|
549
601
|
await (stop ? Promise.race([pool, stop]) : pool);
|
|
550
602
|
return results;
|
|
551
603
|
}
|
|
552
|
-
function
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
throw new Error(
|
|
557
|
-
`unsupported schema version ${envelope.schema_version} (supports ${SCHEMA_VERSION})`
|
|
558
|
-
);
|
|
559
|
-
}
|
|
560
|
-
return {
|
|
561
|
-
envelope,
|
|
562
|
-
events: lines.map((line) => eventFromWire(JSON.parse(line)))
|
|
563
|
-
};
|
|
604
|
+
function isUnreachable(message) {
|
|
605
|
+
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(
|
|
606
|
+
message
|
|
607
|
+
) || /\bssh:/.test(message);
|
|
564
608
|
}
|
|
565
|
-
|
|
609
|
+
function stripInvocation(message) {
|
|
610
|
+
const firstLine = message.indexOf("\n");
|
|
611
|
+
if (firstLine === -1 || !message.startsWith("Command failed:")) return message;
|
|
612
|
+
const rest = message.slice(firstLine + 1).trim();
|
|
613
|
+
return rest === "" ? message : rest;
|
|
614
|
+
}
|
|
615
|
+
function normalizeFailure(message) {
|
|
616
|
+
return stripInvocation(message).replace(/\s+/g, " ").trim();
|
|
617
|
+
}
|
|
618
|
+
async function collect(store, channel, now = Date.now(), deadline, mux = tmux) {
|
|
566
619
|
const results = [];
|
|
567
620
|
let timer;
|
|
568
621
|
try {
|
|
@@ -574,9 +627,7 @@ async function collect(store, channel, now = Date.now(), deadline) {
|
|
|
574
627
|
const fetches = await mapSettled(
|
|
575
628
|
peers,
|
|
576
629
|
MAX_CONCURRENT_PEERS,
|
|
577
|
-
async (peer) =>
|
|
578
|
-
await channel.exec(peer.target, ["murmur", "export", "--since", String(peer.watermark)])
|
|
579
|
-
),
|
|
630
|
+
async (peer) => parseSnapshot(await channel.exec(peer.target, ["murmur", "export"])),
|
|
580
631
|
bounded
|
|
581
632
|
);
|
|
582
633
|
for (const [index, peer] of peers.entries()) {
|
|
@@ -584,58 +635,35 @@ async function collect(store, channel, now = Date.now(), deadline) {
|
|
|
584
635
|
try {
|
|
585
636
|
if (!fetch) throw new Error("collect deadline passed before this peer answered");
|
|
586
637
|
if (fetch.status === "rejected") throw fetch.reason;
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
const origin = events.filter((event) => event.host_id === envelope.host_id);
|
|
590
|
-
const watermark = origin.reduce(
|
|
591
|
-
(highest, event) => Math.max(highest, event.seq),
|
|
592
|
-
peer.watermark
|
|
593
|
-
);
|
|
594
|
-
store.upsertPeer({
|
|
595
|
-
name: peer.name,
|
|
596
|
-
target: peer.target,
|
|
597
|
-
host_id: envelope.host_id,
|
|
598
|
-
display_name: envelope.display_name,
|
|
599
|
-
watermark,
|
|
600
|
-
fetched_at: now,
|
|
601
|
-
// New events mean the node is authoring again, so whatever a jump
|
|
602
|
-
// observed about its tmux is out of date. Only clear on actual new
|
|
603
|
-
// events: an export that returns nothing proves the binary ran, not
|
|
604
|
-
// that tmux is back, which is the distinction that let a dead host
|
|
605
|
-
// look healthy for three hours.
|
|
606
|
-
//
|
|
607
|
-
// Keyed on the watermark advancing, not on ingest's insert count.
|
|
608
|
-
// Two reasons the count was wrong. Ingest is INSERT OR IGNORE, so a
|
|
609
|
-
// retry after a partial apply re-sees the same events and reports
|
|
610
|
-
// zero -- leaving a recovered host marked down until it happened to
|
|
611
|
-
// author again. And the count includes rows from other origins that
|
|
612
|
-
// this peer merely relayed, which say nothing about whether this
|
|
613
|
-
// peer's tmux is back.
|
|
614
|
-
tmux_down_at: watermark > peer.watermark ? null : peer.tmux_down_at
|
|
615
|
-
});
|
|
616
|
-
results.push({ peer: peer.name, ok: true, ingested });
|
|
638
|
+
store.replacePeerSnapshot(peer.name, { ok: true, snapshot: fetch.value, at: now });
|
|
639
|
+
results.push({ peer: peer.name, ok: true, panes: fetch.value.panes.length });
|
|
617
640
|
} catch (error) {
|
|
618
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
641
|
+
const message = normalizeFailure(error instanceof Error ? error.message : String(error));
|
|
642
|
+
store.replacePeerSnapshot(peer.name, { ok: false, error: message, at: now });
|
|
643
|
+
results.push({
|
|
644
|
+
peer: peer.name,
|
|
645
|
+
ok: false,
|
|
646
|
+
panes: 0,
|
|
647
|
+
error: message,
|
|
648
|
+
// A peer that answered with a bad document is reachable but broken,
|
|
649
|
+
// and must be visibly so rather than silently stale.
|
|
650
|
+
unreachable: error instanceof SnapshotInvalidError ? false : isUnreachable(normalizeFailure(message))
|
|
651
|
+
});
|
|
622
652
|
}
|
|
623
653
|
}
|
|
624
654
|
} catch (error) {
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
655
|
+
results.push({
|
|
656
|
+
peer: "",
|
|
657
|
+
ok: false,
|
|
658
|
+
panes: 0,
|
|
659
|
+
error: error instanceof Error ? error.message : String(error)
|
|
660
|
+
});
|
|
629
661
|
} finally {
|
|
630
662
|
clearTimeout(timer);
|
|
631
663
|
}
|
|
632
664
|
try {
|
|
633
|
-
store.
|
|
634
|
-
} catch
|
|
635
|
-
process.stderr.write(
|
|
636
|
-
`murmur: collect: prune: ${error instanceof Error ? error.message : String(error)}
|
|
637
|
-
`
|
|
638
|
-
);
|
|
665
|
+
store.reconcileLocal({ panes: mux.livePanes(), now });
|
|
666
|
+
} catch {
|
|
639
667
|
}
|
|
640
668
|
return results;
|
|
641
669
|
}
|
|
@@ -644,7 +672,7 @@ async function collect(store, channel, now = Date.now(), deadline) {
|
|
|
644
672
|
import { execFileSync as execFileSync3 } from "child_process";
|
|
645
673
|
var GLANCE_LINES = 40;
|
|
646
674
|
function glance(store, agent, lines = GLANCE_LINES) {
|
|
647
|
-
if (agent.
|
|
675
|
+
if (agent.local) return tmux.capture(agent.pane, lines);
|
|
648
676
|
const peer = store.peers().find((candidate) => candidate.host_id === agent.host_id);
|
|
649
677
|
const target = peer?.target ?? peer?.name;
|
|
650
678
|
if (!target) return null;
|
|
@@ -669,337 +697,554 @@ function glance(store, agent, lines = GLANCE_LINES) {
|
|
|
669
697
|
}
|
|
670
698
|
}
|
|
671
699
|
|
|
700
|
+
// src/identity.ts
|
|
701
|
+
import { randomUUID } from "crypto";
|
|
702
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
703
|
+
import { hostname } from "os";
|
|
704
|
+
import { join as join2 } from "path";
|
|
705
|
+
|
|
706
|
+
// src/paths.ts
|
|
707
|
+
import { homedir } from "os";
|
|
708
|
+
import { join } from "path";
|
|
709
|
+
function stateDir() {
|
|
710
|
+
return process.env.MURMUR_STATE_DIR ?? join(process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state"), "murmur");
|
|
711
|
+
}
|
|
712
|
+
function configDir() {
|
|
713
|
+
return process.env.MURMUR_CONFIG_DIR ?? join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "murmur");
|
|
714
|
+
}
|
|
715
|
+
function dbPath() {
|
|
716
|
+
return join(stateDir(), "state.db");
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// src/identity.ts
|
|
720
|
+
function identityPath() {
|
|
721
|
+
return join2(stateDir(), "identity.json");
|
|
722
|
+
}
|
|
723
|
+
var cache = null;
|
|
724
|
+
function loadIdentity() {
|
|
725
|
+
const path = identityPath();
|
|
726
|
+
if (cache?.path === path) return cache.identity;
|
|
727
|
+
const identity = existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) : null;
|
|
728
|
+
cache = { path, identity };
|
|
729
|
+
return identity;
|
|
730
|
+
}
|
|
731
|
+
function write(identity) {
|
|
732
|
+
mkdirSync(stateDir(), { recursive: true });
|
|
733
|
+
writeFileSync(identityPath(), `${JSON.stringify(identity, null, 2)}
|
|
734
|
+
`);
|
|
735
|
+
cache = { path: identityPath(), identity };
|
|
736
|
+
return identity;
|
|
737
|
+
}
|
|
738
|
+
function createIdentity(displayName = hostname()) {
|
|
739
|
+
if (loadIdentity()) throw new Error(`identity already exists: ${identityPath()}`);
|
|
740
|
+
return write({ host_id: randomUUID(), display_name: displayName });
|
|
741
|
+
}
|
|
742
|
+
function setDisplayName(displayName) {
|
|
743
|
+
const existing = loadIdentity();
|
|
744
|
+
return write(
|
|
745
|
+
existing ? { host_id: existing.host_id, display_name: displayName } : { host_id: randomUUID(), display_name: displayName }
|
|
746
|
+
);
|
|
747
|
+
}
|
|
748
|
+
|
|
672
749
|
// src/status.ts
|
|
673
750
|
function emptyCounts() {
|
|
674
|
-
|
|
751
|
+
const counts = {};
|
|
752
|
+
for (const state of RENDER_PRIORITY) counts[state] = 0;
|
|
753
|
+
return counts;
|
|
675
754
|
}
|
|
676
|
-
function
|
|
677
|
-
const
|
|
678
|
-
const
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
const local = foldAll(
|
|
684
|
-
events.filter((event) => event.host_id === identity?.host_id),
|
|
685
|
-
pidAlive
|
|
686
|
-
);
|
|
687
|
-
const remote = foldAll(
|
|
688
|
-
events.filter((event) => event.host_id !== identity?.host_id),
|
|
689
|
-
() => true
|
|
690
|
-
);
|
|
755
|
+
function tmuxStatus(view) {
|
|
756
|
+
const needsHuman = new Set(NEEDS_HUMAN);
|
|
757
|
+
const total = (state) => view.counts[state] + (needsHuman.has(state) ? view.orchestrated_counts[state] : 0);
|
|
758
|
+
return RENDER_PRIORITY.filter((state) => total(state) > 0).map((state) => `${state === "running" ? "working" : state} ${total(state)}
|
|
759
|
+
`).join("");
|
|
760
|
+
}
|
|
761
|
+
function status(store, identity, now = Date.now()) {
|
|
691
762
|
const counts = emptyCounts();
|
|
692
763
|
const orchestratedCounts = emptyCounts();
|
|
693
|
-
const
|
|
694
|
-
|
|
695
|
-
const
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
target[state] += 1;
|
|
699
|
-
return {
|
|
700
|
-
...agent,
|
|
701
|
-
fetched_at: fetchedAt,
|
|
702
|
-
// Replica freshness: how long since we last reached the peer. Local rows
|
|
703
|
-
// have no fetched_at and are never stale.
|
|
704
|
-
stale: isStale(fetchedAt, now, STALENESS_MS),
|
|
705
|
-
age_ms: fetchedAt === null ? null : now - fetchedAt,
|
|
706
|
-
// Information age: how long since the agent itself said anything. This
|
|
707
|
-
// is the number a human means by "how stale is that row". A successful
|
|
708
|
-
// fetch of a three-hour-old event resets age_ms to zero but leaves this
|
|
709
|
-
// at three hours, which is why they cannot be the same field.
|
|
710
|
-
event_age_ms: agent.event === null ? null : Math.max(0, now - agent.event.ts),
|
|
711
|
-
// A jump proved this host's tmux was down and nothing has authored since.
|
|
712
|
-
// Stronger than staleness: the host answers, its agents are just gone.
|
|
713
|
-
tmux_down: peer?.tmux_down_at != null,
|
|
714
|
-
// The name the human typed, not the machine's self-reported hostname. A
|
|
715
|
-
// peer added as `linuxpc` reported `18c04d69b860` (a container hostname)
|
|
716
|
-
// and that is what the picker showed — a string that appears nowhere
|
|
717
|
-
// else in the tool and cannot be typed at `peer remove` or searched for.
|
|
718
|
-
// Only the local node, which has no peer row, falls back to its own
|
|
719
|
-
// discovered display_name.
|
|
720
|
-
host: peer?.name ?? (agent.host_id === identity?.host_id ? identity.display_name : agent.host_id)
|
|
721
|
-
};
|
|
722
|
-
});
|
|
764
|
+
const panes = viewSort(paneViews(store, identity, now));
|
|
765
|
+
for (const pane of panes) {
|
|
766
|
+
const target = pane.driver === "human" ? counts : orchestratedCounts;
|
|
767
|
+
target[renderState(pane)] += 1;
|
|
768
|
+
}
|
|
723
769
|
return {
|
|
724
770
|
counts,
|
|
725
771
|
orchestrated_counts: orchestratedCounts,
|
|
726
|
-
|
|
727
|
-
peers: peers.map((peer) => ({
|
|
772
|
+
panes,
|
|
773
|
+
peers: store.peers().map((peer) => ({
|
|
728
774
|
name: peer.name,
|
|
729
775
|
display_name: peer.display_name,
|
|
730
776
|
fetched_at: peer.fetched_at,
|
|
731
|
-
//
|
|
732
|
-
//
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
//
|
|
736
|
-
|
|
777
|
+
// Their clock and ours, separately: a peer polled a second ago can be
|
|
778
|
+
// serving a three-hour-old fact, and one number cannot say both.
|
|
779
|
+
snapshot_at: peer.snapshot_at,
|
|
780
|
+
last_error: peer.last_error,
|
|
781
|
+
// The view's verdict, not a second threshold spelled the same way. A
|
|
782
|
+
// peer we have never reached is stale rather than fresh -- null
|
|
783
|
+
// `fetched_at` means the first collect has not succeeded yet -- and
|
|
784
|
+
// `freshness` is the one place that decides, so this list and the panes
|
|
785
|
+
// the peer contributes cannot disagree about the same host.
|
|
786
|
+
stale: freshness(peer.fetched_at, now) === "stale"
|
|
737
787
|
}))
|
|
738
788
|
};
|
|
739
789
|
}
|
|
790
|
+
async function statusWithCollect(store, identity, now = Date.now(), channel = ssh, mux = tmux) {
|
|
791
|
+
try {
|
|
792
|
+
await collect(store, channel, now, void 0, mux);
|
|
793
|
+
} catch {
|
|
794
|
+
}
|
|
795
|
+
return status(store, identity, now);
|
|
796
|
+
}
|
|
740
797
|
|
|
741
798
|
// src/store.ts
|
|
742
|
-
import {
|
|
799
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
800
|
+
import { mkdirSync as mkdirSync2, rmSync } from "fs";
|
|
801
|
+
import { dirname } from "path";
|
|
743
802
|
import Database from "better-sqlite3";
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
803
|
+
|
|
804
|
+
// src/version.ts
|
|
805
|
+
import { createRequire } from "module";
|
|
806
|
+
function readVersion() {
|
|
807
|
+
const require2 = createRequire(import.meta.url);
|
|
808
|
+
for (const candidate of ["../package.json", "../../package.json"]) {
|
|
809
|
+
try {
|
|
810
|
+
return require2(candidate).version;
|
|
811
|
+
} catch {
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
throw new Error("cannot locate package.json to read the murmur version");
|
|
815
|
+
}
|
|
816
|
+
var MURMUR_VERSION = readVersion();
|
|
817
|
+
|
|
818
|
+
// src/store.ts
|
|
819
|
+
var SCHEMA_USER_VERSION = 3;
|
|
820
|
+
var SCHEMA = `
|
|
821
|
+
CREATE TABLE agents (
|
|
822
|
+
agent_id TEXT NOT NULL PRIMARY KEY,
|
|
823
|
+
pane TEXT NOT NULL UNIQUE,
|
|
824
|
+
owner_pid INTEGER NOT NULL CHECK (owner_pid > 0),
|
|
825
|
+
activity TEXT NOT NULL CHECK (activity IN ('running', 'stopped')),
|
|
826
|
+
session TEXT NOT NULL,
|
|
827
|
+
window TEXT NOT NULL,
|
|
828
|
+
session_name TEXT,
|
|
829
|
+
window_name TEXT,
|
|
830
|
+
agent_name TEXT,
|
|
831
|
+
pi_session TEXT,
|
|
832
|
+
workstream TEXT,
|
|
833
|
+
role TEXT,
|
|
834
|
+
cli TEXT NOT NULL,
|
|
835
|
+
driver TEXT NOT NULL CHECK (driver IN ('human', 'orchestrated')),
|
|
836
|
+
claimed_at INTEGER NOT NULL,
|
|
837
|
+
updated_at INTEGER NOT NULL
|
|
838
|
+
) STRICT;
|
|
839
|
+
|
|
840
|
+
CREATE TABLE attention (
|
|
841
|
+
pane TEXT NOT NULL,
|
|
842
|
+
kind TEXT NOT NULL CHECK (kind IN ('done', 'blocked', 'crashed')),
|
|
843
|
+
message TEXT NOT NULL,
|
|
844
|
+
source TEXT NOT NULL,
|
|
845
|
+
session TEXT NOT NULL,
|
|
846
|
+
window TEXT NOT NULL,
|
|
847
|
+
session_name TEXT,
|
|
848
|
+
window_name TEXT,
|
|
849
|
+
requested_at INTEGER NOT NULL,
|
|
850
|
+
PRIMARY KEY (pane, kind)
|
|
851
|
+
) STRICT;
|
|
852
|
+
|
|
853
|
+
CREATE TABLE peers (
|
|
854
|
+
name TEXT NOT NULL PRIMARY KEY,
|
|
855
|
+
target TEXT NOT NULL,
|
|
856
|
+
host_id TEXT,
|
|
857
|
+
display_name TEXT,
|
|
858
|
+
snapshot TEXT,
|
|
859
|
+
snapshot_at INTEGER,
|
|
860
|
+
fetched_at INTEGER,
|
|
861
|
+
last_attempt_at INTEGER,
|
|
862
|
+
last_error TEXT,
|
|
863
|
+
murmur_version TEXT,
|
|
864
|
+
snapshot_version INTEGER
|
|
865
|
+
) STRICT;
|
|
866
|
+
`;
|
|
867
|
+
function salvagePeers(path) {
|
|
748
868
|
try {
|
|
749
869
|
const existing = new Database(path, { fileMustExist: true });
|
|
750
|
-
|
|
751
|
-
|
|
870
|
+
try {
|
|
871
|
+
const version = existing.pragma("user_version", { simple: true }) ?? 0;
|
|
872
|
+
if (version === SCHEMA_USER_VERSION) return [];
|
|
873
|
+
return existing.prepare("SELECT name, target FROM peers").all();
|
|
874
|
+
} catch {
|
|
875
|
+
return [];
|
|
876
|
+
} finally {
|
|
752
877
|
existing.close();
|
|
753
|
-
return salvaged;
|
|
754
878
|
}
|
|
879
|
+
} catch {
|
|
880
|
+
return [];
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
function needsReset(path) {
|
|
884
|
+
try {
|
|
885
|
+
const existing = new Database(path, { fileMustExist: true });
|
|
755
886
|
try {
|
|
756
|
-
|
|
757
|
-
}
|
|
887
|
+
return (existing.pragma("user_version", { simple: true }) ?? 0) !== SCHEMA_USER_VERSION;
|
|
888
|
+
} finally {
|
|
889
|
+
existing.close();
|
|
758
890
|
}
|
|
759
|
-
existing.close();
|
|
760
891
|
} catch {
|
|
761
|
-
return
|
|
762
|
-
}
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
event.window,
|
|
774
|
-
event.pane,
|
|
775
|
-
event.session_name,
|
|
776
|
-
event.window_name,
|
|
777
|
-
event.agent_name,
|
|
778
|
-
event.pi_session,
|
|
779
|
-
event.workstream,
|
|
780
|
-
event.role,
|
|
781
|
-
event.cli,
|
|
782
|
-
event.driver,
|
|
783
|
-
event.kind,
|
|
784
|
-
event.state,
|
|
785
|
-
event.message,
|
|
786
|
-
event.pid,
|
|
787
|
-
Number(event.synthetic),
|
|
788
|
-
event.reason,
|
|
789
|
-
JSON.stringify(event.extra)
|
|
790
|
-
];
|
|
791
|
-
}
|
|
792
|
-
function toEvent(row) {
|
|
892
|
+
return false;
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
function toAttention(row) {
|
|
896
|
+
return {
|
|
897
|
+
kind: row.kind,
|
|
898
|
+
message: row.message,
|
|
899
|
+
source: row.source,
|
|
900
|
+
requested_at: row.requested_at
|
|
901
|
+
};
|
|
902
|
+
}
|
|
903
|
+
function toAgent(row) {
|
|
793
904
|
return {
|
|
794
|
-
|
|
905
|
+
agent_id: row.agent_id,
|
|
906
|
+
activity: row.activity,
|
|
907
|
+
agent_name: row.agent_name,
|
|
908
|
+
pi_session: row.pi_session,
|
|
909
|
+
workstream: row.workstream,
|
|
910
|
+
role: row.role,
|
|
911
|
+
cli: row.cli,
|
|
795
912
|
driver: row.driver,
|
|
796
|
-
|
|
797
|
-
|
|
913
|
+
claimed_at: row.claimed_at,
|
|
914
|
+
updated_at: row.updated_at
|
|
798
915
|
};
|
|
799
916
|
}
|
|
917
|
+
var PRIORITY = new Map(RENDER_PRIORITY.map((kind, index) => [kind, index]));
|
|
918
|
+
function attentionOrder(left, right) {
|
|
919
|
+
return (PRIORITY.get(left.kind) ?? 99) - (PRIORITY.get(right.kind) ?? 99);
|
|
920
|
+
}
|
|
800
921
|
function openStore() {
|
|
801
|
-
const identity = ensureIdentity();
|
|
802
922
|
const path = dbPath();
|
|
803
|
-
|
|
923
|
+
mkdirSync2(dirname(path), { recursive: true });
|
|
924
|
+
const salvaged = salvagePeers(path);
|
|
925
|
+
if (needsReset(path)) {
|
|
926
|
+
for (const suffix of ["", "-wal", "-shm"]) rmSync(`${path}${suffix}`, { force: true });
|
|
927
|
+
}
|
|
804
928
|
const database = new Database(path);
|
|
805
929
|
database.pragma("journal_mode = WAL");
|
|
806
|
-
database.pragma(
|
|
807
|
-
database.
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
session TEXT NOT NULL,
|
|
814
|
-
window TEXT NOT NULL,
|
|
815
|
-
pane TEXT NOT NULL,
|
|
816
|
-
session_name TEXT,
|
|
817
|
-
window_name TEXT,
|
|
818
|
-
agent_name TEXT,
|
|
819
|
-
pi_session TEXT,
|
|
820
|
-
workstream TEXT,
|
|
821
|
-
role TEXT,
|
|
822
|
-
cli TEXT,
|
|
823
|
-
driver TEXT,
|
|
824
|
-
kind TEXT NOT NULL,
|
|
825
|
-
state TEXT NOT NULL,
|
|
826
|
-
message TEXT NOT NULL,
|
|
827
|
-
pid INTEGER,
|
|
828
|
-
synthetic INTEGER NOT NULL,
|
|
829
|
-
reason TEXT NOT NULL,
|
|
830
|
-
extra TEXT NOT NULL,
|
|
831
|
-
PRIMARY KEY (host_id, seq)
|
|
832
|
-
);
|
|
833
|
-
CREATE INDEX IF NOT EXISTS events_agent_seq ON events (agent_id, seq);
|
|
834
|
-
CREATE TABLE IF NOT EXISTS peers (
|
|
835
|
-
name TEXT PRIMARY KEY,
|
|
836
|
-
target TEXT NOT NULL,
|
|
837
|
-
host_id TEXT,
|
|
838
|
-
display_name TEXT,
|
|
839
|
-
watermark INTEGER NOT NULL,
|
|
840
|
-
fetched_at INTEGER,
|
|
841
|
-
-- When a jump last proved this peer's tmux was not answering. Reader
|
|
842
|
-
-- state, not an event: this node cannot author facts about another
|
|
843
|
-
-- node's agents, and a jump is a local observation, not something the
|
|
844
|
-
-- peer said. Cleared by the next successful collect.
|
|
845
|
-
tmux_down_at INTEGER
|
|
846
|
-
);
|
|
847
|
-
`);
|
|
848
|
-
try {
|
|
849
|
-
database.exec("ALTER TABLE peers ADD COLUMN tmux_down_at INTEGER");
|
|
850
|
-
} catch {
|
|
930
|
+
database.pragma("busy_timeout = 5000");
|
|
931
|
+
const version = database.pragma("user_version", { simple: true }) ?? 0;
|
|
932
|
+
if (version !== SCHEMA_USER_VERSION) {
|
|
933
|
+
database.exec(SCHEMA);
|
|
934
|
+
database.pragma(`user_version = ${SCHEMA_USER_VERSION}`);
|
|
935
|
+
const restore = database.prepare("INSERT OR IGNORE INTO peers (name, target) VALUES (?, ?)");
|
|
936
|
+
for (const peer of salvaged) restore.run(peer.name, peer.target);
|
|
851
937
|
}
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
const
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
);
|
|
870
|
-
const
|
|
871
|
-
|
|
938
|
+
const selectAgentByPane = database.prepare("SELECT * FROM agents WHERE pane = ?");
|
|
939
|
+
const insertAgent = database.prepare(`
|
|
940
|
+
INSERT INTO agents (agent_id, pane, owner_pid, activity, session, window,
|
|
941
|
+
session_name, window_name, agent_name, pi_session,
|
|
942
|
+
workstream, role, cli, driver, claimed_at, updated_at)
|
|
943
|
+
VALUES (@agent_id, @pane, @owner_pid, @activity, @session, @window,
|
|
944
|
+
@session_name, @window_name, @agent_name, @pi_session,
|
|
945
|
+
@workstream, @role, @cli, @driver, @claimed_at, @updated_at)
|
|
946
|
+
`);
|
|
947
|
+
const retainAgent = database.prepare(`
|
|
948
|
+
UPDATE agents
|
|
949
|
+
SET session = @session, window = @window, session_name = @session_name,
|
|
950
|
+
window_name = @window_name, agent_name = @agent_name,
|
|
951
|
+
pi_session = @pi_session, workstream = @workstream, role = @role,
|
|
952
|
+
cli = @cli, driver = @driver, updated_at = @updated_at
|
|
953
|
+
WHERE agent_id = @agent_id
|
|
954
|
+
`);
|
|
955
|
+
const deleteAgentByPane = database.prepare("DELETE FROM agents WHERE pane = ?");
|
|
956
|
+
const deleteAttentionForPane = database.prepare("DELETE FROM attention WHERE pane = ?");
|
|
957
|
+
const updateActivity = database.prepare(`
|
|
958
|
+
UPDATE agents
|
|
959
|
+
SET activity = @activity, session = @session, window = @window,
|
|
960
|
+
session_name = @session_name, window_name = @window_name,
|
|
961
|
+
updated_at = @updated_at
|
|
962
|
+
WHERE agent_id = @agent_id AND owner_pid = @owner_pid
|
|
963
|
+
`);
|
|
964
|
+
const deleteAgentOwned = database.prepare(
|
|
965
|
+
"DELETE FROM agents WHERE agent_id = ? AND owner_pid = ?"
|
|
872
966
|
);
|
|
873
|
-
const
|
|
874
|
-
|
|
967
|
+
const upsertAttention = database.prepare(`
|
|
968
|
+
INSERT INTO attention (pane, kind, message, source, session, window,
|
|
969
|
+
session_name, window_name, requested_at)
|
|
970
|
+
VALUES (@pane, @kind, @message, @source, @session, @window,
|
|
971
|
+
@session_name, @window_name, @requested_at)
|
|
972
|
+
ON CONFLICT (pane, kind) DO UPDATE SET
|
|
973
|
+
message = excluded.message,
|
|
974
|
+
source = excluded.source,
|
|
975
|
+
session = excluded.session,
|
|
976
|
+
window = excluded.window,
|
|
977
|
+
session_name = excluded.session_name,
|
|
978
|
+
window_name = excluded.window_name
|
|
979
|
+
`);
|
|
980
|
+
const selectAgents = database.prepare("SELECT * FROM agents");
|
|
981
|
+
const selectAttention = database.prepare("SELECT * FROM attention");
|
|
982
|
+
const setActivityByPane = database.prepare(
|
|
983
|
+
"UPDATE agents SET activity = ?, updated_at = ? WHERE pane = ?"
|
|
875
984
|
);
|
|
876
|
-
const
|
|
877
|
-
const
|
|
878
|
-
const
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
985
|
+
const claimAgent = database.transaction((claim) => {
|
|
986
|
+
const now = claim.now ?? Date.now();
|
|
987
|
+
const isAlive = claim.isAlive ?? pidAlive;
|
|
988
|
+
const { location, meta, owner_pid } = claim;
|
|
989
|
+
const incumbent = selectAgentByPane.get(location.pane);
|
|
990
|
+
const values = {
|
|
991
|
+
pane: location.pane,
|
|
992
|
+
owner_pid,
|
|
993
|
+
session: location.session,
|
|
994
|
+
window: location.window,
|
|
995
|
+
session_name: location.session_name,
|
|
996
|
+
window_name: location.window_name,
|
|
997
|
+
agent_name: meta.agent_name,
|
|
998
|
+
pi_session: meta.pi_session,
|
|
999
|
+
workstream: meta.workstream,
|
|
1000
|
+
role: meta.role,
|
|
1001
|
+
cli: meta.cli,
|
|
1002
|
+
driver: meta.driver,
|
|
1003
|
+
updated_at: now
|
|
887
1004
|
};
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
1005
|
+
if (!incumbent) {
|
|
1006
|
+
const agentId2 = randomUUID2();
|
|
1007
|
+
insertAgent.run({ ...values, agent_id: agentId2, activity: "stopped", claimed_at: now });
|
|
1008
|
+
return { outcome: "claimed", agent_id: agentId2 };
|
|
1009
|
+
}
|
|
1010
|
+
if (incumbent.owner_pid === owner_pid) {
|
|
1011
|
+
retainAgent.run({ ...values, agent_id: incumbent.agent_id });
|
|
1012
|
+
return { outcome: "retained", agent_id: incumbent.agent_id };
|
|
1013
|
+
}
|
|
1014
|
+
if (isAlive(incumbent.owner_pid)) {
|
|
1015
|
+
return { outcome: "refused", held_by_pid: incumbent.owner_pid };
|
|
1016
|
+
}
|
|
1017
|
+
deleteAgentByPane.run(location.pane);
|
|
1018
|
+
deleteAttentionForPane.run(location.pane);
|
|
1019
|
+
const agentId = randomUUID2();
|
|
1020
|
+
insertAgent.run({ ...values, agent_id: agentId, activity: "stopped", claimed_at: now });
|
|
1021
|
+
return { outcome: "replaced", agent_id: agentId, previous_agent_id: incumbent.agent_id };
|
|
1022
|
+
}).immediate;
|
|
1023
|
+
const reconcileLocal = database.transaction((world) => {
|
|
1024
|
+
const summary = { crashed: [], removed: [], attention_removed: [] };
|
|
1025
|
+
if (world.panes === null) return summary;
|
|
1026
|
+
const live = world.panes;
|
|
1027
|
+
const isAlive = world.isAlive ?? pidAlive;
|
|
1028
|
+
const now = world.now ?? Date.now();
|
|
1029
|
+
const alreadyCrashed = new Set(
|
|
1030
|
+
selectAttention.all().filter((row) => row.kind === "crashed").map((row) => row.pane)
|
|
1031
|
+
);
|
|
1032
|
+
for (const row of selectAgents.all()) {
|
|
1033
|
+
const pane = asPaneId(row.pane);
|
|
1034
|
+
if (!live.has(pane)) {
|
|
1035
|
+
deleteAgentByPane.run(row.pane);
|
|
1036
|
+
deleteAttentionForPane.run(row.pane);
|
|
1037
|
+
summary.removed.push(pane);
|
|
1038
|
+
continue;
|
|
1039
|
+
}
|
|
1040
|
+
if (isAlive(row.owner_pid)) continue;
|
|
1041
|
+
if (row.activity === "running") {
|
|
1042
|
+
setActivityByPane.run("stopped", now, row.pane);
|
|
1043
|
+
upsertAttention.run({
|
|
1044
|
+
pane: row.pane,
|
|
1045
|
+
kind: "crashed",
|
|
1046
|
+
message: "",
|
|
1047
|
+
source: "murmur",
|
|
1048
|
+
session: row.session,
|
|
1049
|
+
window: row.window,
|
|
1050
|
+
session_name: row.session_name,
|
|
1051
|
+
window_name: row.window_name,
|
|
1052
|
+
requested_at: now
|
|
1053
|
+
});
|
|
1054
|
+
summary.crashed.push(pane);
|
|
1055
|
+
} else if (!alreadyCrashed.has(row.pane)) {
|
|
1056
|
+
deleteAgentByPane.run(row.pane);
|
|
1057
|
+
summary.removed.push(pane);
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
for (const row of selectAttention.all()) {
|
|
1061
|
+
const pane = asPaneId(row.pane);
|
|
1062
|
+
if (live.has(pane)) continue;
|
|
1063
|
+
deleteAttentionForPane.run(row.pane);
|
|
1064
|
+
if (!summary.attention_removed.includes(pane)) summary.attention_removed.push(pane);
|
|
1065
|
+
}
|
|
1066
|
+
return summary;
|
|
1067
|
+
}).immediate;
|
|
1068
|
+
const readLocalPanes = database.transaction(() => {
|
|
1069
|
+
const agents = selectAgents.all();
|
|
1070
|
+
const attention = selectAttention.all();
|
|
1071
|
+
const panes = /* @__PURE__ */ new Map();
|
|
1072
|
+
const locate = (row) => {
|
|
1073
|
+
const existing = panes.get(row.pane);
|
|
1074
|
+
if (existing) return existing;
|
|
1075
|
+
const created = {
|
|
1076
|
+
pane: asPaneId(row.pane),
|
|
1077
|
+
session: asSessionId(row.session),
|
|
1078
|
+
window: asWindowId(row.window),
|
|
1079
|
+
session_name: row.session_name,
|
|
1080
|
+
window_name: row.window_name,
|
|
1081
|
+
agent: null,
|
|
1082
|
+
attention: []
|
|
1083
|
+
};
|
|
1084
|
+
panes.set(row.pane, created);
|
|
1085
|
+
return created;
|
|
1086
|
+
};
|
|
1087
|
+
for (const row of agents) locate(row).agent = toAgent(row);
|
|
1088
|
+
for (const row of attention) locate(row).attention.push(toAttention(row));
|
|
1089
|
+
for (const pane of panes.values()) pane.attention.sort(attentionOrder);
|
|
1090
|
+
return [...panes.values()].sort((left, right) => left.pane.localeCompare(right.pane));
|
|
895
1091
|
});
|
|
1092
|
+
function peerRecord(row) {
|
|
1093
|
+
let snapshot = null;
|
|
1094
|
+
if (row.snapshot !== null) {
|
|
1095
|
+
try {
|
|
1096
|
+
snapshot = JSON.parse(row.snapshot);
|
|
1097
|
+
} catch {
|
|
1098
|
+
snapshot = null;
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
return {
|
|
1102
|
+
name: row.name,
|
|
1103
|
+
target: row.target,
|
|
1104
|
+
host_id: row.host_id,
|
|
1105
|
+
display_name: row.display_name,
|
|
1106
|
+
snapshot,
|
|
1107
|
+
snapshot_at: row.snapshot_at,
|
|
1108
|
+
fetched_at: row.fetched_at,
|
|
1109
|
+
last_attempt_at: row.last_attempt_at,
|
|
1110
|
+
last_error: row.last_error,
|
|
1111
|
+
murmur_version: row.murmur_version,
|
|
1112
|
+
snapshot_version: row.snapshot_version
|
|
1113
|
+
};
|
|
1114
|
+
}
|
|
896
1115
|
return {
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
1116
|
+
claimAgent,
|
|
1117
|
+
reconcileLocal,
|
|
1118
|
+
setActivity(update) {
|
|
1119
|
+
return updateActivity.run({
|
|
1120
|
+
activity: update.activity,
|
|
1121
|
+
session: update.location.session,
|
|
1122
|
+
window: update.location.window,
|
|
1123
|
+
session_name: update.location.session_name,
|
|
1124
|
+
window_name: update.location.window_name,
|
|
1125
|
+
updated_at: update.now ?? Date.now(),
|
|
1126
|
+
agent_id: update.agent_id,
|
|
1127
|
+
owner_pid: update.owner_pid
|
|
1128
|
+
}).changes === 1;
|
|
906
1129
|
},
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
`SELECT * FROM events
|
|
910
|
-
WHERE host_id = ? AND agent_id = ?
|
|
911
|
-
ORDER BY seq DESC LIMIT 1`
|
|
912
|
-
).get(hostId, agentId);
|
|
913
|
-
return row ? toEvent(row) : null;
|
|
1130
|
+
releaseAgent(release) {
|
|
1131
|
+
return deleteAgentOwned.run(release.agent_id, release.owner_pid).changes === 1;
|
|
914
1132
|
},
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
) WHERE rn = 1
|
|
928
|
-
)
|
|
929
|
-
`).run(Date.now() - horizonMs).changes;
|
|
1133
|
+
requestAttention(request) {
|
|
1134
|
+
upsertAttention.run({
|
|
1135
|
+
pane: request.location.pane,
|
|
1136
|
+
kind: request.kind,
|
|
1137
|
+
message: request.message,
|
|
1138
|
+
source: request.source,
|
|
1139
|
+
session: request.location.session,
|
|
1140
|
+
window: request.location.window,
|
|
1141
|
+
session_name: request.location.session_name,
|
|
1142
|
+
window_name: request.location.window_name,
|
|
1143
|
+
requested_at: request.now ?? Date.now()
|
|
1144
|
+
});
|
|
930
1145
|
},
|
|
931
|
-
|
|
932
|
-
return
|
|
1146
|
+
acknowledgePane(pane) {
|
|
1147
|
+
return deleteAttentionForPane.run(pane).changes;
|
|
933
1148
|
},
|
|
934
|
-
|
|
935
|
-
return
|
|
1149
|
+
localPanes() {
|
|
1150
|
+
return readLocalPanes();
|
|
936
1151
|
},
|
|
937
|
-
|
|
938
|
-
|
|
1152
|
+
buildLocalSnapshot(identity, world) {
|
|
1153
|
+
reconcileLocal(world);
|
|
1154
|
+
return {
|
|
1155
|
+
murmur_snapshot: 1,
|
|
1156
|
+
host_id: identity.host_id,
|
|
1157
|
+
display_name: identity.display_name,
|
|
1158
|
+
murmur_version: MURMUR_VERSION,
|
|
1159
|
+
generated_at: world.now ?? Date.now(),
|
|
1160
|
+
// Rule 3: a pane with no agent and no attention must not be published.
|
|
1161
|
+
// A no-op against today's `readLocalPanes`, which builds a pane entry
|
|
1162
|
+
// only from a row and so cannot produce an empty one -- kept because the
|
|
1163
|
+
// rule belongs to the DOCUMENT, and the validator rejects such an entry
|
|
1164
|
+
// outright. Without it, one narrowing of the local read would make this
|
|
1165
|
+
// node reachable-but-broken on every peer that collects it, and the
|
|
1166
|
+
// symptom would show up on the other machines.
|
|
1167
|
+
panes: readLocalPanes().filter((pane) => pane.agent !== null || pane.attention.length > 0)
|
|
1168
|
+
};
|
|
939
1169
|
},
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
INSERT INTO peers (name, target, host_id, display_name, watermark, fetched_at, tmux_down_at)
|
|
944
|
-
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
945
|
-
ON CONFLICT(name) DO UPDATE SET
|
|
946
|
-
target = excluded.target,
|
|
947
|
-
host_id = excluded.host_id,
|
|
948
|
-
display_name = excluded.display_name,
|
|
949
|
-
watermark = excluded.watermark,
|
|
950
|
-
fetched_at = excluded.fetched_at,
|
|
951
|
-
tmux_down_at = excluded.tmux_down_at
|
|
952
|
-
`).run(
|
|
953
|
-
peer.name,
|
|
954
|
-
peer.target,
|
|
955
|
-
peer.host_id !== void 0 ? peer.host_id : current?.host_id ?? null,
|
|
956
|
-
peer.display_name !== void 0 ? peer.display_name : current?.display_name ?? null,
|
|
957
|
-
peer.watermark !== void 0 ? peer.watermark : current?.watermark ?? 0,
|
|
958
|
-
peer.fetched_at !== void 0 ? peer.fetched_at : current?.fetched_at ?? null,
|
|
959
|
-
peer.tmux_down_at !== void 0 ? peer.tmux_down_at : current?.tmux_down_at ?? null
|
|
1170
|
+
peers() {
|
|
1171
|
+
return database.prepare("SELECT * FROM peers ORDER BY name").all().map(
|
|
1172
|
+
peerRecord
|
|
960
1173
|
);
|
|
961
1174
|
},
|
|
1175
|
+
addPeer(name, target) {
|
|
1176
|
+
database.prepare(
|
|
1177
|
+
`INSERT INTO peers (name, target) VALUES (?, ?)
|
|
1178
|
+
ON CONFLICT(name) DO UPDATE SET target = excluded.target`
|
|
1179
|
+
).run(name, target);
|
|
1180
|
+
},
|
|
962
1181
|
removePeer(name) {
|
|
963
1182
|
return database.prepare("DELETE FROM peers WHERE name = ?").run(name).changes > 0;
|
|
964
1183
|
},
|
|
1184
|
+
replacePeerSnapshot(name, fetch) {
|
|
1185
|
+
if (!fetch.ok) {
|
|
1186
|
+
database.prepare("UPDATE peers SET last_attempt_at = ?, last_error = ? WHERE name = ?").run(fetch.at, fetch.error, name);
|
|
1187
|
+
return;
|
|
1188
|
+
}
|
|
1189
|
+
database.prepare(
|
|
1190
|
+
`UPDATE peers
|
|
1191
|
+
SET snapshot = ?, snapshot_at = ?, fetched_at = ?, last_attempt_at = ?,
|
|
1192
|
+
last_error = NULL, host_id = ?, display_name = ?,
|
|
1193
|
+
murmur_version = ?, snapshot_version = ?
|
|
1194
|
+
WHERE name = ?`
|
|
1195
|
+
).run(
|
|
1196
|
+
JSON.stringify(fetch.snapshot),
|
|
1197
|
+
fetch.snapshot.generated_at,
|
|
1198
|
+
fetch.at,
|
|
1199
|
+
fetch.at,
|
|
1200
|
+
fetch.snapshot.host_id,
|
|
1201
|
+
fetch.snapshot.display_name,
|
|
1202
|
+
fetch.snapshot.murmur_version,
|
|
1203
|
+
fetch.snapshot.murmur_snapshot,
|
|
1204
|
+
name
|
|
1205
|
+
);
|
|
1206
|
+
},
|
|
965
1207
|
close() {
|
|
966
1208
|
database.close();
|
|
967
1209
|
}
|
|
968
1210
|
};
|
|
969
1211
|
}
|
|
970
|
-
|
|
971
|
-
// src/index.ts
|
|
972
|
-
var manifest = createRequire(import.meta.url)("../package.json");
|
|
973
|
-
var VERSION = manifest.version;
|
|
974
1212
|
export {
|
|
975
1213
|
DEFAULT_DRIVER,
|
|
976
1214
|
MAX_CONCURRENT_PEERS,
|
|
977
|
-
|
|
1215
|
+
NEEDS_HUMAN,
|
|
1216
|
+
RENDER_PRIORITY,
|
|
978
1217
|
STALENESS_MS,
|
|
979
|
-
|
|
980
|
-
VERSION,
|
|
1218
|
+
SnapshotInvalidError,
|
|
1219
|
+
MURMUR_VERSION as VERSION,
|
|
1220
|
+
age,
|
|
981
1221
|
agentLabel,
|
|
982
1222
|
agentLocation,
|
|
983
|
-
|
|
1223
|
+
asPaneId,
|
|
1224
|
+
asSessionId,
|
|
1225
|
+
asWindowId,
|
|
984
1226
|
collect,
|
|
985
1227
|
configDir,
|
|
1228
|
+
createIdentity,
|
|
986
1229
|
dbPath,
|
|
987
|
-
|
|
988
|
-
eventFromWire,
|
|
989
|
-
exportJsonl,
|
|
990
|
-
foldAgent,
|
|
991
|
-
foldAll,
|
|
1230
|
+
freshness,
|
|
992
1231
|
glance,
|
|
993
1232
|
hasWarmSocket,
|
|
994
|
-
isStale,
|
|
995
1233
|
jumpToAgent,
|
|
996
1234
|
loadIdentity,
|
|
997
1235
|
openStore,
|
|
1236
|
+
paneViews,
|
|
1237
|
+
parseSnapshot,
|
|
998
1238
|
pidAlive,
|
|
1239
|
+
renderState,
|
|
1240
|
+
setDisplayName,
|
|
999
1241
|
shellQuote,
|
|
1000
1242
|
ssh,
|
|
1001
1243
|
stateDir,
|
|
1002
1244
|
status,
|
|
1003
|
-
|
|
1245
|
+
statusWithCollect,
|
|
1246
|
+
tmux,
|
|
1247
|
+
tmuxStatus,
|
|
1248
|
+
viewSort
|
|
1004
1249
|
};
|
|
1005
1250
|
//# sourceMappingURL=index.js.map
|