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