@martintrojer/murmur 0.1.3 → 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 +742 -219
- package/CHANGELOG.md +172 -0
- package/README.md +136 -27
- package/dist/cli.js +1419 -869
- package/dist/cli.js.map +1 -1
- package/dist/extension/murmur-pi.js +190 -102
- package/dist/extension/murmur-pi.js.map +1 -1
- package/dist/extension/store.js +413 -190
- package/dist/extension/store.js.map +1 -1
- package/dist/index.d.ts +492 -147
- package/dist/index.js +970 -641
- package/dist/index.js.map +1 -1
- package/package.json +4 -3
package/dist/index.js
CHANGED
|
@@ -1,48 +1,59 @@
|
|
|
1
|
-
// src/index.ts
|
|
2
|
-
import { createRequire } from "module";
|
|
3
|
-
|
|
4
1
|
// src/agents.ts
|
|
5
2
|
import { spawnSync } from "child_process";
|
|
6
3
|
|
|
7
|
-
// src/
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
4
|
+
// src/channel.ts
|
|
5
|
+
import { execFile, execFileSync } from "child_process";
|
|
6
|
+
import { promisify } from "util";
|
|
7
|
+
var execFileAsync = promisify(execFile);
|
|
8
|
+
var CONTROL_PATH = "~/.ssh/control/%r@%h:%p";
|
|
9
|
+
var CONNECT_TIMEOUT_S = 1;
|
|
10
|
+
var EXEC_TIMEOUT_MS = 3e3;
|
|
11
|
+
var SSH_OPTIONS = [
|
|
12
|
+
"-o",
|
|
13
|
+
"BatchMode=yes",
|
|
14
|
+
"-o",
|
|
15
|
+
"ControlMaster=no",
|
|
16
|
+
"-o",
|
|
17
|
+
`ControlPath=${CONTROL_PATH}`,
|
|
18
|
+
"-o",
|
|
19
|
+
`ConnectTimeout=${CONNECT_TIMEOUT_S}`
|
|
20
|
+
];
|
|
21
|
+
var MAX_EXPORT_BYTES = 64 * 1024 * 1024;
|
|
22
|
+
var ssh = {
|
|
23
|
+
async exec(target, argv) {
|
|
24
|
+
const { stdout } = await execFileAsync("ssh", [...SSH_OPTIONS, target, ...argv], {
|
|
25
|
+
encoding: "utf8",
|
|
26
|
+
timeout: EXEC_TIMEOUT_MS,
|
|
27
|
+
maxBuffer: MAX_EXPORT_BYTES
|
|
28
|
+
});
|
|
29
|
+
return stdout;
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
function hasWarmSocket(target) {
|
|
33
|
+
try {
|
|
34
|
+
execFileSync("ssh", [...SSH_OPTIONS, "-O", "check", target], { stdio: "ignore" });
|
|
35
|
+
return true;
|
|
36
|
+
} catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
24
39
|
}
|
|
25
40
|
|
|
26
|
-
// src/
|
|
27
|
-
function
|
|
28
|
-
|
|
29
|
-
return existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) : null;
|
|
41
|
+
// src/ids.ts
|
|
42
|
+
function asSessionId(raw) {
|
|
43
|
+
return raw;
|
|
30
44
|
}
|
|
31
|
-
function
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
writeFileSync(join2(stateDir(), "identity.json"), `${JSON.stringify(identity, null, 2)}
|
|
37
|
-
`);
|
|
38
|
-
return identity;
|
|
45
|
+
function asWindowId(raw) {
|
|
46
|
+
return raw;
|
|
47
|
+
}
|
|
48
|
+
function asPaneId(raw) {
|
|
49
|
+
return raw;
|
|
39
50
|
}
|
|
40
51
|
|
|
41
52
|
// src/mux.ts
|
|
42
|
-
import { execFileSync } from "child_process";
|
|
53
|
+
import { execFileSync as execFileSync2 } from "child_process";
|
|
43
54
|
function runTmux(args) {
|
|
44
55
|
try {
|
|
45
|
-
return
|
|
56
|
+
return execFileSync2("tmux", args, {
|
|
46
57
|
encoding: "utf8",
|
|
47
58
|
timeout: 3e3,
|
|
48
59
|
stdio: ["ignore", "pipe", "ignore"]
|
|
@@ -51,10 +62,20 @@ function runTmux(args) {
|
|
|
51
62
|
return null;
|
|
52
63
|
}
|
|
53
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
|
+
}
|
|
54
74
|
var tmux = {
|
|
55
75
|
currentWindow() {
|
|
56
|
-
const
|
|
57
|
-
if (!
|
|
76
|
+
const raw = process.env.TMUX_PANE;
|
|
77
|
+
if (!raw) return null;
|
|
78
|
+
const pane = asPaneId(raw);
|
|
58
79
|
const fields = runTmux([
|
|
59
80
|
"display-message",
|
|
60
81
|
"-t",
|
|
@@ -65,82 +86,84 @@ var tmux = {
|
|
|
65
86
|
const [session, window, sessionName, windowName] = fields?.split(" ") ?? [];
|
|
66
87
|
if (!session || !window) return null;
|
|
67
88
|
return {
|
|
68
|
-
session,
|
|
69
|
-
window,
|
|
89
|
+
session: asSessionId(session),
|
|
90
|
+
window: asWindowId(window),
|
|
70
91
|
pane,
|
|
71
92
|
session_name: sessionName || null,
|
|
72
93
|
window_name: windowName || null
|
|
73
94
|
};
|
|
74
95
|
},
|
|
75
|
-
// Which of this host's
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
// null means "could not tell" (no tmux server, tmux missing) and is
|
|
81
|
-
// deliberately distinct from an empty set, which means "tmux answered, and
|
|
82
|
-
// there are no windows". Treating the first as the second would clear every
|
|
83
|
-
// agent on the host the moment tmux was unreachable.
|
|
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.
|
|
84
100
|
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
liveWindows() {
|
|
91
|
-
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}"]);
|
|
92
106
|
if (out === null) return null;
|
|
93
|
-
return new Set(out.split("\n").filter(Boolean));
|
|
107
|
+
return new Set(out.split("\n").filter(Boolean).map(asPaneId));
|
|
94
108
|
},
|
|
95
|
-
|
|
109
|
+
setWindowBadge(window, state) {
|
|
96
110
|
if (state === null) {
|
|
97
111
|
runTmux(["set-window-option", "-qu", "-t", window, "@agent_state"]);
|
|
98
112
|
} else {
|
|
99
|
-
runTmux(["set-window-option", "-q", "-t", window, "@agent_state", state]);
|
|
113
|
+
runTmux(["set-window-option", "-q", "-t", window, "@agent_state", tmuxBadgeState(state)]);
|
|
100
114
|
runTmux(["set-window-option", "-q", "-t", window, "@pane_agent", "1"]);
|
|
101
115
|
}
|
|
102
116
|
runTmux(["refresh-client", "-S"]);
|
|
103
117
|
},
|
|
104
118
|
attach(session, window) {
|
|
105
119
|
runTmux(["switch-client", "-t", session]);
|
|
106
|
-
runTmux(["select-window", "-t", window]);
|
|
120
|
+
return runTmux(["select-window", "-t", window]) !== null;
|
|
107
121
|
},
|
|
108
|
-
// Window ids are what the log stores, because they are stable; names are
|
|
109
|
-
// what a human recognises in a picker. Names are live tmux state, not
|
|
110
|
-
// history, so they are resolved at render time rather than recorded.
|
|
111
|
-
windowNames() {
|
|
112
|
-
const out = runTmux(["list-windows", "-a", "-F", "#{window_id} #{window_name}"]);
|
|
113
|
-
const names = /* @__PURE__ */ new Map();
|
|
114
|
-
for (const line of out?.split("\n") ?? []) {
|
|
115
|
-
const [id, name] = line.split(" ");
|
|
116
|
-
if (id && name) names.set(id, name);
|
|
117
|
-
}
|
|
118
|
-
return names;
|
|
119
|
-
},
|
|
120
|
-
// First window carrying this exact name, or null. Used to reuse a per-host
|
|
121
|
-
// ssh window instead of opening another one.
|
|
122
122
|
// Sibling panes, for deciding whether an unowned pane may clear the window's
|
|
123
123
|
// badge. A window holding an agent and a shell must not lose the badge when
|
|
124
124
|
// you focus the shell.
|
|
125
125
|
panesInWindow(window) {
|
|
126
126
|
const out = runTmux(["list-panes", "-t", window, "-F", "#{pane_id}"]);
|
|
127
|
-
return out?.split("\n").filter(Boolean) ?? [];
|
|
127
|
+
return out?.split("\n").filter(Boolean).map(asPaneId) ?? [];
|
|
128
128
|
},
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
}
|
|
135
|
-
|
|
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;
|
|
135
|
+
},
|
|
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;
|
|
136
142
|
},
|
|
137
|
-
|
|
138
|
-
|
|
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);
|
|
139
150
|
},
|
|
140
|
-
|
|
151
|
+
newSession(name, command) {
|
|
152
|
+
return runTmux(["new-session", "-d", "-s", name, command]) !== null;
|
|
153
|
+
},
|
|
154
|
+
setSessionOption(session, option, value) {
|
|
155
|
+
runTmux(["set-option", "-t", exactPaneTarget(session), option, value]);
|
|
156
|
+
},
|
|
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
|
|
141
163
|
// a badge is a tmux operation and does not require murmur to own the pane.
|
|
142
164
|
windowForPane(pane) {
|
|
143
|
-
|
|
165
|
+
const out = runTmux(["display-message", "-t", pane, "-p", "#{window_id}"]);
|
|
166
|
+
return out ? asWindowId(out) : null;
|
|
144
167
|
},
|
|
145
168
|
capture(pane, lines) {
|
|
146
169
|
const args = ["capture-pane", "-p", "-t", pane];
|
|
@@ -176,41 +199,41 @@ function terminalText(value) {
|
|
|
176
199
|
function shellQuote(value) {
|
|
177
200
|
return `'${value.replaceAll("'", `'\\''`)}'`;
|
|
178
201
|
}
|
|
179
|
-
function
|
|
180
|
-
|
|
181
|
-
const peer = store.peers().find((candidate) => candidate.host_id === hostId);
|
|
182
|
-
store.forgetHost(hostId);
|
|
183
|
-
if (peer) {
|
|
184
|
-
store.upsertPeer({
|
|
185
|
-
name: peer.name,
|
|
186
|
-
target: peer.target,
|
|
187
|
-
tmux_down_at: Date.now()
|
|
188
|
-
});
|
|
189
|
-
}
|
|
190
|
-
} catch {
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
function forgetReplica(store, agentId, hostId) {
|
|
194
|
-
try {
|
|
195
|
-
store.forgetAgent(agentId);
|
|
196
|
-
const peer = store.peers().find((candidate) => candidate.host_id === hostId);
|
|
197
|
-
if (peer) store.upsertPeer({ name: peer.name, target: peer.target, watermark: 0 });
|
|
198
|
-
} catch {
|
|
199
|
-
}
|
|
202
|
+
function remoteSessionName(peerName) {
|
|
203
|
+
return `${peerName.replace(/^[@$%=]+/, "")}~`;
|
|
200
204
|
}
|
|
201
|
-
|
|
202
|
-
const
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
205
|
+
var spawnRunner = (file, args, inherit = false) => {
|
|
206
|
+
const result = spawnSync(file, args, {
|
|
207
|
+
encoding: "utf8",
|
|
208
|
+
timeout: 1e4,
|
|
209
|
+
...inherit ? { stdio: "inherit" } : {}
|
|
210
|
+
});
|
|
211
|
+
return {
|
|
212
|
+
status: result.status,
|
|
213
|
+
stdout: result.stdout ?? "",
|
|
214
|
+
// spawnSync reports a failure to even start the child in `error`, leaving
|
|
215
|
+
// status null. Collapsing both here keeps the decision table below reading
|
|
216
|
+
// as one question rather than two.
|
|
217
|
+
failed: result.error !== void 0
|
|
218
|
+
};
|
|
219
|
+
};
|
|
220
|
+
function jumpToAgent(store, agent, mux = tmux, run = spawnRunner) {
|
|
221
|
+
if (agent.local) {
|
|
222
|
+
const panes = mux.livePanes();
|
|
223
|
+
if (panes && !panes.has(agent.pane)) {
|
|
224
|
+
return {
|
|
225
|
+
ok: false,
|
|
226
|
+
reason: "pane_gone",
|
|
227
|
+
message: `${agentLabel(agent)} is gone -- its pane no longer exists.`
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
if (!mux.attach(agent.session, agent.window)) {
|
|
207
231
|
return {
|
|
208
232
|
ok: false,
|
|
209
|
-
reason: "
|
|
210
|
-
message:
|
|
233
|
+
reason: "attach_failed",
|
|
234
|
+
message: `could not attach to ${agentLabel(agent)} (tmux select-window failed).`
|
|
211
235
|
};
|
|
212
236
|
}
|
|
213
|
-
tmux.attach(agent.session, agent.window);
|
|
214
237
|
return { ok: true };
|
|
215
238
|
}
|
|
216
239
|
const peer = store.peers().find((candidate) => candidate.host_id === agent.host_id);
|
|
@@ -222,330 +245,421 @@ function jumpToAgent(store, agent) {
|
|
|
222
245
|
message: `No peer configured for host ${agent.host_id.slice(0, 8)}. Try: murmur peer add <target>`
|
|
223
246
|
};
|
|
224
247
|
}
|
|
225
|
-
const probe =
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
);
|
|
248
|
+
const probe = run("ssh", [
|
|
249
|
+
...SSH_OPTIONS,
|
|
250
|
+
target,
|
|
251
|
+
`tmux list-panes -a -F ${shellQuote("#{pane_id}")}`
|
|
252
|
+
]);
|
|
230
253
|
if (probe.status !== 0) {
|
|
231
|
-
const sshFailed = probe.status === 255 || probe.
|
|
254
|
+
const sshFailed = probe.status === 255 || probe.failed;
|
|
232
255
|
if (sshFailed) {
|
|
233
256
|
return {
|
|
234
257
|
ok: false,
|
|
235
258
|
reason: "unreachable",
|
|
236
|
-
message: `cannot reach ${target} over ssh.
|
|
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.`
|
|
237
260
|
};
|
|
238
261
|
}
|
|
239
|
-
forgetHostReplica(store, agent.host_id);
|
|
240
262
|
return {
|
|
241
263
|
ok: false,
|
|
242
264
|
reason: "no_tmux",
|
|
243
|
-
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.`
|
|
244
266
|
};
|
|
245
267
|
}
|
|
246
|
-
const
|
|
247
|
-
if (!
|
|
248
|
-
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)) {
|
|
249
270
|
return {
|
|
250
271
|
ok: false,
|
|
251
|
-
reason: "
|
|
252
|
-
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.`
|
|
253
274
|
};
|
|
254
275
|
}
|
|
255
276
|
const attachTarget = shellQuote(`${agent.session}:${agent.window}`);
|
|
256
277
|
if (process.env.TMUX) {
|
|
257
|
-
const
|
|
258
|
-
const
|
|
259
|
-
const
|
|
260
|
-
if (
|
|
261
|
-
|
|
262
|
-
|
|
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 } : {
|
|
283
|
+
ok: false,
|
|
284
|
+
reason: "attach_failed",
|
|
285
|
+
message: `could not switch to the existing ${name} session.`
|
|
286
|
+
};
|
|
263
287
|
}
|
|
264
|
-
|
|
265
|
-
|
|
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 } : {
|
|
301
|
+
ok: false,
|
|
302
|
+
reason: "attach_failed",
|
|
303
|
+
message: `attached to ${target} in session ${name}, but could not switch to it.`
|
|
304
|
+
};
|
|
266
305
|
}
|
|
267
|
-
|
|
268
|
-
return { ok: true }
|
|
306
|
+
const attach = run("ssh", ["-t", target, "tmux", "attach", "-t", attachTarget], true);
|
|
307
|
+
return attach.status === 0 && !attach.failed ? { ok: true } : {
|
|
308
|
+
ok: false,
|
|
309
|
+
reason: "attach_failed",
|
|
310
|
+
message: `ssh attach to ${target} failed.`
|
|
311
|
+
};
|
|
269
312
|
}
|
|
270
313
|
|
|
271
|
-
// src/
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
var EXEC_TIMEOUT_MS = 1e4;
|
|
278
|
-
var SSH_OPTIONS = [
|
|
279
|
-
"-o",
|
|
280
|
-
"BatchMode=yes",
|
|
281
|
-
"-o",
|
|
282
|
-
"ControlMaster=no",
|
|
283
|
-
"-o",
|
|
284
|
-
`ControlPath=${CONTROL_PATH}`,
|
|
285
|
-
"-o",
|
|
286
|
-
`ConnectTimeout=${CONNECT_TIMEOUT_S}`
|
|
287
|
-
];
|
|
288
|
-
var ssh = {
|
|
289
|
-
async exec(target, argv) {
|
|
290
|
-
const { stdout } = await execFileAsync("ssh", [...SSH_OPTIONS, target, ...argv], {
|
|
291
|
-
encoding: "utf8",
|
|
292
|
-
timeout: EXEC_TIMEOUT_MS
|
|
293
|
-
});
|
|
294
|
-
return stdout;
|
|
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";
|
|
295
320
|
}
|
|
321
|
+
path;
|
|
296
322
|
};
|
|
297
|
-
function
|
|
298
|
-
|
|
299
|
-
execFileSync2("ssh", [...SSH_OPTIONS, "-O", "check", target], { stdio: "ignore" });
|
|
300
|
-
return true;
|
|
301
|
-
} catch {
|
|
302
|
-
return false;
|
|
303
|
-
}
|
|
323
|
+
function fail(path, detail) {
|
|
324
|
+
throw new SnapshotInvalidError(path, detail);
|
|
304
325
|
}
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
// src/fold.ts
|
|
310
|
-
function foldAgent(events, isAlive) {
|
|
311
|
-
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
312
|
-
const event = events[index];
|
|
313
|
-
if (!event) continue;
|
|
314
|
-
switch (event.state) {
|
|
315
|
-
case "blocked":
|
|
316
|
-
case "done":
|
|
317
|
-
case "crashed":
|
|
318
|
-
return { state: event.state, event };
|
|
319
|
-
case "cleared":
|
|
320
|
-
return { state: null, event: null };
|
|
321
|
-
case "working":
|
|
322
|
-
return {
|
|
323
|
-
state: event.pid !== null && event.pid > 0 && isAlive(event.pid) ? "working" : "crashed",
|
|
324
|
-
event
|
|
325
|
-
};
|
|
326
|
-
}
|
|
326
|
+
function object(value, path, keys) {
|
|
327
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
328
|
+
fail(path, "expected an object");
|
|
327
329
|
}
|
|
328
|
-
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
for (const event of events) {
|
|
333
|
-
const agentEvents = byAgent.get(event.agent_id);
|
|
334
|
-
if (agentEvents) agentEvents.push(event);
|
|
335
|
-
else byAgent.set(event.agent_id, [event]);
|
|
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}`);
|
|
336
334
|
}
|
|
337
|
-
return
|
|
338
|
-
const folded = foldAgent(agentEvents, isAlive);
|
|
339
|
-
const source = folded.event ?? agentEvents[agentEvents.length - 1];
|
|
340
|
-
if (!source) throw new Error("agent event group cannot be empty");
|
|
341
|
-
return {
|
|
342
|
-
agent_id: source.agent_id,
|
|
343
|
-
host_id: source.host_id,
|
|
344
|
-
state: folded.state,
|
|
345
|
-
event: folded.event,
|
|
346
|
-
workstream: source.workstream,
|
|
347
|
-
role: source.role,
|
|
348
|
-
cli: source.cli,
|
|
349
|
-
driver: source.driver ?? DEFAULT_DRIVER,
|
|
350
|
-
session: source.session,
|
|
351
|
-
window: source.window,
|
|
352
|
-
pane: source.pane,
|
|
353
|
-
session_name: source.session_name,
|
|
354
|
-
window_name: source.window_name,
|
|
355
|
-
agent_name: source.agent_name,
|
|
356
|
-
pi_session: source.pi_session,
|
|
357
|
-
fetched_at: null
|
|
358
|
-
};
|
|
359
|
-
});
|
|
335
|
+
return record;
|
|
360
336
|
}
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
crashed: 2,
|
|
365
|
-
working: 3,
|
|
366
|
-
cleared: 4
|
|
367
|
-
};
|
|
368
|
-
function attentionSort(views) {
|
|
369
|
-
return [...views].sort((left, right) => {
|
|
370
|
-
const stateOrder = (left.state === null ? 4 : ATTENTION_ORDER[left.state]) - (right.state === null ? 4 : ATTENTION_ORDER[right.state]);
|
|
371
|
-
if (stateOrder !== 0) return stateOrder;
|
|
372
|
-
return (right.event?.ts ?? 0) - (left.event?.ts ?? 0);
|
|
373
|
-
});
|
|
337
|
+
function text(value, path) {
|
|
338
|
+
if (typeof value !== "string" || value === "") fail(path, "expected a non-empty string");
|
|
339
|
+
return value;
|
|
374
340
|
}
|
|
375
|
-
function
|
|
376
|
-
|
|
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;
|
|
377
345
|
}
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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",
|
|
382
367
|
"host_id",
|
|
383
|
-
"
|
|
384
|
-
"
|
|
385
|
-
"
|
|
368
|
+
"display_name",
|
|
369
|
+
"murmur_version",
|
|
370
|
+
"generated_at",
|
|
371
|
+
"panes"
|
|
372
|
+
];
|
|
373
|
+
var PANE_KEYS = [
|
|
374
|
+
"pane",
|
|
386
375
|
"session",
|
|
387
376
|
"window",
|
|
388
|
-
"pane",
|
|
389
377
|
"session_name",
|
|
390
378
|
"window_name",
|
|
379
|
+
"agent",
|
|
380
|
+
"attention"
|
|
381
|
+
];
|
|
382
|
+
var AGENT_KEYS = [
|
|
383
|
+
"agent_id",
|
|
384
|
+
"activity",
|
|
391
385
|
"agent_name",
|
|
392
386
|
"pi_session",
|
|
393
387
|
"workstream",
|
|
394
388
|
"role",
|
|
395
389
|
"cli",
|
|
396
390
|
"driver",
|
|
397
|
-
"
|
|
398
|
-
"
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
function eventToWire(event) {
|
|
405
|
-
const { extra, ...known } = event;
|
|
406
|
-
return { ...extra, ...known };
|
|
407
|
-
}
|
|
408
|
-
function eventFromWire(wire) {
|
|
409
|
-
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);
|
|
410
398
|
return {
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
pi_session: wire.pi_session ?? null,
|
|
422
|
-
workstream: wire.workstream ?? null,
|
|
423
|
-
role: wire.role ?? null,
|
|
424
|
-
cli: wire.cli ?? null,
|
|
425
|
-
driver: wire.driver ?? null,
|
|
426
|
-
kind: wire.kind,
|
|
427
|
-
state: wire.state,
|
|
428
|
-
message: wire.message,
|
|
429
|
-
pid: wire.pid ?? null,
|
|
430
|
-
synthetic: wire.synthetic,
|
|
431
|
-
reason: wire.reason,
|
|
432
|
-
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`)
|
|
433
409
|
};
|
|
434
410
|
}
|
|
435
|
-
function
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
const
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
}
|
|
450
|
-
}
|
|
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
|
+
});
|
|
451
427
|
}
|
|
452
|
-
function
|
|
453
|
-
|
|
454
|
-
const
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
if (!previous || event.seq > previous.seq) newest.set(event.agent_id, event);
|
|
459
|
-
}
|
|
460
|
-
for (const event of newest.values()) {
|
|
461
|
-
if (event.state === "cleared") continue;
|
|
462
|
-
if (live.has(event.window)) continue;
|
|
463
|
-
const { host_id: _hostId, seq: _seq, ts: _ts, ...rest } = event;
|
|
464
|
-
store.append({
|
|
465
|
-
...rest,
|
|
466
|
-
state: "cleared",
|
|
467
|
-
synthetic: true,
|
|
468
|
-
reason: "window_gone",
|
|
469
|
-
message: ""
|
|
470
|
-
});
|
|
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");
|
|
471
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
|
+
};
|
|
472
444
|
}
|
|
473
|
-
function
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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
|
|
482
470
|
};
|
|
483
|
-
const lines = [
|
|
484
|
-
JSON.stringify(envelope),
|
|
485
|
-
...store.eventsSince(identity.host_id, since).map((event) => JSON.stringify(eventToWire(event)))
|
|
486
|
-
];
|
|
487
|
-
return `${lines.join("\n")}
|
|
488
|
-
`;
|
|
489
471
|
}
|
|
490
472
|
|
|
491
|
-
// src/
|
|
492
|
-
var
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
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;
|
|
501
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;
|
|
505
|
+
}
|
|
506
|
+
return newest;
|
|
507
|
+
}
|
|
508
|
+
function paneView(pane, source) {
|
|
509
|
+
const agent = pane.agent;
|
|
502
510
|
return {
|
|
503
|
-
|
|
504
|
-
|
|
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
|
|
505
532
|
};
|
|
506
533
|
}
|
|
507
|
-
|
|
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
|
+
});
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// src/collector.ts
|
|
577
|
+
var MAX_CONCURRENT_PEERS = 8;
|
|
578
|
+
var COLLECT_DEADLINE_MS = 4e3;
|
|
579
|
+
async function mapSettled(items, limit, task, deadline) {
|
|
580
|
+
const results = new Array(items.length);
|
|
581
|
+
let cursor = 0;
|
|
582
|
+
let expired = false;
|
|
583
|
+
const stop = deadline?.then(() => {
|
|
584
|
+
expired = true;
|
|
585
|
+
});
|
|
586
|
+
const worker = async () => {
|
|
587
|
+
while (cursor < items.length && !expired) {
|
|
588
|
+
const index = cursor++;
|
|
589
|
+
try {
|
|
590
|
+
results[index] = { status: "fulfilled", value: await task(items[index]) };
|
|
591
|
+
} catch (reason) {
|
|
592
|
+
results[index] = { status: "rejected", reason };
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
};
|
|
596
|
+
const pool = Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
|
597
|
+
await (stop ? Promise.race([pool, stop]) : pool);
|
|
598
|
+
return results;
|
|
599
|
+
}
|
|
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();
|
|
613
|
+
}
|
|
614
|
+
async function collect(store, channel, now = Date.now(), deadline) {
|
|
508
615
|
const results = [];
|
|
616
|
+
let timer;
|
|
509
617
|
try {
|
|
510
|
-
|
|
618
|
+
const peers = store.peers();
|
|
619
|
+
const bounded = deadline ?? new Promise((resolve) => {
|
|
620
|
+
timer = setTimeout(resolve, COLLECT_DEADLINE_MS);
|
|
621
|
+
timer.unref?.();
|
|
622
|
+
});
|
|
623
|
+
const fetches = await mapSettled(
|
|
624
|
+
peers,
|
|
625
|
+
MAX_CONCURRENT_PEERS,
|
|
626
|
+
async (peer) => parseSnapshot(await channel.exec(peer.target, ["murmur", "export"])),
|
|
627
|
+
bounded
|
|
628
|
+
);
|
|
629
|
+
for (const [index, peer] of peers.entries()) {
|
|
630
|
+
const fetch = fetches[index];
|
|
511
631
|
try {
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
String(peer.watermark)
|
|
517
|
-
]);
|
|
518
|
-
const { envelope, events } = parseJsonl(output);
|
|
519
|
-
const ingested = store.ingest(events);
|
|
520
|
-
const watermark = events.filter((event) => event.host_id === envelope.host_id).reduce((highest, event) => Math.max(highest, event.seq), peer.watermark);
|
|
521
|
-
store.upsertPeer({
|
|
522
|
-
name: peer.name,
|
|
523
|
-
target: peer.target,
|
|
524
|
-
host_id: envelope.host_id,
|
|
525
|
-
display_name: envelope.display_name,
|
|
526
|
-
watermark,
|
|
527
|
-
fetched_at: now,
|
|
528
|
-
// New events mean the node is authoring again, so whatever a jump
|
|
529
|
-
// observed about its tmux is out of date. Only clear on actual new
|
|
530
|
-
// events: an export that returns nothing proves the binary ran, not
|
|
531
|
-
// that tmux is back, which is the distinction that let a dead host
|
|
532
|
-
// look healthy for three hours.
|
|
533
|
-
tmux_down_at: ingested > 0 ? null : peer.tmux_down_at
|
|
534
|
-
});
|
|
535
|
-
store.prune();
|
|
536
|
-
results.push({ peer: peer.name, ok: true, ingested });
|
|
632
|
+
if (!fetch) throw new Error("collect deadline passed before this peer answered");
|
|
633
|
+
if (fetch.status === "rejected") throw fetch.reason;
|
|
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 });
|
|
537
636
|
} catch (error) {
|
|
538
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
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
|
+
});
|
|
542
648
|
}
|
|
543
649
|
}
|
|
544
650
|
} catch (error) {
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
651
|
+
results.push({
|
|
652
|
+
peer: "",
|
|
653
|
+
ok: false,
|
|
654
|
+
panes: 0,
|
|
655
|
+
error: error instanceof Error ? error.message : String(error)
|
|
656
|
+
});
|
|
657
|
+
} finally {
|
|
658
|
+
clearTimeout(timer);
|
|
659
|
+
}
|
|
660
|
+
try {
|
|
661
|
+
store.reconcileLocal({ panes: tmux.livePanes(), now });
|
|
662
|
+
} catch {
|
|
549
663
|
}
|
|
550
664
|
return results;
|
|
551
665
|
}
|
|
@@ -553,18 +667,8 @@ async function collect(store, channel, now = Date.now()) {
|
|
|
553
667
|
// src/glance.ts
|
|
554
668
|
import { execFileSync as execFileSync3 } from "child_process";
|
|
555
669
|
var GLANCE_LINES = 40;
|
|
556
|
-
var SSH_OPTIONS2 = [
|
|
557
|
-
"-o",
|
|
558
|
-
"BatchMode=yes",
|
|
559
|
-
"-o",
|
|
560
|
-
"ControlMaster=no",
|
|
561
|
-
"-o",
|
|
562
|
-
"ControlPath=~/.ssh/control/%r@%h:%p",
|
|
563
|
-
"-o",
|
|
564
|
-
"ConnectTimeout=2"
|
|
565
|
-
];
|
|
566
670
|
function glance(store, agent, lines = GLANCE_LINES) {
|
|
567
|
-
if (agent.
|
|
671
|
+
if (agent.local) return tmux.capture(agent.pane, lines);
|
|
568
672
|
const peer = store.peers().find((candidate) => candidate.host_id === agent.host_id);
|
|
569
673
|
const target = peer?.target ?? peer?.name;
|
|
570
674
|
if (!target) return null;
|
|
@@ -572,7 +676,7 @@ function glance(store, agent, lines = GLANCE_LINES) {
|
|
|
572
676
|
return execFileSync3(
|
|
573
677
|
"ssh",
|
|
574
678
|
[
|
|
575
|
-
...
|
|
679
|
+
...SSH_OPTIONS,
|
|
576
680
|
target,
|
|
577
681
|
"tmux",
|
|
578
682
|
"capture-pane",
|
|
@@ -589,329 +693,554 @@ function glance(store, agent, lines = GLANCE_LINES) {
|
|
|
589
693
|
}
|
|
590
694
|
}
|
|
591
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
|
+
|
|
592
745
|
// src/status.ts
|
|
593
746
|
function emptyCounts() {
|
|
594
|
-
|
|
747
|
+
const counts = {};
|
|
748
|
+
for (const state of RENDER_PRIORITY) counts[state] = 0;
|
|
749
|
+
return counts;
|
|
595
750
|
}
|
|
596
|
-
function
|
|
597
|
-
const
|
|
598
|
-
const
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
const local = foldAll(
|
|
604
|
-
events.filter((event) => event.host_id === identity?.host_id),
|
|
605
|
-
pidAlive
|
|
606
|
-
);
|
|
607
|
-
const remote = foldAll(
|
|
608
|
-
events.filter((event) => event.host_id !== identity?.host_id),
|
|
609
|
-
() => true
|
|
610
|
-
);
|
|
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()) {
|
|
611
758
|
const counts = emptyCounts();
|
|
612
759
|
const orchestratedCounts = emptyCounts();
|
|
613
|
-
const
|
|
614
|
-
|
|
615
|
-
const
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
target[state] += 1;
|
|
619
|
-
return {
|
|
620
|
-
...agent,
|
|
621
|
-
fetched_at: fetchedAt,
|
|
622
|
-
// Replica freshness: how long since we last reached the peer. Local rows
|
|
623
|
-
// have no fetched_at and are never stale.
|
|
624
|
-
stale: isStale(fetchedAt, now, STALENESS_MS),
|
|
625
|
-
age_ms: fetchedAt === null ? null : now - fetchedAt,
|
|
626
|
-
// Information age: how long since the agent itself said anything. This
|
|
627
|
-
// is the number a human means by "how stale is that row". A successful
|
|
628
|
-
// fetch of a three-hour-old event resets age_ms to zero but leaves this
|
|
629
|
-
// at three hours, which is why they cannot be the same field.
|
|
630
|
-
event_age_ms: agent.event === null ? null : Math.max(0, now - agent.event.ts),
|
|
631
|
-
// A jump proved this host's tmux was down and nothing has authored since.
|
|
632
|
-
// Stronger than staleness: the host answers, its agents are just gone.
|
|
633
|
-
tmux_down: peer?.tmux_down_at != null,
|
|
634
|
-
// The name the human typed, not the machine's self-reported hostname. A
|
|
635
|
-
// peer added as `linuxpc` reported `18c04d69b860` (a container hostname)
|
|
636
|
-
// and that is what the picker showed — a string that appears nowhere
|
|
637
|
-
// else in the tool and cannot be typed at `peer remove` or searched for.
|
|
638
|
-
// Only the local node, which has no peer row, falls back to its own
|
|
639
|
-
// discovered display_name.
|
|
640
|
-
host: peer?.name ?? (agent.host_id === identity?.host_id ? identity.display_name : agent.host_id)
|
|
641
|
-
};
|
|
642
|
-
});
|
|
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
|
+
}
|
|
643
765
|
return {
|
|
644
766
|
counts,
|
|
645
767
|
orchestrated_counts: orchestratedCounts,
|
|
646
|
-
|
|
647
|
-
peers: peers.map((peer) => ({
|
|
768
|
+
panes,
|
|
769
|
+
peers: store.peers().map((peer) => ({
|
|
648
770
|
name: peer.name,
|
|
649
771
|
display_name: peer.display_name,
|
|
650
772
|
fetched_at: peer.fetched_at,
|
|
651
|
-
//
|
|
652
|
-
//
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
//
|
|
656
|
-
|
|
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"
|
|
657
783
|
}))
|
|
658
784
|
};
|
|
659
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
|
+
}
|
|
660
793
|
|
|
661
794
|
// src/store.ts
|
|
662
|
-
import {
|
|
795
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
796
|
+
import { mkdirSync as mkdirSync2, rmSync } from "fs";
|
|
797
|
+
import { dirname } from "path";
|
|
663
798
|
import Database from "better-sqlite3";
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
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) {
|
|
668
864
|
try {
|
|
669
865
|
const existing = new Database(path, { fileMustExist: true });
|
|
670
|
-
|
|
671
|
-
|
|
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 {
|
|
672
873
|
existing.close();
|
|
673
|
-
return salvaged;
|
|
674
874
|
}
|
|
875
|
+
} catch {
|
|
876
|
+
return [];
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
function needsReset(path) {
|
|
880
|
+
try {
|
|
881
|
+
const existing = new Database(path, { fileMustExist: true });
|
|
675
882
|
try {
|
|
676
|
-
|
|
677
|
-
}
|
|
883
|
+
return (existing.pragma("user_version", { simple: true }) ?? 0) !== SCHEMA_USER_VERSION;
|
|
884
|
+
} finally {
|
|
885
|
+
existing.close();
|
|
678
886
|
}
|
|
679
|
-
existing.close();
|
|
680
887
|
} catch {
|
|
681
|
-
return
|
|
682
|
-
}
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
event.window,
|
|
694
|
-
event.pane,
|
|
695
|
-
event.session_name,
|
|
696
|
-
event.window_name,
|
|
697
|
-
event.agent_name,
|
|
698
|
-
event.pi_session,
|
|
699
|
-
event.workstream,
|
|
700
|
-
event.role,
|
|
701
|
-
event.cli,
|
|
702
|
-
event.driver,
|
|
703
|
-
event.kind,
|
|
704
|
-
event.state,
|
|
705
|
-
event.message,
|
|
706
|
-
event.pid,
|
|
707
|
-
Number(event.synthetic),
|
|
708
|
-
event.reason,
|
|
709
|
-
JSON.stringify(event.extra)
|
|
710
|
-
];
|
|
711
|
-
}
|
|
712
|
-
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) {
|
|
713
900
|
return {
|
|
714
|
-
|
|
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,
|
|
715
908
|
driver: row.driver,
|
|
716
|
-
|
|
717
|
-
|
|
909
|
+
claimed_at: row.claimed_at,
|
|
910
|
+
updated_at: row.updated_at
|
|
718
911
|
};
|
|
719
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
|
+
}
|
|
720
917
|
function openStore() {
|
|
721
|
-
const identity = ensureIdentity();
|
|
722
918
|
const path = dbPath();
|
|
723
|
-
|
|
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
|
+
}
|
|
724
924
|
const database = new Database(path);
|
|
725
925
|
database.pragma("journal_mode = WAL");
|
|
726
|
-
database.pragma(
|
|
727
|
-
database.
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
session TEXT NOT NULL,
|
|
734
|
-
window TEXT NOT NULL,
|
|
735
|
-
pane TEXT NOT NULL,
|
|
736
|
-
session_name TEXT,
|
|
737
|
-
window_name TEXT,
|
|
738
|
-
agent_name TEXT,
|
|
739
|
-
pi_session TEXT,
|
|
740
|
-
workstream TEXT,
|
|
741
|
-
role TEXT,
|
|
742
|
-
cli TEXT,
|
|
743
|
-
driver TEXT,
|
|
744
|
-
kind TEXT NOT NULL,
|
|
745
|
-
state TEXT NOT NULL,
|
|
746
|
-
message TEXT NOT NULL,
|
|
747
|
-
pid INTEGER,
|
|
748
|
-
synthetic INTEGER NOT NULL,
|
|
749
|
-
reason TEXT NOT NULL,
|
|
750
|
-
extra TEXT NOT NULL,
|
|
751
|
-
PRIMARY KEY (host_id, seq)
|
|
752
|
-
);
|
|
753
|
-
CREATE INDEX IF NOT EXISTS events_agent_seq ON events (agent_id, seq);
|
|
754
|
-
CREATE TABLE IF NOT EXISTS peers (
|
|
755
|
-
name TEXT PRIMARY KEY,
|
|
756
|
-
target TEXT NOT NULL,
|
|
757
|
-
host_id TEXT,
|
|
758
|
-
display_name TEXT,
|
|
759
|
-
watermark INTEGER NOT NULL,
|
|
760
|
-
fetched_at INTEGER,
|
|
761
|
-
-- When a jump last proved this peer's tmux was not answering. Reader
|
|
762
|
-
-- state, not an event: this node cannot author facts about another
|
|
763
|
-
-- node's agents, and a jump is a local observation, not something the
|
|
764
|
-
-- peer said. Cleared by the next successful collect.
|
|
765
|
-
tmux_down_at INTEGER
|
|
766
|
-
);
|
|
767
|
-
`);
|
|
768
|
-
try {
|
|
769
|
-
database.exec("ALTER TABLE peers ADD COLUMN tmux_down_at INTEGER");
|
|
770
|
-
} catch {
|
|
771
|
-
}
|
|
772
|
-
if (salvagedPeers.length > 0) {
|
|
773
|
-
const restore = database.prepare(
|
|
774
|
-
`INSERT OR IGNORE INTO peers (name, target, host_id, display_name, watermark, fetched_at)
|
|
775
|
-
VALUES (?, ?, ?, ?, 0, NULL)`
|
|
776
|
-
);
|
|
777
|
-
for (const peer of salvagedPeers) {
|
|
778
|
-
restore.run(peer.name, peer.target, peer.host_id ?? null, peer.display_name ?? null);
|
|
779
|
-
}
|
|
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);
|
|
780
933
|
}
|
|
781
|
-
const
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
);
|
|
790
|
-
const
|
|
791
|
-
|
|
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 = ?"
|
|
792
962
|
);
|
|
793
|
-
const
|
|
794
|
-
|
|
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 = ?"
|
|
795
980
|
);
|
|
796
|
-
const
|
|
797
|
-
const
|
|
798
|
-
const
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
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
|
|
807
1000
|
};
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
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));
|
|
815
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
|
+
}
|
|
816
1111
|
return {
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
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;
|
|
826
1125
|
},
|
|
827
|
-
|
|
828
|
-
return
|
|
1126
|
+
releaseAgent(release) {
|
|
1127
|
+
return deleteAgentOwned.run(release.agent_id, release.owner_pid).changes === 1;
|
|
829
1128
|
},
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
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
|
+
});
|
|
842
1141
|
},
|
|
843
|
-
|
|
844
|
-
return
|
|
1142
|
+
acknowledgePane(pane) {
|
|
1143
|
+
return deleteAttentionForPane.run(pane).changes;
|
|
845
1144
|
},
|
|
846
|
-
|
|
847
|
-
return
|
|
1145
|
+
localPanes() {
|
|
1146
|
+
return readLocalPanes();
|
|
848
1147
|
},
|
|
849
|
-
|
|
850
|
-
|
|
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
|
+
};
|
|
851
1165
|
},
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
INSERT INTO peers (name, target, host_id, display_name, watermark, fetched_at, tmux_down_at)
|
|
856
|
-
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
857
|
-
ON CONFLICT(name) DO UPDATE SET
|
|
858
|
-
target = excluded.target,
|
|
859
|
-
host_id = excluded.host_id,
|
|
860
|
-
display_name = excluded.display_name,
|
|
861
|
-
watermark = excluded.watermark,
|
|
862
|
-
fetched_at = excluded.fetched_at,
|
|
863
|
-
tmux_down_at = excluded.tmux_down_at
|
|
864
|
-
`).run(
|
|
865
|
-
peer.name,
|
|
866
|
-
peer.target,
|
|
867
|
-
peer.host_id !== void 0 ? peer.host_id : current?.host_id ?? null,
|
|
868
|
-
peer.display_name !== void 0 ? peer.display_name : current?.display_name ?? null,
|
|
869
|
-
peer.watermark !== void 0 ? peer.watermark : current?.watermark ?? 0,
|
|
870
|
-
peer.fetched_at !== void 0 ? peer.fetched_at : current?.fetched_at ?? null,
|
|
871
|
-
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
|
|
872
1169
|
);
|
|
873
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
|
+
},
|
|
874
1177
|
removePeer(name) {
|
|
875
1178
|
return database.prepare("DELETE FROM peers WHERE name = ?").run(name).changes > 0;
|
|
876
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
|
+
},
|
|
877
1203
|
close() {
|
|
878
1204
|
database.close();
|
|
879
1205
|
}
|
|
880
1206
|
};
|
|
881
1207
|
}
|
|
882
|
-
|
|
883
|
-
// src/index.ts
|
|
884
|
-
var manifest = createRequire(import.meta.url)("../package.json");
|
|
885
|
-
var VERSION = manifest.version;
|
|
886
1208
|
export {
|
|
887
|
-
COLLECT_INTERVAL_MS,
|
|
888
1209
|
DEFAULT_DRIVER,
|
|
889
|
-
|
|
1210
|
+
MAX_CONCURRENT_PEERS,
|
|
1211
|
+
NEEDS_HUMAN,
|
|
1212
|
+
RENDER_PRIORITY,
|
|
890
1213
|
STALENESS_MS,
|
|
891
|
-
|
|
892
|
-
VERSION,
|
|
1214
|
+
SnapshotInvalidError,
|
|
1215
|
+
MURMUR_VERSION as VERSION,
|
|
1216
|
+
age,
|
|
893
1217
|
agentLabel,
|
|
894
1218
|
agentLocation,
|
|
895
|
-
|
|
1219
|
+
asPaneId,
|
|
1220
|
+
asSessionId,
|
|
1221
|
+
asWindowId,
|
|
896
1222
|
collect,
|
|
897
1223
|
configDir,
|
|
1224
|
+
createIdentity,
|
|
898
1225
|
dbPath,
|
|
899
|
-
|
|
900
|
-
eventFromWire,
|
|
901
|
-
exportJsonl,
|
|
902
|
-
foldAgent,
|
|
903
|
-
foldAll,
|
|
1226
|
+
freshness,
|
|
904
1227
|
glance,
|
|
905
1228
|
hasWarmSocket,
|
|
906
|
-
isStale,
|
|
907
1229
|
jumpToAgent,
|
|
908
1230
|
loadIdentity,
|
|
909
1231
|
openStore,
|
|
1232
|
+
paneViews,
|
|
1233
|
+
parseSnapshot,
|
|
910
1234
|
pidAlive,
|
|
1235
|
+
renderState,
|
|
1236
|
+
setDisplayName,
|
|
911
1237
|
shellQuote,
|
|
912
1238
|
ssh,
|
|
913
1239
|
stateDir,
|
|
914
1240
|
status,
|
|
915
|
-
|
|
1241
|
+
statusWithCollect,
|
|
1242
|
+
tmux,
|
|
1243
|
+
tmuxStatus,
|
|
1244
|
+
viewSort
|
|
916
1245
|
};
|
|
917
1246
|
//# sourceMappingURL=index.js.map
|