@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/cli.js
CHANGED
|
@@ -3,38 +3,15 @@
|
|
|
3
3
|
// src/cli.ts
|
|
4
4
|
import { Command } from "commander";
|
|
5
5
|
|
|
6
|
-
// src/
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
// src/identity.ts
|
|
10
|
-
import { randomUUID } from "crypto";
|
|
11
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
12
|
-
import { hostname } from "os";
|
|
13
|
-
import { join as join2 } from "path";
|
|
14
|
-
|
|
15
|
-
// src/paths.ts
|
|
16
|
-
import { homedir } from "os";
|
|
17
|
-
import { join } from "path";
|
|
18
|
-
function stateDir() {
|
|
19
|
-
return process.env.MURMUR_STATE_DIR ?? join(process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state"), "murmur");
|
|
20
|
-
}
|
|
21
|
-
function dbPath() {
|
|
22
|
-
return join(stateDir(), "events.db");
|
|
6
|
+
// src/ids.ts
|
|
7
|
+
function asSessionId(raw) {
|
|
8
|
+
return raw;
|
|
23
9
|
}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
function loadIdentity() {
|
|
27
|
-
const path = join2(stateDir(), "identity.json");
|
|
28
|
-
return existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) : null;
|
|
10
|
+
function asWindowId(raw) {
|
|
11
|
+
return raw;
|
|
29
12
|
}
|
|
30
|
-
function
|
|
31
|
-
|
|
32
|
-
if (existing) return existing;
|
|
33
|
-
const identity = { host_id: randomUUID(), display_name: displayName };
|
|
34
|
-
mkdirSync(stateDir(), { recursive: true });
|
|
35
|
-
writeFileSync(join2(stateDir(), "identity.json"), `${JSON.stringify(identity, null, 2)}
|
|
36
|
-
`);
|
|
37
|
-
return identity;
|
|
13
|
+
function asPaneId(raw) {
|
|
14
|
+
return raw;
|
|
38
15
|
}
|
|
39
16
|
|
|
40
17
|
// src/mux.ts
|
|
@@ -50,10 +27,20 @@ function runTmux(args) {
|
|
|
50
27
|
return null;
|
|
51
28
|
}
|
|
52
29
|
}
|
|
30
|
+
function exactSession(session) {
|
|
31
|
+
return `=${session}`;
|
|
32
|
+
}
|
|
33
|
+
function exactPaneTarget(session) {
|
|
34
|
+
return `=${session}:`;
|
|
35
|
+
}
|
|
36
|
+
function tmuxBadgeState(state) {
|
|
37
|
+
return state === "running" ? "working" : state;
|
|
38
|
+
}
|
|
53
39
|
var tmux = {
|
|
54
40
|
currentWindow() {
|
|
55
|
-
const
|
|
56
|
-
if (!
|
|
41
|
+
const raw = process.env.TMUX_PANE;
|
|
42
|
+
if (!raw) return null;
|
|
43
|
+
const pane = asPaneId(raw);
|
|
57
44
|
const fields = runTmux([
|
|
58
45
|
"display-message",
|
|
59
46
|
"-t",
|
|
@@ -64,82 +51,84 @@ var tmux = {
|
|
|
64
51
|
const [session, window, sessionName, windowName] = fields?.split(" ") ?? [];
|
|
65
52
|
if (!session || !window) return null;
|
|
66
53
|
return {
|
|
67
|
-
session,
|
|
68
|
-
window,
|
|
54
|
+
session: asSessionId(session),
|
|
55
|
+
window: asWindowId(window),
|
|
69
56
|
pane,
|
|
70
57
|
session_name: sessionName || null,
|
|
71
58
|
window_name: windowName || null
|
|
72
59
|
};
|
|
73
60
|
},
|
|
74
|
-
// Which of this host's
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
61
|
+
// Which of this host's PANES still exist. The only liveness question tmux is
|
|
62
|
+
// ever asked, and the one that matches how an agent is addressed: a pane keeps
|
|
63
|
+
// its id when it moves between windows, so a recorded window id can be gone
|
|
64
|
+
// while the agent is very much alive.
|
|
78
65
|
//
|
|
79
|
-
// null means
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
// Unlike currentWindow, this deliberately asks tmux rather than reading the
|
|
85
|
-
// environment, and it is right to: "which windows exist on this host" is a
|
|
86
|
-
// server-wide question with one answer, and export runs over ssh with no
|
|
87
|
-
// pane of its own. currentWindow asks "which pane am I in", which only
|
|
88
|
-
// $TMUX_PANE can answer.
|
|
89
|
-
liveWindows() {
|
|
90
|
-
const out = runTmux(["list-windows", "-a", "-F", "#{window_id}"]);
|
|
66
|
+
// null means tmux could not answer; an empty set means it did and there are
|
|
67
|
+
// none. Conflating the two would delete every agent on the host the moment
|
|
68
|
+
// tmux was briefly unreachable.
|
|
69
|
+
livePanes() {
|
|
70
|
+
const out = runTmux(["list-panes", "-a", "-F", "#{pane_id}"]);
|
|
91
71
|
if (out === null) return null;
|
|
92
|
-
return new Set(out.split("\n").filter(Boolean));
|
|
72
|
+
return new Set(out.split("\n").filter(Boolean).map(asPaneId));
|
|
93
73
|
},
|
|
94
|
-
|
|
74
|
+
setWindowBadge(window, state) {
|
|
95
75
|
if (state === null) {
|
|
96
76
|
runTmux(["set-window-option", "-qu", "-t", window, "@agent_state"]);
|
|
97
77
|
} else {
|
|
98
|
-
runTmux(["set-window-option", "-q", "-t", window, "@agent_state", state]);
|
|
78
|
+
runTmux(["set-window-option", "-q", "-t", window, "@agent_state", tmuxBadgeState(state)]);
|
|
99
79
|
runTmux(["set-window-option", "-q", "-t", window, "@pane_agent", "1"]);
|
|
100
80
|
}
|
|
101
81
|
runTmux(["refresh-client", "-S"]);
|
|
102
82
|
},
|
|
103
83
|
attach(session, window) {
|
|
104
84
|
runTmux(["switch-client", "-t", session]);
|
|
105
|
-
runTmux(["select-window", "-t", window]);
|
|
85
|
+
return runTmux(["select-window", "-t", window]) !== null;
|
|
106
86
|
},
|
|
107
|
-
// Window ids are what the log stores, because they are stable; names are
|
|
108
|
-
// what a human recognises in a picker. Names are live tmux state, not
|
|
109
|
-
// history, so they are resolved at render time rather than recorded.
|
|
110
|
-
windowNames() {
|
|
111
|
-
const out = runTmux(["list-windows", "-a", "-F", "#{window_id} #{window_name}"]);
|
|
112
|
-
const names = /* @__PURE__ */ new Map();
|
|
113
|
-
for (const line of out?.split("\n") ?? []) {
|
|
114
|
-
const [id, name] = line.split(" ");
|
|
115
|
-
if (id && name) names.set(id, name);
|
|
116
|
-
}
|
|
117
|
-
return names;
|
|
118
|
-
},
|
|
119
|
-
// First window carrying this exact name, or null. Used to reuse a per-host
|
|
120
|
-
// ssh window instead of opening another one.
|
|
121
87
|
// Sibling panes, for deciding whether an unowned pane may clear the window's
|
|
122
88
|
// badge. A window holding an agent and a shell must not lose the badge when
|
|
123
89
|
// you focus the shell.
|
|
124
90
|
panesInWindow(window) {
|
|
125
91
|
const out = runTmux(["list-panes", "-t", window, "-F", "#{pane_id}"]);
|
|
126
|
-
return out?.split("\n").filter(Boolean) ?? [];
|
|
92
|
+
return out?.split("\n").filter(Boolean).map(asPaneId) ?? [];
|
|
127
93
|
},
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
}
|
|
134
|
-
return null;
|
|
94
|
+
// Which client to send home when the remote attach exits. `switch-client`
|
|
95
|
+
// with no -c moves whichever client tmux considers current, and `murmur pick`
|
|
96
|
+
// usually runs in a popup -- a client of its own, which dies with the popup.
|
|
97
|
+
// Naming the real client is what lets the return outlive the picker.
|
|
98
|
+
clientName() {
|
|
99
|
+
return runTmux(["display-message", "-p", "#{client_name}"]) || null;
|
|
135
100
|
},
|
|
136
|
-
|
|
137
|
-
|
|
101
|
+
// Where the jump started, as a switch-client target. Window-level, not just
|
|
102
|
+
// the session: coming back to the right session but the wrong window is
|
|
103
|
+
// still the wrong place. The window id is stable where its index is not,
|
|
104
|
+
// since renumber-windows renumbers on every close.
|
|
105
|
+
currentTarget() {
|
|
106
|
+
return runTmux(["display-message", "-p", "#{session_name}:#{window_id}"]) || null;
|
|
138
107
|
},
|
|
139
|
-
//
|
|
108
|
+
// Whether a wrapper session for this host already exists. Deliberately not
|
|
109
|
+
// returning an id: a session is addressed by name, so a `#{session_id}` would
|
|
110
|
+
// only have to be turned back into one.
|
|
111
|
+
sessionNamed(name) {
|
|
112
|
+
const out = runTmux(["list-sessions", "-F", "#{session_name}"]);
|
|
113
|
+
if (out === null) return false;
|
|
114
|
+
return out.split("\n").includes(name);
|
|
115
|
+
},
|
|
116
|
+
newSession(name, command) {
|
|
117
|
+
return runTmux(["new-session", "-d", "-s", name, command]) !== null;
|
|
118
|
+
},
|
|
119
|
+
setSessionOption(session, option, value) {
|
|
120
|
+
runTmux(["set-option", "-t", exactPaneTarget(session), option, value]);
|
|
121
|
+
},
|
|
122
|
+
switchClient(client, session) {
|
|
123
|
+
const target = exactSession(session);
|
|
124
|
+
const args = client ? ["switch-client", "-c", client, "-t", target] : ["switch-client", "-t", target];
|
|
125
|
+
return runTmux(args) !== null;
|
|
126
|
+
},
|
|
127
|
+
// The window a pane belongs to, for a pane murmur holds no row for. Clearing
|
|
140
128
|
// a badge is a tmux operation and does not require murmur to own the pane.
|
|
141
129
|
windowForPane(pane) {
|
|
142
|
-
|
|
130
|
+
const out = runTmux(["display-message", "-t", pane, "-p", "#{window_id}"]);
|
|
131
|
+
return out ? asWindowId(out) : null;
|
|
143
132
|
},
|
|
144
133
|
capture(pane, lines) {
|
|
145
134
|
const args = ["capture-pane", "-p", "-t", pane];
|
|
@@ -157,221 +146,527 @@ function pidAlive(pid) {
|
|
|
157
146
|
}
|
|
158
147
|
|
|
159
148
|
// src/store.ts
|
|
160
|
-
import {
|
|
149
|
+
import { randomUUID } from "crypto";
|
|
150
|
+
import { mkdirSync, rmSync } from "fs";
|
|
151
|
+
import { dirname } from "path";
|
|
161
152
|
import Database from "better-sqlite3";
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
153
|
+
|
|
154
|
+
// src/paths.ts
|
|
155
|
+
import { homedir } from "os";
|
|
156
|
+
import { join } from "path";
|
|
157
|
+
function stateDir() {
|
|
158
|
+
return process.env.MURMUR_STATE_DIR ?? join(process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state"), "murmur");
|
|
159
|
+
}
|
|
160
|
+
function dbPath() {
|
|
161
|
+
return join(stateDir(), "state.db");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// src/version.ts
|
|
165
|
+
import { createRequire } from "module";
|
|
166
|
+
function readVersion() {
|
|
167
|
+
const require2 = createRequire(import.meta.url);
|
|
168
|
+
for (const candidate of ["../package.json", "../../package.json"]) {
|
|
169
|
+
try {
|
|
170
|
+
return require2(candidate).version;
|
|
171
|
+
} catch {
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
throw new Error("cannot locate package.json to read the murmur version");
|
|
175
|
+
}
|
|
176
|
+
var MURMUR_VERSION = readVersion();
|
|
177
|
+
|
|
178
|
+
// src/types.ts
|
|
179
|
+
var DEFAULT_DRIVER = "human";
|
|
180
|
+
|
|
181
|
+
// src/view.ts
|
|
182
|
+
var RENDER_PRIORITY = [
|
|
183
|
+
"crashed",
|
|
184
|
+
"blocked",
|
|
185
|
+
"done",
|
|
186
|
+
"running",
|
|
187
|
+
"idle"
|
|
188
|
+
];
|
|
189
|
+
var NEEDS_HUMAN = ["blocked", "crashed"];
|
|
190
|
+
var STALENESS_MS = 6e4;
|
|
191
|
+
function age(ms) {
|
|
192
|
+
if (ms === null || ms < 6e4) return "";
|
|
193
|
+
if (ms < 36e5) return `${Math.floor(ms / 6e4)}m`;
|
|
194
|
+
if (ms < 864e5) return `${Math.floor(ms / 36e5)}h`;
|
|
195
|
+
return `${Math.floor(ms / 864e5)}d`;
|
|
196
|
+
}
|
|
197
|
+
function freshness(fetchedAt, now, thresholdMs = STALENESS_MS) {
|
|
198
|
+
return fetchedAt !== null && now - fetchedAt <= thresholdMs ? "fresh" : "stale";
|
|
199
|
+
}
|
|
200
|
+
function renderState(view) {
|
|
201
|
+
for (const kind of ["crashed", "blocked", "done"]) {
|
|
202
|
+
if (view.attention.includes(kind)) return kind;
|
|
203
|
+
}
|
|
204
|
+
return view.activity === "running" ? "running" : "idle";
|
|
205
|
+
}
|
|
206
|
+
function newestAttention(pane) {
|
|
207
|
+
let newest = null;
|
|
208
|
+
for (const entry of pane.attention) {
|
|
209
|
+
if (newest === null || entry.requested_at > newest) newest = entry.requested_at;
|
|
210
|
+
}
|
|
211
|
+
return newest;
|
|
212
|
+
}
|
|
213
|
+
function paneView(pane, source) {
|
|
214
|
+
const agent = pane.agent;
|
|
215
|
+
return {
|
|
216
|
+
host_id: source.host_id,
|
|
217
|
+
host: source.host,
|
|
218
|
+
local: source.local,
|
|
219
|
+
pane: pane.pane,
|
|
220
|
+
session: pane.session,
|
|
221
|
+
window: pane.window,
|
|
222
|
+
session_name: pane.session_name,
|
|
223
|
+
window_name: pane.window_name,
|
|
224
|
+
activity: agent?.activity ?? null,
|
|
225
|
+
attention: pane.attention.map((entry) => entry.kind),
|
|
226
|
+
freshness: source.freshness,
|
|
227
|
+
agent_id: agent?.agent_id ?? null,
|
|
228
|
+
agent_name: agent?.agent_name ?? null,
|
|
229
|
+
pi_session: agent?.pi_session ?? null,
|
|
230
|
+
workstream: agent?.workstream ?? null,
|
|
231
|
+
role: agent?.role ?? null,
|
|
232
|
+
cli: agent?.cli ?? null,
|
|
233
|
+
driver: agent?.driver ?? DEFAULT_DRIVER,
|
|
234
|
+
updated_at: agent?.updated_at ?? newestAttention(pane),
|
|
235
|
+
snapshot_at: source.snapshot_at,
|
|
236
|
+
fetched_at: source.fetched_at
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
function paneViews(store, identity, now = Date.now()) {
|
|
240
|
+
const views = store.localPanes().map(
|
|
241
|
+
(pane) => paneView(pane, {
|
|
242
|
+
host_id: identity.host_id,
|
|
243
|
+
host: identity.display_name,
|
|
244
|
+
local: true,
|
|
245
|
+
// Local panes are always fresh: we are the node that authored them.
|
|
246
|
+
freshness: "fresh",
|
|
247
|
+
snapshot_at: null,
|
|
248
|
+
fetched_at: null
|
|
249
|
+
})
|
|
250
|
+
);
|
|
251
|
+
for (const peer of store.peers()) {
|
|
252
|
+
const snapshot = peer.snapshot;
|
|
253
|
+
if (!snapshot) continue;
|
|
254
|
+
const source = {
|
|
255
|
+
host_id: snapshot.host_id,
|
|
256
|
+
// The name the human typed, not the machine's self-reported hostname: a
|
|
257
|
+
// peer added as `linuxpc` can report a container id, which appears
|
|
258
|
+
// nowhere else in the tool and cannot be typed at `peer remove`.
|
|
259
|
+
host: peer.name,
|
|
260
|
+
local: false,
|
|
261
|
+
freshness: freshness(peer.fetched_at, now),
|
|
262
|
+
snapshot_at: peer.snapshot_at,
|
|
263
|
+
fetched_at: peer.fetched_at
|
|
264
|
+
};
|
|
265
|
+
for (const pane of snapshot.panes) views.push(paneView(pane, source));
|
|
266
|
+
}
|
|
267
|
+
return views;
|
|
268
|
+
}
|
|
269
|
+
var ORDER = new Map(RENDER_PRIORITY.map((state, index) => [state, index]));
|
|
270
|
+
function viewSort(views) {
|
|
271
|
+
return [...views].sort((left, right) => {
|
|
272
|
+
const byState = (ORDER.get(renderState(left)) ?? 99) - (ORDER.get(renderState(right)) ?? 99);
|
|
273
|
+
if (byState !== 0) return byState;
|
|
274
|
+
const byAge = (right.updated_at ?? 0) - (left.updated_at ?? 0);
|
|
275
|
+
if (byAge !== 0) return byAge;
|
|
276
|
+
const byHost = left.host.localeCompare(right.host);
|
|
277
|
+
return byHost !== 0 ? byHost : left.pane.localeCompare(right.pane);
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// src/store.ts
|
|
282
|
+
var SCHEMA_USER_VERSION = 3;
|
|
283
|
+
var SCHEMA = `
|
|
284
|
+
CREATE TABLE agents (
|
|
285
|
+
agent_id TEXT NOT NULL PRIMARY KEY,
|
|
286
|
+
pane TEXT NOT NULL UNIQUE,
|
|
287
|
+
owner_pid INTEGER NOT NULL CHECK (owner_pid > 0),
|
|
288
|
+
activity TEXT NOT NULL CHECK (activity IN ('running', 'stopped')),
|
|
289
|
+
session TEXT NOT NULL,
|
|
290
|
+
window TEXT NOT NULL,
|
|
291
|
+
session_name TEXT,
|
|
292
|
+
window_name TEXT,
|
|
293
|
+
agent_name TEXT,
|
|
294
|
+
pi_session TEXT,
|
|
295
|
+
workstream TEXT,
|
|
296
|
+
role TEXT,
|
|
297
|
+
cli TEXT NOT NULL,
|
|
298
|
+
driver TEXT NOT NULL CHECK (driver IN ('human', 'orchestrated')),
|
|
299
|
+
claimed_at INTEGER NOT NULL,
|
|
300
|
+
updated_at INTEGER NOT NULL
|
|
301
|
+
) STRICT;
|
|
302
|
+
|
|
303
|
+
CREATE TABLE attention (
|
|
304
|
+
pane TEXT NOT NULL,
|
|
305
|
+
kind TEXT NOT NULL CHECK (kind IN ('done', 'blocked', 'crashed')),
|
|
306
|
+
message TEXT NOT NULL,
|
|
307
|
+
source TEXT NOT NULL,
|
|
308
|
+
session TEXT NOT NULL,
|
|
309
|
+
window TEXT NOT NULL,
|
|
310
|
+
session_name TEXT,
|
|
311
|
+
window_name TEXT,
|
|
312
|
+
requested_at INTEGER NOT NULL,
|
|
313
|
+
PRIMARY KEY (pane, kind)
|
|
314
|
+
) STRICT;
|
|
315
|
+
|
|
316
|
+
CREATE TABLE peers (
|
|
317
|
+
name TEXT NOT NULL PRIMARY KEY,
|
|
318
|
+
target TEXT NOT NULL,
|
|
319
|
+
host_id TEXT,
|
|
320
|
+
display_name TEXT,
|
|
321
|
+
snapshot TEXT,
|
|
322
|
+
snapshot_at INTEGER,
|
|
323
|
+
fetched_at INTEGER,
|
|
324
|
+
last_attempt_at INTEGER,
|
|
325
|
+
last_error TEXT,
|
|
326
|
+
murmur_version TEXT,
|
|
327
|
+
snapshot_version INTEGER
|
|
328
|
+
) STRICT;
|
|
329
|
+
`;
|
|
330
|
+
function salvagePeers(path) {
|
|
166
331
|
try {
|
|
167
332
|
const existing = new Database(path, { fileMustExist: true });
|
|
168
|
-
|
|
169
|
-
|
|
333
|
+
try {
|
|
334
|
+
const version = existing.pragma("user_version", { simple: true }) ?? 0;
|
|
335
|
+
if (version === SCHEMA_USER_VERSION) return [];
|
|
336
|
+
return existing.prepare("SELECT name, target FROM peers").all();
|
|
337
|
+
} catch {
|
|
338
|
+
return [];
|
|
339
|
+
} finally {
|
|
170
340
|
existing.close();
|
|
171
|
-
return salvaged;
|
|
172
341
|
}
|
|
342
|
+
} catch {
|
|
343
|
+
return [];
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
function needsReset(path) {
|
|
347
|
+
try {
|
|
348
|
+
const existing = new Database(path, { fileMustExist: true });
|
|
173
349
|
try {
|
|
174
|
-
|
|
175
|
-
}
|
|
350
|
+
return (existing.pragma("user_version", { simple: true }) ?? 0) !== SCHEMA_USER_VERSION;
|
|
351
|
+
} finally {
|
|
352
|
+
existing.close();
|
|
176
353
|
}
|
|
177
|
-
existing.close();
|
|
178
354
|
} catch {
|
|
179
|
-
return
|
|
355
|
+
return false;
|
|
180
356
|
}
|
|
181
|
-
for (const suffix of ["", "-wal", "-shm"]) rmSync(`${path}${suffix}`, { force: true });
|
|
182
|
-
return salvaged;
|
|
183
357
|
}
|
|
184
|
-
function
|
|
185
|
-
return
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
event.window,
|
|
192
|
-
event.pane,
|
|
193
|
-
event.session_name,
|
|
194
|
-
event.window_name,
|
|
195
|
-
event.agent_name,
|
|
196
|
-
event.pi_session,
|
|
197
|
-
event.workstream,
|
|
198
|
-
event.role,
|
|
199
|
-
event.cli,
|
|
200
|
-
event.driver,
|
|
201
|
-
event.kind,
|
|
202
|
-
event.state,
|
|
203
|
-
event.message,
|
|
204
|
-
event.pid,
|
|
205
|
-
Number(event.synthetic),
|
|
206
|
-
event.reason,
|
|
207
|
-
JSON.stringify(event.extra)
|
|
208
|
-
];
|
|
358
|
+
function toAttention(row) {
|
|
359
|
+
return {
|
|
360
|
+
kind: row.kind,
|
|
361
|
+
message: row.message,
|
|
362
|
+
source: row.source,
|
|
363
|
+
requested_at: row.requested_at
|
|
364
|
+
};
|
|
209
365
|
}
|
|
210
|
-
function
|
|
366
|
+
function toAgent(row) {
|
|
211
367
|
return {
|
|
212
|
-
|
|
368
|
+
agent_id: row.agent_id,
|
|
369
|
+
activity: row.activity,
|
|
370
|
+
agent_name: row.agent_name,
|
|
371
|
+
pi_session: row.pi_session,
|
|
372
|
+
workstream: row.workstream,
|
|
373
|
+
role: row.role,
|
|
374
|
+
cli: row.cli,
|
|
213
375
|
driver: row.driver,
|
|
214
|
-
|
|
215
|
-
|
|
376
|
+
claimed_at: row.claimed_at,
|
|
377
|
+
updated_at: row.updated_at
|
|
216
378
|
};
|
|
217
379
|
}
|
|
380
|
+
var PRIORITY = new Map(RENDER_PRIORITY.map((kind, index) => [kind, index]));
|
|
381
|
+
function attentionOrder(left, right) {
|
|
382
|
+
return (PRIORITY.get(left.kind) ?? 99) - (PRIORITY.get(right.kind) ?? 99);
|
|
383
|
+
}
|
|
218
384
|
function openStore() {
|
|
219
|
-
const identity = ensureIdentity();
|
|
220
385
|
const path = dbPath();
|
|
221
|
-
|
|
386
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
387
|
+
const salvaged = salvagePeers(path);
|
|
388
|
+
if (needsReset(path)) {
|
|
389
|
+
for (const suffix of ["", "-wal", "-shm"]) rmSync(`${path}${suffix}`, { force: true });
|
|
390
|
+
}
|
|
222
391
|
const database = new Database(path);
|
|
223
392
|
database.pragma("journal_mode = WAL");
|
|
224
|
-
database.pragma(
|
|
225
|
-
database.
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
session TEXT NOT NULL,
|
|
232
|
-
window TEXT NOT NULL,
|
|
233
|
-
pane TEXT NOT NULL,
|
|
234
|
-
session_name TEXT,
|
|
235
|
-
window_name TEXT,
|
|
236
|
-
agent_name TEXT,
|
|
237
|
-
pi_session TEXT,
|
|
238
|
-
workstream TEXT,
|
|
239
|
-
role TEXT,
|
|
240
|
-
cli TEXT,
|
|
241
|
-
driver TEXT,
|
|
242
|
-
kind TEXT NOT NULL,
|
|
243
|
-
state TEXT NOT NULL,
|
|
244
|
-
message TEXT NOT NULL,
|
|
245
|
-
pid INTEGER,
|
|
246
|
-
synthetic INTEGER NOT NULL,
|
|
247
|
-
reason TEXT NOT NULL,
|
|
248
|
-
extra TEXT NOT NULL,
|
|
249
|
-
PRIMARY KEY (host_id, seq)
|
|
250
|
-
);
|
|
251
|
-
CREATE INDEX IF NOT EXISTS events_agent_seq ON events (agent_id, seq);
|
|
252
|
-
CREATE TABLE IF NOT EXISTS peers (
|
|
253
|
-
name TEXT PRIMARY KEY,
|
|
254
|
-
target TEXT NOT NULL,
|
|
255
|
-
host_id TEXT,
|
|
256
|
-
display_name TEXT,
|
|
257
|
-
watermark INTEGER NOT NULL,
|
|
258
|
-
fetched_at INTEGER,
|
|
259
|
-
-- When a jump last proved this peer's tmux was not answering. Reader
|
|
260
|
-
-- state, not an event: this node cannot author facts about another
|
|
261
|
-
-- node's agents, and a jump is a local observation, not something the
|
|
262
|
-
-- peer said. Cleared by the next successful collect.
|
|
263
|
-
tmux_down_at INTEGER
|
|
264
|
-
);
|
|
265
|
-
`);
|
|
266
|
-
try {
|
|
267
|
-
database.exec("ALTER TABLE peers ADD COLUMN tmux_down_at INTEGER");
|
|
268
|
-
} catch {
|
|
269
|
-
}
|
|
270
|
-
if (salvagedPeers.length > 0) {
|
|
271
|
-
const restore = database.prepare(
|
|
272
|
-
`INSERT OR IGNORE INTO peers (name, target, host_id, display_name, watermark, fetched_at)
|
|
273
|
-
VALUES (?, ?, ?, ?, 0, NULL)`
|
|
274
|
-
);
|
|
275
|
-
for (const peer of salvagedPeers) {
|
|
276
|
-
restore.run(peer.name, peer.target, peer.host_id ?? null, peer.display_name ?? null);
|
|
277
|
-
}
|
|
393
|
+
database.pragma("busy_timeout = 5000");
|
|
394
|
+
const version = database.pragma("user_version", { simple: true }) ?? 0;
|
|
395
|
+
if (version !== SCHEMA_USER_VERSION) {
|
|
396
|
+
database.exec(SCHEMA);
|
|
397
|
+
database.pragma(`user_version = ${SCHEMA_USER_VERSION}`);
|
|
398
|
+
const restore = database.prepare("INSERT OR IGNORE INTO peers (name, target) VALUES (?, ?)");
|
|
399
|
+
for (const peer of salvaged) restore.run(peer.name, peer.target);
|
|
278
400
|
}
|
|
279
|
-
const
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
);
|
|
288
|
-
const
|
|
289
|
-
|
|
401
|
+
const selectAgentByPane = database.prepare("SELECT * FROM agents WHERE pane = ?");
|
|
402
|
+
const insertAgent = database.prepare(`
|
|
403
|
+
INSERT INTO agents (agent_id, pane, owner_pid, activity, session, window,
|
|
404
|
+
session_name, window_name, agent_name, pi_session,
|
|
405
|
+
workstream, role, cli, driver, claimed_at, updated_at)
|
|
406
|
+
VALUES (@agent_id, @pane, @owner_pid, @activity, @session, @window,
|
|
407
|
+
@session_name, @window_name, @agent_name, @pi_session,
|
|
408
|
+
@workstream, @role, @cli, @driver, @claimed_at, @updated_at)
|
|
409
|
+
`);
|
|
410
|
+
const retainAgent = database.prepare(`
|
|
411
|
+
UPDATE agents
|
|
412
|
+
SET session = @session, window = @window, session_name = @session_name,
|
|
413
|
+
window_name = @window_name, agent_name = @agent_name,
|
|
414
|
+
pi_session = @pi_session, workstream = @workstream, role = @role,
|
|
415
|
+
cli = @cli, driver = @driver, updated_at = @updated_at
|
|
416
|
+
WHERE agent_id = @agent_id
|
|
417
|
+
`);
|
|
418
|
+
const deleteAgentByPane = database.prepare("DELETE FROM agents WHERE pane = ?");
|
|
419
|
+
const deleteAttentionForPane = database.prepare("DELETE FROM attention WHERE pane = ?");
|
|
420
|
+
const updateActivity = database.prepare(`
|
|
421
|
+
UPDATE agents
|
|
422
|
+
SET activity = @activity, session = @session, window = @window,
|
|
423
|
+
session_name = @session_name, window_name = @window_name,
|
|
424
|
+
updated_at = @updated_at
|
|
425
|
+
WHERE agent_id = @agent_id AND owner_pid = @owner_pid
|
|
426
|
+
`);
|
|
427
|
+
const deleteAgentOwned = database.prepare(
|
|
428
|
+
"DELETE FROM agents WHERE agent_id = ? AND owner_pid = ?"
|
|
290
429
|
);
|
|
291
|
-
const
|
|
292
|
-
|
|
430
|
+
const upsertAttention = database.prepare(`
|
|
431
|
+
INSERT INTO attention (pane, kind, message, source, session, window,
|
|
432
|
+
session_name, window_name, requested_at)
|
|
433
|
+
VALUES (@pane, @kind, @message, @source, @session, @window,
|
|
434
|
+
@session_name, @window_name, @requested_at)
|
|
435
|
+
ON CONFLICT (pane, kind) DO UPDATE SET
|
|
436
|
+
message = excluded.message,
|
|
437
|
+
source = excluded.source,
|
|
438
|
+
session = excluded.session,
|
|
439
|
+
window = excluded.window,
|
|
440
|
+
session_name = excluded.session_name,
|
|
441
|
+
window_name = excluded.window_name
|
|
442
|
+
`);
|
|
443
|
+
const selectAgents = database.prepare("SELECT * FROM agents");
|
|
444
|
+
const selectAttention = database.prepare("SELECT * FROM attention");
|
|
445
|
+
const setActivityByPane = database.prepare(
|
|
446
|
+
"UPDATE agents SET activity = ?, updated_at = ? WHERE pane = ?"
|
|
293
447
|
);
|
|
294
|
-
const
|
|
295
|
-
const
|
|
296
|
-
const
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
448
|
+
const claimAgent = database.transaction((claim) => {
|
|
449
|
+
const now = claim.now ?? Date.now();
|
|
450
|
+
const isAlive = claim.isAlive ?? pidAlive;
|
|
451
|
+
const { location, meta, owner_pid } = claim;
|
|
452
|
+
const incumbent = selectAgentByPane.get(location.pane);
|
|
453
|
+
const values = {
|
|
454
|
+
pane: location.pane,
|
|
455
|
+
owner_pid,
|
|
456
|
+
session: location.session,
|
|
457
|
+
window: location.window,
|
|
458
|
+
session_name: location.session_name,
|
|
459
|
+
window_name: location.window_name,
|
|
460
|
+
agent_name: meta.agent_name,
|
|
461
|
+
pi_session: meta.pi_session,
|
|
462
|
+
workstream: meta.workstream,
|
|
463
|
+
role: meta.role,
|
|
464
|
+
cli: meta.cli,
|
|
465
|
+
driver: meta.driver,
|
|
466
|
+
updated_at: now
|
|
305
467
|
};
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
468
|
+
if (!incumbent) {
|
|
469
|
+
const agentId2 = randomUUID();
|
|
470
|
+
insertAgent.run({ ...values, agent_id: agentId2, activity: "stopped", claimed_at: now });
|
|
471
|
+
return { outcome: "claimed", agent_id: agentId2 };
|
|
472
|
+
}
|
|
473
|
+
if (incumbent.owner_pid === owner_pid) {
|
|
474
|
+
retainAgent.run({ ...values, agent_id: incumbent.agent_id });
|
|
475
|
+
return { outcome: "retained", agent_id: incumbent.agent_id };
|
|
476
|
+
}
|
|
477
|
+
if (isAlive(incumbent.owner_pid)) {
|
|
478
|
+
return { outcome: "refused", held_by_pid: incumbent.owner_pid };
|
|
479
|
+
}
|
|
480
|
+
deleteAgentByPane.run(location.pane);
|
|
481
|
+
deleteAttentionForPane.run(location.pane);
|
|
482
|
+
const agentId = randomUUID();
|
|
483
|
+
insertAgent.run({ ...values, agent_id: agentId, activity: "stopped", claimed_at: now });
|
|
484
|
+
return { outcome: "replaced", agent_id: agentId, previous_agent_id: incumbent.agent_id };
|
|
485
|
+
}).immediate;
|
|
486
|
+
const reconcileLocal = database.transaction((world) => {
|
|
487
|
+
const summary = { crashed: [], removed: [], attention_removed: [] };
|
|
488
|
+
if (world.panes === null) return summary;
|
|
489
|
+
const live = world.panes;
|
|
490
|
+
const isAlive = world.isAlive ?? pidAlive;
|
|
491
|
+
const now = world.now ?? Date.now();
|
|
492
|
+
const alreadyCrashed = new Set(
|
|
493
|
+
selectAttention.all().filter((row) => row.kind === "crashed").map((row) => row.pane)
|
|
494
|
+
);
|
|
495
|
+
for (const row of selectAgents.all()) {
|
|
496
|
+
const pane = asPaneId(row.pane);
|
|
497
|
+
if (!live.has(pane)) {
|
|
498
|
+
deleteAgentByPane.run(row.pane);
|
|
499
|
+
deleteAttentionForPane.run(row.pane);
|
|
500
|
+
summary.removed.push(pane);
|
|
501
|
+
continue;
|
|
502
|
+
}
|
|
503
|
+
if (isAlive(row.owner_pid)) continue;
|
|
504
|
+
if (row.activity === "running") {
|
|
505
|
+
setActivityByPane.run("stopped", now, row.pane);
|
|
506
|
+
upsertAttention.run({
|
|
507
|
+
pane: row.pane,
|
|
508
|
+
kind: "crashed",
|
|
509
|
+
message: "",
|
|
510
|
+
source: "murmur",
|
|
511
|
+
session: row.session,
|
|
512
|
+
window: row.window,
|
|
513
|
+
session_name: row.session_name,
|
|
514
|
+
window_name: row.window_name,
|
|
515
|
+
requested_at: now
|
|
516
|
+
});
|
|
517
|
+
summary.crashed.push(pane);
|
|
518
|
+
} else if (!alreadyCrashed.has(row.pane)) {
|
|
519
|
+
deleteAgentByPane.run(row.pane);
|
|
520
|
+
summary.removed.push(pane);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
for (const row of selectAttention.all()) {
|
|
524
|
+
const pane = asPaneId(row.pane);
|
|
525
|
+
if (live.has(pane)) continue;
|
|
526
|
+
deleteAttentionForPane.run(row.pane);
|
|
527
|
+
if (!summary.attention_removed.includes(pane)) summary.attention_removed.push(pane);
|
|
528
|
+
}
|
|
529
|
+
return summary;
|
|
530
|
+
}).immediate;
|
|
531
|
+
const readLocalPanes = database.transaction(() => {
|
|
532
|
+
const agents = selectAgents.all();
|
|
533
|
+
const attention = selectAttention.all();
|
|
534
|
+
const panes = /* @__PURE__ */ new Map();
|
|
535
|
+
const locate = (row) => {
|
|
536
|
+
const existing = panes.get(row.pane);
|
|
537
|
+
if (existing) return existing;
|
|
538
|
+
const created = {
|
|
539
|
+
pane: asPaneId(row.pane),
|
|
540
|
+
session: asSessionId(row.session),
|
|
541
|
+
window: asWindowId(row.window),
|
|
542
|
+
session_name: row.session_name,
|
|
543
|
+
window_name: row.window_name,
|
|
544
|
+
agent: null,
|
|
545
|
+
attention: []
|
|
546
|
+
};
|
|
547
|
+
panes.set(row.pane, created);
|
|
548
|
+
return created;
|
|
549
|
+
};
|
|
550
|
+
for (const row of agents) locate(row).agent = toAgent(row);
|
|
551
|
+
for (const row of attention) locate(row).attention.push(toAttention(row));
|
|
552
|
+
for (const pane of panes.values()) pane.attention.sort(attentionOrder);
|
|
553
|
+
return [...panes.values()].sort((left, right) => left.pane.localeCompare(right.pane));
|
|
313
554
|
});
|
|
555
|
+
function peerRecord(row) {
|
|
556
|
+
let snapshot = null;
|
|
557
|
+
if (row.snapshot !== null) {
|
|
558
|
+
try {
|
|
559
|
+
snapshot = JSON.parse(row.snapshot);
|
|
560
|
+
} catch {
|
|
561
|
+
snapshot = null;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
return {
|
|
565
|
+
name: row.name,
|
|
566
|
+
target: row.target,
|
|
567
|
+
host_id: row.host_id,
|
|
568
|
+
display_name: row.display_name,
|
|
569
|
+
snapshot,
|
|
570
|
+
snapshot_at: row.snapshot_at,
|
|
571
|
+
fetched_at: row.fetched_at,
|
|
572
|
+
last_attempt_at: row.last_attempt_at,
|
|
573
|
+
last_error: row.last_error,
|
|
574
|
+
murmur_version: row.murmur_version,
|
|
575
|
+
snapshot_version: row.snapshot_version
|
|
576
|
+
};
|
|
577
|
+
}
|
|
314
578
|
return {
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
579
|
+
claimAgent,
|
|
580
|
+
reconcileLocal,
|
|
581
|
+
setActivity(update) {
|
|
582
|
+
return updateActivity.run({
|
|
583
|
+
activity: update.activity,
|
|
584
|
+
session: update.location.session,
|
|
585
|
+
window: update.location.window,
|
|
586
|
+
session_name: update.location.session_name,
|
|
587
|
+
window_name: update.location.window_name,
|
|
588
|
+
updated_at: update.now ?? Date.now(),
|
|
589
|
+
agent_id: update.agent_id,
|
|
590
|
+
owner_pid: update.owner_pid
|
|
591
|
+
}).changes === 1;
|
|
324
592
|
},
|
|
325
|
-
|
|
326
|
-
return
|
|
593
|
+
releaseAgent(release) {
|
|
594
|
+
return deleteAgentOwned.run(release.agent_id, release.owner_pid).changes === 1;
|
|
327
595
|
},
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
596
|
+
requestAttention(request) {
|
|
597
|
+
upsertAttention.run({
|
|
598
|
+
pane: request.location.pane,
|
|
599
|
+
kind: request.kind,
|
|
600
|
+
message: request.message,
|
|
601
|
+
source: request.source,
|
|
602
|
+
session: request.location.session,
|
|
603
|
+
window: request.location.window,
|
|
604
|
+
session_name: request.location.session_name,
|
|
605
|
+
window_name: request.location.window_name,
|
|
606
|
+
requested_at: request.now ?? Date.now()
|
|
607
|
+
});
|
|
340
608
|
},
|
|
341
|
-
|
|
342
|
-
return
|
|
609
|
+
acknowledgePane(pane) {
|
|
610
|
+
return deleteAttentionForPane.run(pane).changes;
|
|
343
611
|
},
|
|
344
|
-
|
|
345
|
-
return
|
|
612
|
+
localPanes() {
|
|
613
|
+
return readLocalPanes();
|
|
346
614
|
},
|
|
347
|
-
|
|
348
|
-
|
|
615
|
+
buildLocalSnapshot(identity, world) {
|
|
616
|
+
reconcileLocal(world);
|
|
617
|
+
return {
|
|
618
|
+
murmur_snapshot: 1,
|
|
619
|
+
host_id: identity.host_id,
|
|
620
|
+
display_name: identity.display_name,
|
|
621
|
+
murmur_version: MURMUR_VERSION,
|
|
622
|
+
generated_at: world.now ?? Date.now(),
|
|
623
|
+
// Rule 3: a pane with no agent and no attention must not be published.
|
|
624
|
+
// A no-op against today's `readLocalPanes`, which builds a pane entry
|
|
625
|
+
// only from a row and so cannot produce an empty one -- kept because the
|
|
626
|
+
// rule belongs to the DOCUMENT, and the validator rejects such an entry
|
|
627
|
+
// outright. Without it, one narrowing of the local read would make this
|
|
628
|
+
// node reachable-but-broken on every peer that collects it, and the
|
|
629
|
+
// symptom would show up on the other machines.
|
|
630
|
+
panes: readLocalPanes().filter((pane) => pane.agent !== null || pane.attention.length > 0)
|
|
631
|
+
};
|
|
349
632
|
},
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
INSERT INTO peers (name, target, host_id, display_name, watermark, fetched_at, tmux_down_at)
|
|
354
|
-
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
355
|
-
ON CONFLICT(name) DO UPDATE SET
|
|
356
|
-
target = excluded.target,
|
|
357
|
-
host_id = excluded.host_id,
|
|
358
|
-
display_name = excluded.display_name,
|
|
359
|
-
watermark = excluded.watermark,
|
|
360
|
-
fetched_at = excluded.fetched_at,
|
|
361
|
-
tmux_down_at = excluded.tmux_down_at
|
|
362
|
-
`).run(
|
|
363
|
-
peer.name,
|
|
364
|
-
peer.target,
|
|
365
|
-
peer.host_id !== void 0 ? peer.host_id : current?.host_id ?? null,
|
|
366
|
-
peer.display_name !== void 0 ? peer.display_name : current?.display_name ?? null,
|
|
367
|
-
peer.watermark !== void 0 ? peer.watermark : current?.watermark ?? 0,
|
|
368
|
-
peer.fetched_at !== void 0 ? peer.fetched_at : current?.fetched_at ?? null,
|
|
369
|
-
peer.tmux_down_at !== void 0 ? peer.tmux_down_at : current?.tmux_down_at ?? null
|
|
633
|
+
peers() {
|
|
634
|
+
return database.prepare("SELECT * FROM peers ORDER BY name").all().map(
|
|
635
|
+
peerRecord
|
|
370
636
|
);
|
|
371
637
|
},
|
|
638
|
+
addPeer(name, target) {
|
|
639
|
+
database.prepare(
|
|
640
|
+
`INSERT INTO peers (name, target) VALUES (?, ?)
|
|
641
|
+
ON CONFLICT(name) DO UPDATE SET target = excluded.target`
|
|
642
|
+
).run(name, target);
|
|
643
|
+
},
|
|
372
644
|
removePeer(name) {
|
|
373
645
|
return database.prepare("DELETE FROM peers WHERE name = ?").run(name).changes > 0;
|
|
374
646
|
},
|
|
647
|
+
replacePeerSnapshot(name, fetch) {
|
|
648
|
+
if (!fetch.ok) {
|
|
649
|
+
database.prepare("UPDATE peers SET last_attempt_at = ?, last_error = ? WHERE name = ?").run(fetch.at, fetch.error, name);
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
database.prepare(
|
|
653
|
+
`UPDATE peers
|
|
654
|
+
SET snapshot = ?, snapshot_at = ?, fetched_at = ?, last_attempt_at = ?,
|
|
655
|
+
last_error = NULL, host_id = ?, display_name = ?,
|
|
656
|
+
murmur_version = ?, snapshot_version = ?
|
|
657
|
+
WHERE name = ?`
|
|
658
|
+
).run(
|
|
659
|
+
JSON.stringify(fetch.snapshot),
|
|
660
|
+
fetch.snapshot.generated_at,
|
|
661
|
+
fetch.at,
|
|
662
|
+
fetch.at,
|
|
663
|
+
fetch.snapshot.host_id,
|
|
664
|
+
fetch.snapshot.display_name,
|
|
665
|
+
fetch.snapshot.murmur_version,
|
|
666
|
+
fetch.snapshot.murmur_snapshot,
|
|
667
|
+
name
|
|
668
|
+
);
|
|
669
|
+
},
|
|
375
670
|
close() {
|
|
376
671
|
database.close();
|
|
377
672
|
}
|
|
@@ -379,97 +674,42 @@ function openStore() {
|
|
|
379
674
|
}
|
|
380
675
|
|
|
381
676
|
// src/cli/clear.ts
|
|
382
|
-
function
|
|
383
|
-
|
|
384
|
-
const
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
`SELECT state FROM events
|
|
392
|
-
WHERE host_id = ? AND agent_id = ?
|
|
393
|
-
ORDER BY seq DESC LIMIT 1`
|
|
394
|
-
).get(hostId, `${hostId}:${sibling}`);
|
|
395
|
-
if (row && row.state !== "cleared") return true;
|
|
396
|
-
}
|
|
397
|
-
} finally {
|
|
398
|
-
database.close();
|
|
399
|
-
}
|
|
400
|
-
return false;
|
|
401
|
-
} catch {
|
|
402
|
-
return true;
|
|
403
|
-
}
|
|
677
|
+
function windowBadge(window, mux, store) {
|
|
678
|
+
const panes = new Set(mux.panesInWindow(window));
|
|
679
|
+
const states = store.localPanes().filter((pane) => panes.has(pane.pane)).map(
|
|
680
|
+
(pane) => renderState({
|
|
681
|
+
activity: pane.agent?.activity ?? null,
|
|
682
|
+
attention: pane.attention.map((entry) => entry.kind)
|
|
683
|
+
})
|
|
684
|
+
);
|
|
685
|
+
return RENDER_PRIORITY.find((state) => state !== "idle" && states.includes(state)) ?? null;
|
|
404
686
|
}
|
|
405
|
-
function clearPane(
|
|
687
|
+
function clearPane(raw, mux = tmux) {
|
|
688
|
+
let store;
|
|
406
689
|
try {
|
|
407
|
-
if (!
|
|
690
|
+
if (!raw) return;
|
|
691
|
+
const pane = asPaneId(raw);
|
|
408
692
|
const window = mux.windowForPane(pane);
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
const database = new Database2(dbPath(), { readonly: true, fileMustExist: true });
|
|
414
|
-
try {
|
|
415
|
-
owner = database.prepare(
|
|
416
|
-
`SELECT agent_id, session, window, pane, session_name, window_name,
|
|
417
|
-
agent_name, pi_session, workstream, role, cli, driver, state
|
|
418
|
-
FROM events
|
|
419
|
-
WHERE host_id = ? AND agent_id = ?
|
|
420
|
-
ORDER BY seq DESC
|
|
421
|
-
LIMIT 1`
|
|
422
|
-
).get(identity.host_id, `${identity.host_id}:${pane}`);
|
|
423
|
-
} finally {
|
|
424
|
-
database.close();
|
|
425
|
-
}
|
|
426
|
-
} catch {
|
|
427
|
-
}
|
|
428
|
-
}
|
|
429
|
-
if (!owner) {
|
|
430
|
-
if (window && !windowHasAgent(window, pane, identity?.host_id, mux)) {
|
|
431
|
-
mux.setState(window, null);
|
|
432
|
-
}
|
|
433
|
-
return;
|
|
434
|
-
}
|
|
435
|
-
if (owner.state === "cleared") {
|
|
436
|
-
mux.setState(owner.window, null);
|
|
437
|
-
return;
|
|
693
|
+
try {
|
|
694
|
+
store = openStore();
|
|
695
|
+
store.acknowledgePane(pane);
|
|
696
|
+
} catch {
|
|
438
697
|
}
|
|
439
|
-
|
|
698
|
+
if (!window) return;
|
|
440
699
|
try {
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
session: owner.session,
|
|
444
|
-
window: owner.window,
|
|
445
|
-
pane: owner.pane,
|
|
446
|
-
// Carry the names forward: a `cleared` row that drops them makes the
|
|
447
|
-
// agent's last event nameless, which is what left "@75" in the picker.
|
|
448
|
-
session_name: owner.session_name,
|
|
449
|
-
window_name: owner.window_name,
|
|
450
|
-
agent_name: owner.agent_name,
|
|
451
|
-
pi_session: owner.pi_session,
|
|
452
|
-
workstream: owner.workstream,
|
|
453
|
-
role: owner.role,
|
|
454
|
-
cli: owner.cli,
|
|
455
|
-
driver: owner.driver,
|
|
456
|
-
kind: "state",
|
|
457
|
-
state: "cleared",
|
|
458
|
-
message: "",
|
|
459
|
-
pid: null,
|
|
460
|
-
synthetic: false,
|
|
461
|
-
reason: "",
|
|
462
|
-
extra: {}
|
|
463
|
-
});
|
|
464
|
-
} finally {
|
|
465
|
-
store.close();
|
|
700
|
+
mux.setWindowBadge(window, store ? windowBadge(window, mux, store) : null);
|
|
701
|
+
} catch {
|
|
466
702
|
}
|
|
467
|
-
mux.setState(owner.window, null);
|
|
468
703
|
} catch {
|
|
704
|
+
} finally {
|
|
705
|
+
try {
|
|
706
|
+
store?.close();
|
|
707
|
+
} catch {
|
|
708
|
+
}
|
|
469
709
|
}
|
|
470
710
|
}
|
|
471
711
|
function registerClear(program2) {
|
|
472
|
-
program2.command("clear").description("
|
|
712
|
+
program2.command("clear").description("Acknowledge attention for a pane").option("--pane <pane-id>", "focused tmux pane id").action((options) => clearPane(options.pane ?? ""));
|
|
473
713
|
}
|
|
474
714
|
|
|
475
715
|
// src/channel.ts
|
|
@@ -477,8 +717,8 @@ import { execFile, execFileSync as execFileSync2 } from "child_process";
|
|
|
477
717
|
import { promisify } from "util";
|
|
478
718
|
var execFileAsync = promisify(execFile);
|
|
479
719
|
var CONTROL_PATH = "~/.ssh/control/%r@%h:%p";
|
|
480
|
-
var CONNECT_TIMEOUT_S =
|
|
481
|
-
var EXEC_TIMEOUT_MS =
|
|
720
|
+
var CONNECT_TIMEOUT_S = 1;
|
|
721
|
+
var EXEC_TIMEOUT_MS = 3e3;
|
|
482
722
|
var SSH_OPTIONS = [
|
|
483
723
|
"-o",
|
|
484
724
|
"BatchMode=yes",
|
|
@@ -489,11 +729,13 @@ var SSH_OPTIONS = [
|
|
|
489
729
|
"-o",
|
|
490
730
|
`ConnectTimeout=${CONNECT_TIMEOUT_S}`
|
|
491
731
|
];
|
|
732
|
+
var MAX_EXPORT_BYTES = 64 * 1024 * 1024;
|
|
492
733
|
var ssh = {
|
|
493
734
|
async exec(target, argv) {
|
|
494
735
|
const { stdout } = await execFileAsync("ssh", [...SSH_OPTIONS, target, ...argv], {
|
|
495
736
|
encoding: "utf8",
|
|
496
|
-
timeout: EXEC_TIMEOUT_MS
|
|
737
|
+
timeout: EXEC_TIMEOUT_MS,
|
|
738
|
+
maxBuffer: MAX_EXPORT_BYTES
|
|
497
739
|
});
|
|
498
740
|
return stdout;
|
|
499
741
|
}
|
|
@@ -507,259 +749,324 @@ function hasWarmSocket(target) {
|
|
|
507
749
|
}
|
|
508
750
|
}
|
|
509
751
|
|
|
510
|
-
// src/
|
|
511
|
-
var
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
const event = events[index];
|
|
517
|
-
if (!event) continue;
|
|
518
|
-
switch (event.state) {
|
|
519
|
-
case "blocked":
|
|
520
|
-
case "done":
|
|
521
|
-
case "crashed":
|
|
522
|
-
return { state: event.state, event };
|
|
523
|
-
case "cleared":
|
|
524
|
-
return { state: null, event: null };
|
|
525
|
-
case "working":
|
|
526
|
-
return {
|
|
527
|
-
state: event.pid !== null && event.pid > 0 && isAlive(event.pid) ? "working" : "crashed",
|
|
528
|
-
event
|
|
529
|
-
};
|
|
530
|
-
}
|
|
752
|
+
// src/snapshot.ts
|
|
753
|
+
var SnapshotInvalidError = class extends Error {
|
|
754
|
+
constructor(path, detail) {
|
|
755
|
+
super(path === "" ? detail : `${path}: ${detail}`);
|
|
756
|
+
this.path = path;
|
|
757
|
+
this.name = "SnapshotInvalidError";
|
|
531
758
|
}
|
|
532
|
-
|
|
533
|
-
}
|
|
534
|
-
function
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
759
|
+
path;
|
|
760
|
+
};
|
|
761
|
+
function fail(path, detail) {
|
|
762
|
+
throw new SnapshotInvalidError(path, detail);
|
|
763
|
+
}
|
|
764
|
+
function object(value, path, keys) {
|
|
765
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
766
|
+
fail(path, "expected an object");
|
|
540
767
|
}
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
if (!
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
host_id: source.host_id,
|
|
548
|
-
state: folded.state,
|
|
549
|
-
event: folded.event,
|
|
550
|
-
workstream: source.workstream,
|
|
551
|
-
role: source.role,
|
|
552
|
-
cli: source.cli,
|
|
553
|
-
driver: source.driver ?? DEFAULT_DRIVER,
|
|
554
|
-
session: source.session,
|
|
555
|
-
window: source.window,
|
|
556
|
-
pane: source.pane,
|
|
557
|
-
session_name: source.session_name,
|
|
558
|
-
window_name: source.window_name,
|
|
559
|
-
agent_name: source.agent_name,
|
|
560
|
-
pi_session: source.pi_session,
|
|
561
|
-
fetched_at: null
|
|
562
|
-
};
|
|
563
|
-
});
|
|
768
|
+
const record = value;
|
|
769
|
+
for (const key of keys) if (!(key in record)) fail(path, `missing key ${key}`);
|
|
770
|
+
for (const key of Object.keys(record)) {
|
|
771
|
+
if (!keys.includes(key)) fail(path, `unknown key ${key}`);
|
|
772
|
+
}
|
|
773
|
+
return record;
|
|
564
774
|
}
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
crashed: 2,
|
|
569
|
-
working: 3,
|
|
570
|
-
cleared: 4
|
|
571
|
-
};
|
|
572
|
-
function attentionSort(views) {
|
|
573
|
-
return [...views].sort((left, right) => {
|
|
574
|
-
const stateOrder = (left.state === null ? 4 : ATTENTION_ORDER[left.state]) - (right.state === null ? 4 : ATTENTION_ORDER[right.state]);
|
|
575
|
-
if (stateOrder !== 0) return stateOrder;
|
|
576
|
-
return (right.event?.ts ?? 0) - (left.event?.ts ?? 0);
|
|
577
|
-
});
|
|
775
|
+
function text(value, path) {
|
|
776
|
+
if (typeof value !== "string" || value === "") fail(path, "expected a non-empty string");
|
|
777
|
+
return value;
|
|
578
778
|
}
|
|
579
|
-
function
|
|
580
|
-
|
|
779
|
+
function textOrNull(value, path) {
|
|
780
|
+
if (value === null) return null;
|
|
781
|
+
if (typeof value !== "string") fail(path, "expected a string or null");
|
|
782
|
+
return value;
|
|
581
783
|
}
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
784
|
+
function anyText(value, path) {
|
|
785
|
+
if (typeof value !== "string") fail(path, "expected a string");
|
|
786
|
+
return value;
|
|
787
|
+
}
|
|
788
|
+
function timestamp(value, path) {
|
|
789
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 0) {
|
|
790
|
+
fail(path, "expected a non-negative integer");
|
|
791
|
+
}
|
|
792
|
+
return value;
|
|
793
|
+
}
|
|
794
|
+
function member(value, path, allowed) {
|
|
795
|
+
if (typeof value !== "string" || !allowed.includes(value)) {
|
|
796
|
+
fail(path, `expected one of ${allowed.join(", ")}`);
|
|
797
|
+
}
|
|
798
|
+
return value;
|
|
799
|
+
}
|
|
800
|
+
var ACTIVITIES = ["running", "stopped"];
|
|
801
|
+
var DRIVERS = ["human", "orchestrated"];
|
|
802
|
+
var KINDS = ["done", "blocked", "crashed"];
|
|
803
|
+
var TOP_KEYS = [
|
|
804
|
+
"murmur_snapshot",
|
|
586
805
|
"host_id",
|
|
587
|
-
"
|
|
588
|
-
"
|
|
589
|
-
"
|
|
806
|
+
"display_name",
|
|
807
|
+
"murmur_version",
|
|
808
|
+
"generated_at",
|
|
809
|
+
"panes"
|
|
810
|
+
];
|
|
811
|
+
var PANE_KEYS = [
|
|
812
|
+
"pane",
|
|
590
813
|
"session",
|
|
591
814
|
"window",
|
|
592
|
-
"pane",
|
|
593
815
|
"session_name",
|
|
594
816
|
"window_name",
|
|
817
|
+
"agent",
|
|
818
|
+
"attention"
|
|
819
|
+
];
|
|
820
|
+
var AGENT_KEYS = [
|
|
821
|
+
"agent_id",
|
|
822
|
+
"activity",
|
|
595
823
|
"agent_name",
|
|
596
824
|
"pi_session",
|
|
597
825
|
"workstream",
|
|
598
826
|
"role",
|
|
599
827
|
"cli",
|
|
600
828
|
"driver",
|
|
601
|
-
"
|
|
602
|
-
"
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
function eventToWire(event) {
|
|
609
|
-
const { extra, ...known } = event;
|
|
610
|
-
return { ...extra, ...known };
|
|
611
|
-
}
|
|
612
|
-
function eventFromWire(wire) {
|
|
613
|
-
const extra = Object.fromEntries(Object.entries(wire).filter(([key]) => !EVENT_FIELDS.has(key)));
|
|
829
|
+
"claimed_at",
|
|
830
|
+
"updated_at"
|
|
831
|
+
];
|
|
832
|
+
var ATTENTION_KEYS = ["kind", "message", "source", "requested_at"];
|
|
833
|
+
function parseAgent(value, path) {
|
|
834
|
+
if (value === null) return null;
|
|
835
|
+
const row = object(value, path, AGENT_KEYS);
|
|
614
836
|
return {
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
pi_session: wire.pi_session ?? null,
|
|
626
|
-
workstream: wire.workstream ?? null,
|
|
627
|
-
role: wire.role ?? null,
|
|
628
|
-
cli: wire.cli ?? null,
|
|
629
|
-
driver: wire.driver ?? null,
|
|
630
|
-
kind: wire.kind,
|
|
631
|
-
state: wire.state,
|
|
632
|
-
message: wire.message,
|
|
633
|
-
pid: wire.pid ?? null,
|
|
634
|
-
synthetic: wire.synthetic,
|
|
635
|
-
reason: wire.reason,
|
|
636
|
-
extra
|
|
837
|
+
agent_id: text(row.agent_id, `${path}.agent_id`),
|
|
838
|
+
activity: member(row.activity, `${path}.activity`, ACTIVITIES),
|
|
839
|
+
agent_name: textOrNull(row.agent_name, `${path}.agent_name`),
|
|
840
|
+
pi_session: textOrNull(row.pi_session, `${path}.pi_session`),
|
|
841
|
+
workstream: textOrNull(row.workstream, `${path}.workstream`),
|
|
842
|
+
role: textOrNull(row.role, `${path}.role`),
|
|
843
|
+
cli: text(row.cli, `${path}.cli`),
|
|
844
|
+
driver: member(row.driver, `${path}.driver`, DRIVERS),
|
|
845
|
+
claimed_at: timestamp(row.claimed_at, `${path}.claimed_at`),
|
|
846
|
+
updated_at: timestamp(row.updated_at, `${path}.updated_at`)
|
|
637
847
|
};
|
|
638
848
|
}
|
|
639
|
-
function
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
const
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
}
|
|
849
|
+
function parseAttention(value, path) {
|
|
850
|
+
if (!Array.isArray(value)) fail(path, "expected an array");
|
|
851
|
+
const seen = /* @__PURE__ */ new Set();
|
|
852
|
+
return value.map((entry, index) => {
|
|
853
|
+
const at = `${path}[${index}]`;
|
|
854
|
+
const row = object(entry, at, ATTENTION_KEYS);
|
|
855
|
+
const kind = member(row.kind, `${at}.kind`, KINDS);
|
|
856
|
+
if (seen.has(kind)) fail(`${at}.kind`, `duplicate kind ${kind} for this pane`);
|
|
857
|
+
seen.add(kind);
|
|
858
|
+
return {
|
|
859
|
+
kind,
|
|
860
|
+
message: anyText(row.message, `${at}.message`),
|
|
861
|
+
source: anyText(row.source, `${at}.source`),
|
|
862
|
+
requested_at: timestamp(row.requested_at, `${at}.requested_at`)
|
|
863
|
+
};
|
|
864
|
+
});
|
|
865
|
+
}
|
|
866
|
+
function parsePane(value, path) {
|
|
867
|
+
const row = object(value, path, PANE_KEYS);
|
|
868
|
+
const agent = parseAgent(row.agent, `${path}.agent`);
|
|
869
|
+
const attention = parseAttention(row.attention, `${path}.attention`);
|
|
870
|
+
if (agent === null && attention.length === 0) {
|
|
871
|
+
fail(path, "a pane with no agent and no attention must not be emitted");
|
|
654
872
|
}
|
|
873
|
+
return {
|
|
874
|
+
pane: asPaneId(text(row.pane, `${path}.pane`)),
|
|
875
|
+
session: asSessionId(text(row.session, `${path}.session`)),
|
|
876
|
+
window: asWindowId(text(row.window, `${path}.window`)),
|
|
877
|
+
session_name: textOrNull(row.session_name, `${path}.session_name`),
|
|
878
|
+
window_name: textOrNull(row.window_name, `${path}.window_name`),
|
|
879
|
+
agent,
|
|
880
|
+
attention
|
|
881
|
+
};
|
|
655
882
|
}
|
|
656
|
-
function
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
if (!previous || event.seq > previous.seq) newest.set(event.agent_id, event);
|
|
883
|
+
function parseSnapshot(input) {
|
|
884
|
+
let parsed;
|
|
885
|
+
try {
|
|
886
|
+
parsed = JSON.parse(input);
|
|
887
|
+
} catch (error) {
|
|
888
|
+
fail("", `not JSON (${error instanceof Error ? error.message : String(error)})`);
|
|
663
889
|
}
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
const { host_id: _hostId, seq: _seq, ts: _ts, ...rest } = event;
|
|
668
|
-
store.append({
|
|
669
|
-
...rest,
|
|
670
|
-
state: "cleared",
|
|
671
|
-
synthetic: true,
|
|
672
|
-
reason: "window_gone",
|
|
673
|
-
message: ""
|
|
674
|
-
});
|
|
890
|
+
const top = object(parsed, "", TOP_KEYS);
|
|
891
|
+
if (top.murmur_snapshot !== 1) {
|
|
892
|
+
fail("murmur_snapshot", `expected 1, got ${JSON.stringify(top.murmur_snapshot)}`);
|
|
675
893
|
}
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
const
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
894
|
+
if (!Array.isArray(top.panes)) fail("panes", "expected an array");
|
|
895
|
+
const panes = top.panes.map((entry, index) => parsePane(entry, `panes[${index}]`));
|
|
896
|
+
const seen = /* @__PURE__ */ new Set();
|
|
897
|
+
for (const pane of panes) {
|
|
898
|
+
if (seen.has(pane.pane)) fail("panes", `duplicate pane ${pane.pane}`);
|
|
899
|
+
seen.add(pane.pane);
|
|
900
|
+
}
|
|
901
|
+
return {
|
|
902
|
+
murmur_snapshot: 1,
|
|
903
|
+
host_id: text(top.host_id, "host_id"),
|
|
904
|
+
display_name: text(top.display_name, "display_name"),
|
|
905
|
+
murmur_version: text(top.murmur_version, "murmur_version"),
|
|
906
|
+
generated_at: timestamp(top.generated_at, "generated_at"),
|
|
907
|
+
panes
|
|
686
908
|
};
|
|
687
|
-
const lines = [
|
|
688
|
-
JSON.stringify(envelope),
|
|
689
|
-
...store.eventsSince(identity.host_id, since).map((event) => JSON.stringify(eventToWire(event)))
|
|
690
|
-
];
|
|
691
|
-
return `${lines.join("\n")}
|
|
692
|
-
`;
|
|
693
909
|
}
|
|
694
910
|
|
|
695
911
|
// src/collector.ts
|
|
696
|
-
var
|
|
697
|
-
var
|
|
698
|
-
function
|
|
699
|
-
const
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
912
|
+
var MAX_CONCURRENT_PEERS = 8;
|
|
913
|
+
var COLLECT_DEADLINE_MS = 4e3;
|
|
914
|
+
async function mapSettled(items, limit, task, deadline) {
|
|
915
|
+
const results = new Array(items.length);
|
|
916
|
+
let cursor = 0;
|
|
917
|
+
let expired = false;
|
|
918
|
+
const stop = deadline?.then(() => {
|
|
919
|
+
expired = true;
|
|
920
|
+
});
|
|
921
|
+
const worker = async () => {
|
|
922
|
+
while (cursor < items.length && !expired) {
|
|
923
|
+
const index = cursor++;
|
|
924
|
+
try {
|
|
925
|
+
results[index] = { status: "fulfilled", value: await task(items[index]) };
|
|
926
|
+
} catch (reason) {
|
|
927
|
+
results[index] = { status: "rejected", reason };
|
|
928
|
+
}
|
|
929
|
+
}
|
|
709
930
|
};
|
|
931
|
+
const pool = Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
|
932
|
+
await (stop ? Promise.race([pool, stop]) : pool);
|
|
933
|
+
return results;
|
|
934
|
+
}
|
|
935
|
+
function isUnreachable(message) {
|
|
936
|
+
return /Host is down|No route to host|Connection refused|Connection timed out|Connection closed|Operation timed out|Network is unreachable|Name or service not known|Could not resolve hostname|timed out after/i.test(
|
|
937
|
+
message
|
|
938
|
+
) || /\bssh:/.test(message);
|
|
939
|
+
}
|
|
940
|
+
function stripInvocation(message) {
|
|
941
|
+
const firstLine = message.indexOf("\n");
|
|
942
|
+
if (firstLine === -1 || !message.startsWith("Command failed:")) return message;
|
|
943
|
+
const rest = message.slice(firstLine + 1).trim();
|
|
944
|
+
return rest === "" ? message : rest;
|
|
945
|
+
}
|
|
946
|
+
function normalizeFailure(message) {
|
|
947
|
+
return stripInvocation(message).replace(/\s+/g, " ").trim();
|
|
710
948
|
}
|
|
711
|
-
|
|
949
|
+
function describeFailure(peer, message) {
|
|
950
|
+
const collapsed = normalizeFailure(message);
|
|
951
|
+
if (isUnreachable(collapsed)) {
|
|
952
|
+
const reason = /ssh: (?:connect to host \S+ port \d+: )?(.+?)(?: \(|$)/i.exec(collapsed);
|
|
953
|
+
return `${peer}: unreachable (${(reason?.[1] ?? "ssh failed").trim()})`;
|
|
954
|
+
}
|
|
955
|
+
const detail = collapsed.length > 160 ? `${collapsed.slice(0, 157)}...` : collapsed;
|
|
956
|
+
return `${peer}: ${detail}`;
|
|
957
|
+
}
|
|
958
|
+
async function collect(store, channel, now = Date.now(), deadline) {
|
|
712
959
|
const results = [];
|
|
960
|
+
let timer;
|
|
713
961
|
try {
|
|
714
|
-
|
|
962
|
+
const peers = store.peers();
|
|
963
|
+
const bounded = deadline ?? new Promise((resolve) => {
|
|
964
|
+
timer = setTimeout(resolve, COLLECT_DEADLINE_MS);
|
|
965
|
+
timer.unref?.();
|
|
966
|
+
});
|
|
967
|
+
const fetches = await mapSettled(
|
|
968
|
+
peers,
|
|
969
|
+
MAX_CONCURRENT_PEERS,
|
|
970
|
+
async (peer) => parseSnapshot(await channel.exec(peer.target, ["murmur", "export"])),
|
|
971
|
+
bounded
|
|
972
|
+
);
|
|
973
|
+
for (const [index, peer] of peers.entries()) {
|
|
974
|
+
const fetch = fetches[index];
|
|
715
975
|
try {
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
String(peer.watermark)
|
|
721
|
-
]);
|
|
722
|
-
const { envelope, events } = parseJsonl(output);
|
|
723
|
-
const ingested = store.ingest(events);
|
|
724
|
-
const watermark = events.filter((event) => event.host_id === envelope.host_id).reduce((highest, event) => Math.max(highest, event.seq), peer.watermark);
|
|
725
|
-
store.upsertPeer({
|
|
726
|
-
name: peer.name,
|
|
727
|
-
target: peer.target,
|
|
728
|
-
host_id: envelope.host_id,
|
|
729
|
-
display_name: envelope.display_name,
|
|
730
|
-
watermark,
|
|
731
|
-
fetched_at: now,
|
|
732
|
-
// New events mean the node is authoring again, so whatever a jump
|
|
733
|
-
// observed about its tmux is out of date. Only clear on actual new
|
|
734
|
-
// events: an export that returns nothing proves the binary ran, not
|
|
735
|
-
// that tmux is back, which is the distinction that let a dead host
|
|
736
|
-
// look healthy for three hours.
|
|
737
|
-
tmux_down_at: ingested > 0 ? null : peer.tmux_down_at
|
|
738
|
-
});
|
|
739
|
-
store.prune();
|
|
740
|
-
results.push({ peer: peer.name, ok: true, ingested });
|
|
976
|
+
if (!fetch) throw new Error("collect deadline passed before this peer answered");
|
|
977
|
+
if (fetch.status === "rejected") throw fetch.reason;
|
|
978
|
+
store.replacePeerSnapshot(peer.name, { ok: true, snapshot: fetch.value, at: now });
|
|
979
|
+
results.push({ peer: peer.name, ok: true, panes: fetch.value.panes.length });
|
|
741
980
|
} catch (error) {
|
|
742
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
981
|
+
const message = normalizeFailure(error instanceof Error ? error.message : String(error));
|
|
982
|
+
store.replacePeerSnapshot(peer.name, { ok: false, error: message, at: now });
|
|
983
|
+
results.push({
|
|
984
|
+
peer: peer.name,
|
|
985
|
+
ok: false,
|
|
986
|
+
panes: 0,
|
|
987
|
+
error: message,
|
|
988
|
+
// A peer that answered with a bad document is reachable but broken,
|
|
989
|
+
// and must be visibly so rather than silently stale.
|
|
990
|
+
unreachable: error instanceof SnapshotInvalidError ? false : isUnreachable(normalizeFailure(message))
|
|
991
|
+
});
|
|
746
992
|
}
|
|
747
993
|
}
|
|
748
994
|
} catch (error) {
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
995
|
+
results.push({
|
|
996
|
+
peer: "",
|
|
997
|
+
ok: false,
|
|
998
|
+
panes: 0,
|
|
999
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1000
|
+
});
|
|
1001
|
+
} finally {
|
|
1002
|
+
clearTimeout(timer);
|
|
1003
|
+
}
|
|
1004
|
+
try {
|
|
1005
|
+
store.reconcileLocal({ panes: tmux.livePanes(), now });
|
|
1006
|
+
} catch {
|
|
753
1007
|
}
|
|
754
1008
|
return results;
|
|
755
1009
|
}
|
|
756
1010
|
|
|
1011
|
+
// src/identity.ts
|
|
1012
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
1013
|
+
import { existsSync, mkdirSync as mkdirSync2, readFileSync, writeFileSync } from "fs";
|
|
1014
|
+
import { hostname } from "os";
|
|
1015
|
+
import { join as join2 } from "path";
|
|
1016
|
+
function identityPath() {
|
|
1017
|
+
return join2(stateDir(), "identity.json");
|
|
1018
|
+
}
|
|
1019
|
+
var cache = null;
|
|
1020
|
+
function loadIdentity() {
|
|
1021
|
+
const path = identityPath();
|
|
1022
|
+
if (cache?.path === path) return cache.identity;
|
|
1023
|
+
const identity = existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) : null;
|
|
1024
|
+
cache = { path, identity };
|
|
1025
|
+
return identity;
|
|
1026
|
+
}
|
|
1027
|
+
function write(identity) {
|
|
1028
|
+
mkdirSync2(stateDir(), { recursive: true });
|
|
1029
|
+
writeFileSync(identityPath(), `${JSON.stringify(identity, null, 2)}
|
|
1030
|
+
`);
|
|
1031
|
+
cache = { path: identityPath(), identity };
|
|
1032
|
+
return identity;
|
|
1033
|
+
}
|
|
1034
|
+
function createIdentity(displayName = hostname()) {
|
|
1035
|
+
if (loadIdentity()) throw new Error(`identity already exists: ${identityPath()}`);
|
|
1036
|
+
return write({ host_id: randomUUID2(), display_name: displayName });
|
|
1037
|
+
}
|
|
1038
|
+
function setDisplayName(displayName) {
|
|
1039
|
+
const existing = loadIdentity();
|
|
1040
|
+
return write(
|
|
1041
|
+
existing ? { host_id: existing.host_id, display_name: displayName } : { host_id: randomUUID2(), display_name: displayName }
|
|
1042
|
+
);
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
// src/cli/identity-guard.ts
|
|
1046
|
+
function requireIdentity() {
|
|
1047
|
+
const identity = loadIdentity();
|
|
1048
|
+
if (identity) return identity;
|
|
1049
|
+
process.stderr.write("murmur is not initialised on this node; run: murmur init\n");
|
|
1050
|
+
process.exitCode = 1;
|
|
1051
|
+
return null;
|
|
1052
|
+
}
|
|
1053
|
+
|
|
757
1054
|
// src/cli/collect.ts
|
|
758
1055
|
function registerCollect(program2) {
|
|
759
|
-
program2.command("collect").description("
|
|
1056
|
+
program2.command("collect").description("Fetch each peer's snapshot").option("-q, --quiet", "report nothing, not even unreachable peers").action(async (options) => {
|
|
1057
|
+
if (!requireIdentity()) return;
|
|
760
1058
|
const store = openStore();
|
|
761
1059
|
try {
|
|
762
|
-
await collect(store, ssh);
|
|
1060
|
+
const results = await collect(store, ssh);
|
|
1061
|
+
if (options.quiet) return;
|
|
1062
|
+
for (const result of results) {
|
|
1063
|
+
if (result.ok || !result.error) continue;
|
|
1064
|
+
process.stderr.write(`murmur: ${describeFailure(result.peer, result.error)}
|
|
1065
|
+
`);
|
|
1066
|
+
}
|
|
1067
|
+
if (results.some((result) => !result.ok && !result.unreachable)) {
|
|
1068
|
+
process.exitCode = 1;
|
|
1069
|
+
}
|
|
763
1070
|
} finally {
|
|
764
1071
|
store.close();
|
|
765
1072
|
}
|
|
@@ -768,10 +1075,14 @@ function registerCollect(program2) {
|
|
|
768
1075
|
|
|
769
1076
|
// src/cli/export.ts
|
|
770
1077
|
function registerExport(program2) {
|
|
771
|
-
program2.command("export").description("
|
|
1078
|
+
program2.command("export").description("Print this node's current-state snapshot").action(() => {
|
|
1079
|
+
const identity = requireIdentity();
|
|
1080
|
+
if (!identity) return;
|
|
772
1081
|
const store = openStore();
|
|
773
1082
|
try {
|
|
774
|
-
|
|
1083
|
+
const snapshot = store.buildLocalSnapshot(identity, { panes: tmux.livePanes() });
|
|
1084
|
+
process.stdout.write(`${JSON.stringify(snapshot)}
|
|
1085
|
+
`);
|
|
775
1086
|
} finally {
|
|
776
1087
|
store.close();
|
|
777
1088
|
}
|
|
@@ -781,19 +1092,46 @@ function registerExport(program2) {
|
|
|
781
1092
|
// src/cli/init.ts
|
|
782
1093
|
function registerInit(program2) {
|
|
783
1094
|
program2.command("init").description("Initialize this node's identity").option("--name <name>", "display name").action((opts) => {
|
|
784
|
-
const
|
|
1095
|
+
const existing = loadIdentity();
|
|
1096
|
+
const identity = existing ? opts.name ? setDisplayName(opts.name) : existing : createIdentity(opts.name);
|
|
785
1097
|
console.log(`host_id: ${identity.host_id}`);
|
|
786
1098
|
console.log(`display_name: ${identity.display_name}`);
|
|
787
1099
|
});
|
|
788
1100
|
}
|
|
789
1101
|
|
|
790
1102
|
// src/cli/link.ts
|
|
791
|
-
import { mkdirSync as
|
|
1103
|
+
import { mkdirSync as mkdirSync3, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
792
1104
|
import { homedir as homedir2 } from "os";
|
|
793
|
-
import { dirname, join as join3 } from "path";
|
|
1105
|
+
import { dirname as dirname2, join as join3 } from "path";
|
|
794
1106
|
import { fileURLToPath } from "url";
|
|
1107
|
+
var SHIM_MARKER = "// murmur:shim";
|
|
1108
|
+
function shim(entry, storePath) {
|
|
1109
|
+
return `${SHIM_MARKER}
|
|
1110
|
+
// Generated by \`murmur link pi\`. Do not edit.
|
|
1111
|
+
//
|
|
1112
|
+
// A re-export, not a copy: the extension code lives in the murmur install, so
|
|
1113
|
+
// upgrading murmur upgrades the extension with no reinstall step. Re-run
|
|
1114
|
+
// \`murmur link pi\` only if the install path itself moves.
|
|
1115
|
+
//
|
|
1116
|
+
// The store path is set here rather than resolved by the extension. A bare
|
|
1117
|
+
// specifier cannot resolve from ~/.pi/agent/extensions, and the failure is
|
|
1118
|
+
// silent: the import throws, the extension swallows it, and every state report
|
|
1119
|
+
// is dropped while the tmux badge still paints.
|
|
1120
|
+
//
|
|
1121
|
+
// A dynamic import, not \`export ... from\`: ESM hoists static re-exports above
|
|
1122
|
+
// this assignment, so the extension loaded before the variable was set and read
|
|
1123
|
+
// undefined. Verified -- the static form printed \`undefined\` in the target.
|
|
1124
|
+
process.env.MURMUR_STORE_MODULE ??= ${JSON.stringify(storePath)};
|
|
1125
|
+
|
|
1126
|
+
const { default: extension } = await import(${JSON.stringify(entry)});
|
|
1127
|
+
export default extension;
|
|
1128
|
+
`;
|
|
1129
|
+
}
|
|
795
1130
|
function registerLink(program2) {
|
|
796
|
-
program2.command("link").description("Install a murmur integration").argument("<target>", "integration to install").
|
|
1131
|
+
program2.command("link").description("Install a murmur integration").argument("<target>", "integration to install").option(
|
|
1132
|
+
"--copy",
|
|
1133
|
+
"inline the extension instead of re-exporting it (pins to this version; needs re-linking after an upgrade)"
|
|
1134
|
+
).action((target, options) => {
|
|
797
1135
|
if (target !== "pi") throw new Error(`unsupported link target: ${target}`);
|
|
798
1136
|
const destination = join3(
|
|
799
1137
|
process.env.MURMUR_PI_HOME ?? homedir2(),
|
|
@@ -802,12 +1140,32 @@ function registerLink(program2) {
|
|
|
802
1140
|
"extensions",
|
|
803
1141
|
"murmur.ts"
|
|
804
1142
|
);
|
|
805
|
-
|
|
806
|
-
const
|
|
807
|
-
fileURLToPath(new URL("./extension/murmur-pi.js", import.meta.url)),
|
|
808
|
-
"utf8"
|
|
809
|
-
);
|
|
1143
|
+
mkdirSync3(dirname2(destination), { recursive: true });
|
|
1144
|
+
const entry = fileURLToPath(new URL("./extension/murmur-pi.js", import.meta.url));
|
|
810
1145
|
const storePath = fileURLToPath(new URL("./extension/store.js", import.meta.url));
|
|
1146
|
+
const identityMissing = loadIdentity() === null;
|
|
1147
|
+
if (!options.copy) {
|
|
1148
|
+
let replacedCopy = false;
|
|
1149
|
+
try {
|
|
1150
|
+
const existing = readFileSync2(destination, "utf8");
|
|
1151
|
+
replacedCopy = !existing.includes(SHIM_MARKER);
|
|
1152
|
+
} catch {
|
|
1153
|
+
}
|
|
1154
|
+
writeFileSync2(destination, shim(entry, storePath));
|
|
1155
|
+
console.log(destination);
|
|
1156
|
+
if (replacedCopy) {
|
|
1157
|
+
console.log(
|
|
1158
|
+
"Replaced an inlined copy from an older murmur. That copy was pinned to the version that wrote it, so it had stopped picking up fixes; running agents keep the old code until they restart."
|
|
1159
|
+
);
|
|
1160
|
+
}
|
|
1161
|
+
if (identityMissing) {
|
|
1162
|
+
console.log(
|
|
1163
|
+
"This node has no identity yet, so the extension will record nothing. Run: murmur init"
|
|
1164
|
+
);
|
|
1165
|
+
}
|
|
1166
|
+
return;
|
|
1167
|
+
}
|
|
1168
|
+
const source = readFileSync2(entry, "utf8");
|
|
811
1169
|
const pinned = source.replace(
|
|
812
1170
|
/"@martintrojer\/murmur\/extension-store"/,
|
|
813
1171
|
JSON.stringify(storePath)
|
|
@@ -817,6 +1175,107 @@ function registerLink(program2) {
|
|
|
817
1175
|
}
|
|
818
1176
|
writeFileSync2(destination, pinned);
|
|
819
1177
|
console.log(destination);
|
|
1178
|
+
if (identityMissing) {
|
|
1179
|
+
console.log(
|
|
1180
|
+
"This node has no identity yet, so the extension will record nothing. Run: murmur init"
|
|
1181
|
+
);
|
|
1182
|
+
}
|
|
1183
|
+
});
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
// src/cli/notify.ts
|
|
1187
|
+
function notifyFields(input, payload = {}) {
|
|
1188
|
+
const field = (key, flag) => {
|
|
1189
|
+
if (flag) return clean(flag);
|
|
1190
|
+
const value = payload[key];
|
|
1191
|
+
return typeof value === "string" ? clean(value) : "";
|
|
1192
|
+
};
|
|
1193
|
+
const source = field("source", input.source) || "agent";
|
|
1194
|
+
const title = field("title", input.title);
|
|
1195
|
+
const eventType = field("type", input.eventType);
|
|
1196
|
+
const message = field("message", input.message) || title || eventType || "attention";
|
|
1197
|
+
return { source, message };
|
|
1198
|
+
}
|
|
1199
|
+
function clean(value) {
|
|
1200
|
+
const flattened = [...value].map((character) => {
|
|
1201
|
+
const code = character.charCodeAt(0);
|
|
1202
|
+
const control = code < 32 || code === 127 || code >= 128 && code <= 159;
|
|
1203
|
+
return control ? " " : character;
|
|
1204
|
+
}).join("");
|
|
1205
|
+
return flattened.replace(/\s+/g, " ").trim();
|
|
1206
|
+
}
|
|
1207
|
+
function parsePayload(raw) {
|
|
1208
|
+
if (!raw.trim()) return {};
|
|
1209
|
+
try {
|
|
1210
|
+
const parsed = JSON.parse(raw);
|
|
1211
|
+
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed : {};
|
|
1212
|
+
} catch {
|
|
1213
|
+
return {};
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
function runNotify(store, input, payload = {}, mux = tmux) {
|
|
1217
|
+
const location = resolveLocation(input.pane, mux);
|
|
1218
|
+
if (!location) return false;
|
|
1219
|
+
const { source, message } = notifyFields(input, payload);
|
|
1220
|
+
store.requestAttention({
|
|
1221
|
+
kind: "blocked",
|
|
1222
|
+
location,
|
|
1223
|
+
message,
|
|
1224
|
+
// The harness name goes here, not in `driver`. `driver` answers "who is
|
|
1225
|
+
// waiting on this agent" -- a human, or a supervisor consuming the result --
|
|
1226
|
+
// and a codex agent driven by a human is `human` on exactly that question.
|
|
1227
|
+
// `source` answers "who asked", which is the free-text field a new harness
|
|
1228
|
+
// needs no schema change for.
|
|
1229
|
+
source
|
|
1230
|
+
});
|
|
1231
|
+
mux.setWindowBadge(location.window, "blocked");
|
|
1232
|
+
return true;
|
|
1233
|
+
}
|
|
1234
|
+
function resolveLocation(pane, mux) {
|
|
1235
|
+
const here = mux.currentWindow();
|
|
1236
|
+
if (!pane) return here;
|
|
1237
|
+
const target = asPaneId(pane);
|
|
1238
|
+
if (here && here.pane === target) return here;
|
|
1239
|
+
if (here && mux.panesInWindow(here.window).includes(target)) {
|
|
1240
|
+
return { ...here, pane: target };
|
|
1241
|
+
}
|
|
1242
|
+
return null;
|
|
1243
|
+
}
|
|
1244
|
+
function registerNotify(program2) {
|
|
1245
|
+
program2.command("notify").description("Record an attention request for a harness that cannot report itself").option("--source <name>", "harness name, e.g. codex or opencode").option("--event-type <type>", "why attention is wanted").option("--title <title>", "harness display title").option("--message <message>", "the text to show").option("--pane <pane>", "pane to notify about (default: $TMUX_PANE)").action(
|
|
1246
|
+
async (options) => {
|
|
1247
|
+
const payload = parsePayload(await readStdin());
|
|
1248
|
+
const store = openStore();
|
|
1249
|
+
try {
|
|
1250
|
+
runNotify(store, options, payload);
|
|
1251
|
+
} finally {
|
|
1252
|
+
store.close();
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
);
|
|
1256
|
+
}
|
|
1257
|
+
var STDIN_DEADLINE_MS = 250;
|
|
1258
|
+
async function readStdin() {
|
|
1259
|
+
if (process.stdin.isTTY) return "";
|
|
1260
|
+
const chunks = [];
|
|
1261
|
+
return new Promise((resolve) => {
|
|
1262
|
+
const onData = (chunk) => chunks.push(chunk);
|
|
1263
|
+
const done = () => {
|
|
1264
|
+
process.stdin.off("data", onData);
|
|
1265
|
+
process.stdin.unref?.();
|
|
1266
|
+
resolve(Buffer.concat(chunks).toString("utf8"));
|
|
1267
|
+
};
|
|
1268
|
+
const timer = setTimeout(done, STDIN_DEADLINE_MS);
|
|
1269
|
+
timer.unref?.();
|
|
1270
|
+
process.stdin.on("data", onData);
|
|
1271
|
+
process.stdin.once("end", () => {
|
|
1272
|
+
clearTimeout(timer);
|
|
1273
|
+
done();
|
|
1274
|
+
});
|
|
1275
|
+
process.stdin.once("error", () => {
|
|
1276
|
+
clearTimeout(timer);
|
|
1277
|
+
done();
|
|
1278
|
+
});
|
|
820
1279
|
});
|
|
821
1280
|
}
|
|
822
1281
|
|
|
@@ -824,6 +1283,7 @@ function registerLink(program2) {
|
|
|
824
1283
|
import { readFileSync as readFileSync3 } from "fs";
|
|
825
1284
|
import { homedir as homedir3 } from "os";
|
|
826
1285
|
import { join as join4 } from "path";
|
|
1286
|
+
var SNAPSHOT_VERSION = 1;
|
|
827
1287
|
function parseSshHosts(config) {
|
|
828
1288
|
const hosts = [];
|
|
829
1289
|
for (const line of config.split("\n")) {
|
|
@@ -842,6 +1302,22 @@ function sshHosts() {
|
|
|
842
1302
|
return [];
|
|
843
1303
|
}
|
|
844
1304
|
}
|
|
1305
|
+
function lastSeen(fetchedAt, now) {
|
|
1306
|
+
if (fetchedAt === null) return "never";
|
|
1307
|
+
if (freshness(fetchedAt, now, STALENESS_MS) === "fresh") return "just now";
|
|
1308
|
+
return `${age(now - fetchedAt)} ago`;
|
|
1309
|
+
}
|
|
1310
|
+
function versionCell(peer, ours = SNAPSHOT_VERSION) {
|
|
1311
|
+
if (peer.murmur_version === null && peer.snapshot_version === null) {
|
|
1312
|
+
return { text: "unknown", incompatible: false };
|
|
1313
|
+
}
|
|
1314
|
+
const version = peer.murmur_version ?? "unreported";
|
|
1315
|
+
const incompatible = peer.snapshot_version !== null && peer.snapshot_version !== ours;
|
|
1316
|
+
return {
|
|
1317
|
+
text: incompatible ? `${version} (snapshot ${peer.snapshot_version} \u2260 ${ours})` : version,
|
|
1318
|
+
incompatible
|
|
1319
|
+
};
|
|
1320
|
+
}
|
|
845
1321
|
function formatTable(rows) {
|
|
846
1322
|
const widths = [];
|
|
847
1323
|
for (const row of rows) {
|
|
@@ -854,43 +1330,51 @@ function formatTable(rows) {
|
|
|
854
1330
|
).map((line) => `${line}
|
|
855
1331
|
`).join("");
|
|
856
1332
|
}
|
|
1333
|
+
function peerAddDecision(input) {
|
|
1334
|
+
const { name, target, snapshot, selfHostId, peers } = input;
|
|
1335
|
+
if (!snapshot) return null;
|
|
1336
|
+
if (snapshot.host_id === selfHostId) {
|
|
1337
|
+
return `${target} is this node; not adding it as a peer
|
|
1338
|
+
`;
|
|
1339
|
+
}
|
|
1340
|
+
const existing = peers.find(
|
|
1341
|
+
(candidate) => candidate.host_id === snapshot.host_id && candidate.name !== name
|
|
1342
|
+
);
|
|
1343
|
+
if (existing) {
|
|
1344
|
+
return `${target} is already configured as peer "${existing.name}" (${snapshot.display_name}); remove it first to rename
|
|
1345
|
+
`;
|
|
1346
|
+
}
|
|
1347
|
+
return null;
|
|
1348
|
+
}
|
|
857
1349
|
function registerPeer(program2) {
|
|
858
1350
|
const peer = program2.command("peer").description("Manage peers");
|
|
859
1351
|
peer.command("add").description("Add a peer and discover its identity").argument("<name>").argument("[target]").action(async (name, target = name) => {
|
|
860
1352
|
const store = openStore();
|
|
861
1353
|
try {
|
|
862
|
-
let
|
|
1354
|
+
let snapshot = null;
|
|
863
1355
|
try {
|
|
864
|
-
|
|
865
|
-
envelope = JSON.parse(output.trim().split("\n")[0] ?? "");
|
|
1356
|
+
snapshot = parseSnapshot(await ssh.exec(target, ["murmur", "export"]));
|
|
866
1357
|
} catch {
|
|
867
|
-
|
|
1358
|
+
snapshot = null;
|
|
868
1359
|
}
|
|
869
|
-
|
|
870
|
-
if (envelope.host_id === loadIdentity()?.host_id) {
|
|
871
|
-
process.stderr.write(`${target} is this node; not adding it as a peer
|
|
872
|
-
`);
|
|
873
|
-
process.exitCode = 1;
|
|
874
|
-
return;
|
|
875
|
-
}
|
|
876
|
-
const existing = store.peers().find((candidate) => candidate.host_id === envelope.host_id && candidate.name !== name);
|
|
877
|
-
if (existing) {
|
|
878
|
-
process.stderr.write(
|
|
879
|
-
`${target} is already configured as peer "${existing.name}" (${envelope.display_name}); remove it first to rename
|
|
880
|
-
`
|
|
881
|
-
);
|
|
882
|
-
process.exitCode = 1;
|
|
883
|
-
return;
|
|
884
|
-
}
|
|
885
|
-
}
|
|
886
|
-
store.upsertPeer({
|
|
1360
|
+
const refusal = peerAddDecision({
|
|
887
1361
|
name,
|
|
888
1362
|
target,
|
|
889
|
-
|
|
890
|
-
|
|
1363
|
+
snapshot,
|
|
1364
|
+
selfHostId: loadIdentity()?.host_id ?? null,
|
|
1365
|
+
peers: store.peers()
|
|
891
1366
|
});
|
|
1367
|
+
if (refusal) {
|
|
1368
|
+
process.stderr.write(refusal);
|
|
1369
|
+
process.exitCode = 1;
|
|
1370
|
+
return;
|
|
1371
|
+
}
|
|
1372
|
+
store.addPeer(name, target);
|
|
1373
|
+
if (snapshot) {
|
|
1374
|
+
store.replacePeerSnapshot(name, { ok: true, snapshot, at: Date.now() });
|
|
1375
|
+
}
|
|
892
1376
|
process.stdout.write(
|
|
893
|
-
|
|
1377
|
+
snapshot ? `Added ${name} (${snapshot.display_name})
|
|
894
1378
|
` : `Added ${name} (identity pending)
|
|
895
1379
|
`
|
|
896
1380
|
);
|
|
@@ -912,40 +1396,111 @@ function registerPeer(program2) {
|
|
|
912
1396
|
store.close();
|
|
913
1397
|
}
|
|
914
1398
|
});
|
|
915
|
-
peer.command("list").description("List
|
|
1399
|
+
peer.command("list").description("List peers; --all adds SSH hosts that could become peers").option("--json", "print JSON").option("-a, --all", "also show SSH hosts that are not peers yet").action((options) => {
|
|
916
1400
|
const store = openStore();
|
|
917
1401
|
try {
|
|
918
1402
|
const peers = store.peers();
|
|
1403
|
+
const configured = new Map(peers.map((entry) => [entry.target, entry]));
|
|
1404
|
+
const discovered = options.all ? sshHosts().filter((host) => !configured.has(host)) : [];
|
|
1405
|
+
const now = Date.now();
|
|
1406
|
+
const rows = [...configured.keys(), ...discovered].map((target) => {
|
|
1407
|
+
const entry = configured.get(target);
|
|
1408
|
+
return {
|
|
1409
|
+
// The handle other commands take: a peer's name, or for a host that
|
|
1410
|
+
// is not one yet, the ssh host `peer add` wants.
|
|
1411
|
+
name: entry?.name ?? target,
|
|
1412
|
+
target,
|
|
1413
|
+
peer: entry !== void 0,
|
|
1414
|
+
// What the node called itself. Shown, never typed: it can be a
|
|
1415
|
+
// container id.
|
|
1416
|
+
hostname: entry?.display_name ?? null,
|
|
1417
|
+
// Being a peer is not the same as being reachable, and the old
|
|
1418
|
+
// output said only the first. A node asleep for twelve hours read
|
|
1419
|
+
// exactly like one polled a second ago.
|
|
1420
|
+
last_seen: entry === void 0 ? null : lastSeen(entry.fetched_at, now),
|
|
1421
|
+
// A warm ControlMaster socket makes a collect ~10ms instead of
|
|
1422
|
+
// ~170ms, and is the only path that works on a host demanding a
|
|
1423
|
+
// hardware-token touch per connection. A speed hint, never a
|
|
1424
|
+
// requirement -- which is why the old bare `[x]` / `[ ]` was
|
|
1425
|
+
// unreadable: it never said what was being checked.
|
|
1426
|
+
//
|
|
1427
|
+
// Safe for every row: `ssh -O check` talks to a local socket and
|
|
1428
|
+
// never dials, so a host that is down or does not exist answers in
|
|
1429
|
+
// ~16ms. Measured.
|
|
1430
|
+
ssh: hasWarmSocket(target) ? "warm" : "cold",
|
|
1431
|
+
// What it is running, or undefined when nothing is known -- either
|
|
1432
|
+
// because the host is not a peer yet, or because it is a peer that
|
|
1433
|
+
// has never answered. Undefined is what drops the column, so the
|
|
1434
|
+
// test is "has anything told us", not "is this configured": a fleet
|
|
1435
|
+
// of asleep peers must not buy a column of "unknown".
|
|
1436
|
+
version: entry === void 0 || entry.murmur_version === null && entry.snapshot_version === null ? void 0 : versionCell(entry),
|
|
1437
|
+
// Named where it can be acted on: a peer that answered with a bad
|
|
1438
|
+
// document is reachable but broken, which is an operator task and
|
|
1439
|
+
// reads nothing like a sleeping laptop.
|
|
1440
|
+
error: entry?.last_error ?? null
|
|
1441
|
+
};
|
|
1442
|
+
});
|
|
919
1443
|
if (options.json) {
|
|
920
|
-
process.stdout.write(`${JSON.stringify(
|
|
1444
|
+
process.stdout.write(`${JSON.stringify(rows)}
|
|
921
1445
|
`);
|
|
922
1446
|
return;
|
|
923
1447
|
}
|
|
924
|
-
if (
|
|
925
|
-
process.stdout.write(
|
|
1448
|
+
if (rows.length === 0) {
|
|
1449
|
+
process.stdout.write(
|
|
1450
|
+
options.all ? "no peers configured, and no hosts in ~/.ssh/config\n" : "no peers configured. See what could be added with: murmur peer list --all\n"
|
|
1451
|
+
);
|
|
926
1452
|
return;
|
|
927
1453
|
}
|
|
928
|
-
const
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
[
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
1454
|
+
const showPeerColumn = rows.some((row) => !row.peer);
|
|
1455
|
+
const showVersionColumn = rows.some((row) => row.version !== void 0);
|
|
1456
|
+
process.stdout.write(
|
|
1457
|
+
formatTable([
|
|
1458
|
+
[
|
|
1459
|
+
"NAME",
|
|
1460
|
+
"TARGET",
|
|
1461
|
+
...showPeerColumn ? ["PEER"] : [],
|
|
1462
|
+
"HOSTNAME",
|
|
1463
|
+
...showVersionColumn ? ["VERSION"] : [],
|
|
1464
|
+
"LAST SEEN",
|
|
1465
|
+
"SSH"
|
|
1466
|
+
],
|
|
1467
|
+
...rows.map((row) => [
|
|
1468
|
+
row.name,
|
|
1469
|
+
row.target,
|
|
1470
|
+
...showPeerColumn ? [row.peer ? "yes" : "-"] : [],
|
|
1471
|
+
row.hostname ?? "unknown",
|
|
1472
|
+
...showVersionColumn ? [row.version?.text ?? "-"] : [],
|
|
1473
|
+
row.last_seen ?? "-",
|
|
1474
|
+
row.ssh
|
|
1475
|
+
])
|
|
936
1476
|
])
|
|
937
|
-
|
|
938
|
-
|
|
1477
|
+
);
|
|
1478
|
+
const incompatible = rows.filter((row) => row.version?.incompatible);
|
|
1479
|
+
if (incompatible.length > 0) {
|
|
1480
|
+
process.stdout.write(
|
|
1481
|
+
`
|
|
1482
|
+
${incompatible.length} peer${incompatible.length === 1 ? "" : "s"} speak an incompatible snapshot version; state will not sync until murmur versions match: ${incompatible.map((row) => row.name).join(", ")}
|
|
1483
|
+
`
|
|
1484
|
+
);
|
|
1485
|
+
}
|
|
1486
|
+
const broken = rows.filter((row) => row.error);
|
|
1487
|
+
for (const row of broken) {
|
|
1488
|
+
process.stdout.write(`
|
|
1489
|
+
${row.name}: last attempt failed -- ${row.error}
|
|
1490
|
+
`);
|
|
1491
|
+
}
|
|
1492
|
+
const addable = rows.filter((row) => !row.peer).length;
|
|
1493
|
+
if (addable > 0) {
|
|
1494
|
+
process.stdout.write(
|
|
1495
|
+
`
|
|
1496
|
+
${addable} host${addable === 1 ? "" : "s"} not yet a peer. Add one with: murmur peer add <name>
|
|
1497
|
+
`
|
|
1498
|
+
);
|
|
1499
|
+
}
|
|
939
1500
|
} finally {
|
|
940
1501
|
store.close();
|
|
941
1502
|
}
|
|
942
1503
|
});
|
|
943
|
-
peer.command("discover").description("Check SSH hosts for warm control sockets").action(() => {
|
|
944
|
-
for (const host of sshHosts()) {
|
|
945
|
-
process.stdout.write(`${hasWarmSocket(host) ? "[x]" : "[ ]"} ${host}
|
|
946
|
-
`);
|
|
947
|
-
}
|
|
948
|
-
});
|
|
949
1504
|
}
|
|
950
1505
|
|
|
951
1506
|
// src/cli/pick.ts
|
|
@@ -971,51 +1526,41 @@ function terminalText(value) {
|
|
|
971
1526
|
function shellQuote(value) {
|
|
972
1527
|
return `'${value.replaceAll("'", `'\\''`)}'`;
|
|
973
1528
|
}
|
|
974
|
-
function
|
|
975
|
-
|
|
976
|
-
const peer = store.peers().find((candidate) => candidate.host_id === hostId);
|
|
977
|
-
store.forgetHost(hostId);
|
|
978
|
-
if (peer) {
|
|
979
|
-
store.upsertPeer({
|
|
980
|
-
name: peer.name,
|
|
981
|
-
target: peer.target,
|
|
982
|
-
tmux_down_at: Date.now()
|
|
983
|
-
});
|
|
984
|
-
}
|
|
985
|
-
} catch {
|
|
986
|
-
}
|
|
987
|
-
}
|
|
988
|
-
function forgetReplica(store, agentId, hostId) {
|
|
989
|
-
try {
|
|
990
|
-
store.forgetAgent(agentId);
|
|
991
|
-
const peer = store.peers().find((candidate) => candidate.host_id === hostId);
|
|
992
|
-
if (peer) store.upsertPeer({ name: peer.name, target: peer.target, watermark: 0 });
|
|
993
|
-
} catch {
|
|
994
|
-
}
|
|
1529
|
+
function remoteSessionName(peerName) {
|
|
1530
|
+
return `${peerName.replace(/^[@$%=]+/, "")}~`;
|
|
995
1531
|
}
|
|
996
|
-
|
|
997
|
-
const
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1532
|
+
var spawnRunner = (file, args, inherit = false) => {
|
|
1533
|
+
const result = spawnSync(file, args, {
|
|
1534
|
+
encoding: "utf8",
|
|
1535
|
+
timeout: 1e4,
|
|
1536
|
+
...inherit ? { stdio: "inherit" } : {}
|
|
1537
|
+
});
|
|
1538
|
+
return {
|
|
1539
|
+
status: result.status,
|
|
1540
|
+
stdout: result.stdout ?? "",
|
|
1541
|
+
// spawnSync reports a failure to even start the child in `error`, leaving
|
|
1542
|
+
// status null. Collapsing both here keeps the decision table below reading
|
|
1543
|
+
// as one question rather than two.
|
|
1544
|
+
failed: result.error !== void 0
|
|
1545
|
+
};
|
|
1546
|
+
};
|
|
1547
|
+
function jumpToAgent(store, agent, mux = tmux, run = spawnRunner) {
|
|
1548
|
+
if (agent.local) {
|
|
1549
|
+
const panes = mux.livePanes();
|
|
1550
|
+
if (panes && !panes.has(agent.pane)) {
|
|
1551
|
+
return {
|
|
1552
|
+
ok: false,
|
|
1553
|
+
reason: "pane_gone",
|
|
1554
|
+
message: `${agentLabel(agent)} is gone -- its pane no longer exists.`
|
|
1555
|
+
};
|
|
1002
1556
|
}
|
|
1003
|
-
|
|
1004
|
-
forgetReplica(store, agent.agent_id, agent.host_id);
|
|
1005
|
-
}
|
|
1006
|
-
function jumpToAgent(store, agent) {
|
|
1007
|
-
const identity = loadIdentity();
|
|
1008
|
-
if (agent.host_id === identity?.host_id) {
|
|
1009
|
-
const live = tmux.liveWindows();
|
|
1010
|
-
if (live && !live.has(agent.window)) {
|
|
1011
|
-
forgetReplica(store, agent.agent_id, agent.host_id);
|
|
1557
|
+
if (!mux.attach(agent.session, agent.window)) {
|
|
1012
1558
|
return {
|
|
1013
1559
|
ok: false,
|
|
1014
|
-
reason: "
|
|
1015
|
-
message:
|
|
1560
|
+
reason: "attach_failed",
|
|
1561
|
+
message: `could not attach to ${agentLabel(agent)} (tmux select-window failed).`
|
|
1016
1562
|
};
|
|
1017
1563
|
}
|
|
1018
|
-
tmux.attach(agent.session, agent.window);
|
|
1019
1564
|
return { ok: true };
|
|
1020
1565
|
}
|
|
1021
1566
|
const peer = store.peers().find((candidate) => candidate.host_id === agent.host_id);
|
|
@@ -1027,67 +1572,77 @@ function jumpToAgent(store, agent) {
|
|
|
1027
1572
|
message: `No peer configured for host ${agent.host_id.slice(0, 8)}. Try: murmur peer add <target>`
|
|
1028
1573
|
};
|
|
1029
1574
|
}
|
|
1030
|
-
const probe =
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
);
|
|
1575
|
+
const probe = run("ssh", [
|
|
1576
|
+
...SSH_OPTIONS,
|
|
1577
|
+
target,
|
|
1578
|
+
`tmux list-panes -a -F ${shellQuote("#{pane_id}")}`
|
|
1579
|
+
]);
|
|
1035
1580
|
if (probe.status !== 0) {
|
|
1036
|
-
const sshFailed = probe.status === 255 || probe.
|
|
1581
|
+
const sshFailed = probe.status === 255 || probe.failed;
|
|
1037
1582
|
if (sshFailed) {
|
|
1038
1583
|
return {
|
|
1039
1584
|
ok: false,
|
|
1040
1585
|
reason: "unreachable",
|
|
1041
|
-
message: `cannot reach ${target} over ssh.
|
|
1586
|
+
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.`
|
|
1042
1587
|
};
|
|
1043
1588
|
}
|
|
1044
|
-
forgetHostReplica(store, agent.host_id);
|
|
1045
1589
|
return {
|
|
1046
1590
|
ok: false,
|
|
1047
1591
|
reason: "no_tmux",
|
|
1048
|
-
message: `${target} has no tmux server running, so its agents are gone.
|
|
1592
|
+
message: `${target} has no tmux server running, so its agents are gone. They will disappear on the next collect.`
|
|
1049
1593
|
};
|
|
1050
1594
|
}
|
|
1051
|
-
const
|
|
1052
|
-
if (!
|
|
1053
|
-
forgetReplica(store, agent.agent_id, agent.host_id);
|
|
1595
|
+
const remotePanes = new Set(probe.stdout.split("\n").filter(Boolean).map(asPaneId));
|
|
1596
|
+
if (!remotePanes.has(agent.pane)) {
|
|
1054
1597
|
return {
|
|
1055
1598
|
ok: false,
|
|
1056
|
-
reason: "
|
|
1057
|
-
message: `${agentLabel(agent)} is gone -- ${target} no longer has that
|
|
1599
|
+
reason: "pane_gone",
|
|
1600
|
+
message: `${agentLabel(agent)} is gone -- ${target} no longer has that pane.`
|
|
1058
1601
|
};
|
|
1059
1602
|
}
|
|
1060
1603
|
const attachTarget = shellQuote(`${agent.session}:${agent.window}`);
|
|
1061
1604
|
if (process.env.TMUX) {
|
|
1062
|
-
const
|
|
1063
|
-
const
|
|
1064
|
-
const
|
|
1065
|
-
if (
|
|
1066
|
-
|
|
1067
|
-
|
|
1605
|
+
const client = mux.clientName();
|
|
1606
|
+
const origin = mux.currentTarget();
|
|
1607
|
+
const name = remoteSessionName(peer?.name ?? target);
|
|
1608
|
+
if (mux.sessionNamed(name)) {
|
|
1609
|
+
return mux.switchClient(client, name) ? { ok: true } : {
|
|
1610
|
+
ok: false,
|
|
1611
|
+
reason: "attach_failed",
|
|
1612
|
+
message: `could not switch to the existing ${name} session.`
|
|
1613
|
+
};
|
|
1068
1614
|
}
|
|
1069
|
-
|
|
1070
|
-
|
|
1615
|
+
const attach2 = `ssh -t ${shellQuote(target)} tmux attach -t ${shellQuote(attachTarget)}`;
|
|
1616
|
+
const restore = origin ? `; tmux switch-client ${client ? `-c ${shellQuote(client)} ` : ""}-t ${shellQuote(`=${origin}`)}` : "";
|
|
1617
|
+
if (!mux.newSession(name, `${attach2}${restore}`)) {
|
|
1618
|
+
return {
|
|
1619
|
+
ok: false,
|
|
1620
|
+
reason: "attach_failed",
|
|
1621
|
+
message: `could not open a session to attach to ${target}.`
|
|
1622
|
+
};
|
|
1623
|
+
}
|
|
1624
|
+
mux.setSessionOption(name, "status", "off");
|
|
1625
|
+
mux.setSessionOption(name, "prefix", "None");
|
|
1626
|
+
mux.setSessionOption(name, "detach-on-destroy", "previous");
|
|
1627
|
+
return mux.switchClient(client, name) ? { ok: true } : {
|
|
1628
|
+
ok: false,
|
|
1629
|
+
reason: "attach_failed",
|
|
1630
|
+
message: `attached to ${target} in session ${name}, but could not switch to it.`
|
|
1631
|
+
};
|
|
1071
1632
|
}
|
|
1072
|
-
|
|
1073
|
-
return { ok: true }
|
|
1633
|
+
const attach = run("ssh", ["-t", target, "tmux", "attach", "-t", attachTarget], true);
|
|
1634
|
+
return attach.status === 0 && !attach.failed ? { ok: true } : {
|
|
1635
|
+
ok: false,
|
|
1636
|
+
reason: "attach_failed",
|
|
1637
|
+
message: `ssh attach to ${target} failed.`
|
|
1638
|
+
};
|
|
1074
1639
|
}
|
|
1075
1640
|
|
|
1076
1641
|
// src/glance.ts
|
|
1077
1642
|
import { execFileSync as execFileSync3 } from "child_process";
|
|
1078
1643
|
var GLANCE_LINES = 40;
|
|
1079
|
-
var SSH_OPTIONS2 = [
|
|
1080
|
-
"-o",
|
|
1081
|
-
"BatchMode=yes",
|
|
1082
|
-
"-o",
|
|
1083
|
-
"ControlMaster=no",
|
|
1084
|
-
"-o",
|
|
1085
|
-
"ControlPath=~/.ssh/control/%r@%h:%p",
|
|
1086
|
-
"-o",
|
|
1087
|
-
"ConnectTimeout=2"
|
|
1088
|
-
];
|
|
1089
1644
|
function glance(store, agent, lines = GLANCE_LINES) {
|
|
1090
|
-
if (agent.
|
|
1645
|
+
if (agent.local) return tmux.capture(agent.pane, lines);
|
|
1091
1646
|
const peer = store.peers().find((candidate) => candidate.host_id === agent.host_id);
|
|
1092
1647
|
const target = peer?.target ?? peer?.name;
|
|
1093
1648
|
if (!target) return null;
|
|
@@ -1095,7 +1650,7 @@ function glance(store, agent, lines = GLANCE_LINES) {
|
|
|
1095
1650
|
return execFileSync3(
|
|
1096
1651
|
"ssh",
|
|
1097
1652
|
[
|
|
1098
|
-
...
|
|
1653
|
+
...SSH_OPTIONS,
|
|
1099
1654
|
target,
|
|
1100
1655
|
"tmux",
|
|
1101
1656
|
"capture-pane",
|
|
@@ -1114,91 +1669,60 @@ function glance(store, agent, lines = GLANCE_LINES) {
|
|
|
1114
1669
|
|
|
1115
1670
|
// src/status.ts
|
|
1116
1671
|
function emptyCounts() {
|
|
1117
|
-
|
|
1672
|
+
const counts = {};
|
|
1673
|
+
for (const state of RENDER_PRIORITY) counts[state] = 0;
|
|
1674
|
+
return counts;
|
|
1118
1675
|
}
|
|
1119
1676
|
function tmuxStatus(view) {
|
|
1120
|
-
const
|
|
1121
|
-
|
|
1677
|
+
const needsHuman = new Set(NEEDS_HUMAN);
|
|
1678
|
+
const total = (state) => view.counts[state] + (needsHuman.has(state) ? view.orchestrated_counts[state] : 0);
|
|
1679
|
+
return RENDER_PRIORITY.filter((state) => total(state) > 0).map((state) => `${state === "running" ? "working" : state} ${total(state)}
|
|
1122
1680
|
`).join("");
|
|
1123
1681
|
}
|
|
1124
|
-
function status(store, now = Date.now()) {
|
|
1125
|
-
const identity = loadIdentity();
|
|
1126
|
-
const peers = store.peers();
|
|
1127
|
-
const peersByHost = new Map(
|
|
1128
|
-
peers.flatMap((peer) => peer.host_id === null ? [] : [[peer.host_id, peer]])
|
|
1129
|
-
);
|
|
1130
|
-
const events = store.allEvents();
|
|
1131
|
-
const local = foldAll(
|
|
1132
|
-
events.filter((event) => event.host_id === identity?.host_id),
|
|
1133
|
-
pidAlive
|
|
1134
|
-
);
|
|
1135
|
-
const remote = foldAll(
|
|
1136
|
-
events.filter((event) => event.host_id !== identity?.host_id),
|
|
1137
|
-
() => true
|
|
1138
|
-
);
|
|
1682
|
+
function status(store, identity, now = Date.now()) {
|
|
1139
1683
|
const counts = emptyCounts();
|
|
1140
1684
|
const orchestratedCounts = emptyCounts();
|
|
1141
|
-
const
|
|
1142
|
-
|
|
1143
|
-
const
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
target[state] += 1;
|
|
1147
|
-
return {
|
|
1148
|
-
...agent,
|
|
1149
|
-
fetched_at: fetchedAt,
|
|
1150
|
-
// Replica freshness: how long since we last reached the peer. Local rows
|
|
1151
|
-
// have no fetched_at and are never stale.
|
|
1152
|
-
stale: isStale(fetchedAt, now, STALENESS_MS),
|
|
1153
|
-
age_ms: fetchedAt === null ? null : now - fetchedAt,
|
|
1154
|
-
// Information age: how long since the agent itself said anything. This
|
|
1155
|
-
// is the number a human means by "how stale is that row". A successful
|
|
1156
|
-
// fetch of a three-hour-old event resets age_ms to zero but leaves this
|
|
1157
|
-
// at three hours, which is why they cannot be the same field.
|
|
1158
|
-
event_age_ms: agent.event === null ? null : Math.max(0, now - agent.event.ts),
|
|
1159
|
-
// A jump proved this host's tmux was down and nothing has authored since.
|
|
1160
|
-
// Stronger than staleness: the host answers, its agents are just gone.
|
|
1161
|
-
tmux_down: peer?.tmux_down_at != null,
|
|
1162
|
-
// The name the human typed, not the machine's self-reported hostname. A
|
|
1163
|
-
// peer added as `linuxpc` reported `18c04d69b860` (a container hostname)
|
|
1164
|
-
// and that is what the picker showed — a string that appears nowhere
|
|
1165
|
-
// else in the tool and cannot be typed at `peer remove` or searched for.
|
|
1166
|
-
// Only the local node, which has no peer row, falls back to its own
|
|
1167
|
-
// discovered display_name.
|
|
1168
|
-
host: peer?.name ?? (agent.host_id === identity?.host_id ? identity.display_name : agent.host_id)
|
|
1169
|
-
};
|
|
1170
|
-
});
|
|
1685
|
+
const panes = viewSort(paneViews(store, identity, now));
|
|
1686
|
+
for (const pane of panes) {
|
|
1687
|
+
const target = pane.driver === "human" ? counts : orchestratedCounts;
|
|
1688
|
+
target[renderState(pane)] += 1;
|
|
1689
|
+
}
|
|
1171
1690
|
return {
|
|
1172
1691
|
counts,
|
|
1173
1692
|
orchestrated_counts: orchestratedCounts,
|
|
1174
|
-
|
|
1175
|
-
peers: peers.map((peer) => ({
|
|
1693
|
+
panes,
|
|
1694
|
+
peers: store.peers().map((peer) => ({
|
|
1176
1695
|
name: peer.name,
|
|
1177
1696
|
display_name: peer.display_name,
|
|
1178
1697
|
fetched_at: peer.fetched_at,
|
|
1179
|
-
//
|
|
1180
|
-
//
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
//
|
|
1184
|
-
|
|
1698
|
+
// Their clock and ours, separately: a peer polled a second ago can be
|
|
1699
|
+
// serving a three-hour-old fact, and one number cannot say both.
|
|
1700
|
+
snapshot_at: peer.snapshot_at,
|
|
1701
|
+
last_error: peer.last_error,
|
|
1702
|
+
// The view's verdict, not a second threshold spelled the same way. A
|
|
1703
|
+
// peer we have never reached is stale rather than fresh -- null
|
|
1704
|
+
// `fetched_at` means the first collect has not succeeded yet -- and
|
|
1705
|
+
// `freshness` is the one place that decides, so this list and the panes
|
|
1706
|
+
// the peer contributes cannot disagree about the same host.
|
|
1707
|
+
stale: freshness(peer.fetched_at, now) === "stale"
|
|
1185
1708
|
}))
|
|
1186
1709
|
};
|
|
1187
1710
|
}
|
|
1188
|
-
async function statusWithCollect(store, now = Date.now()) {
|
|
1711
|
+
async function statusWithCollect(store, identity, now = Date.now(), channel = ssh) {
|
|
1189
1712
|
try {
|
|
1190
|
-
await collect(store,
|
|
1191
|
-
} catch
|
|
1192
|
-
process.stderr.write(
|
|
1193
|
-
`murmur: status: collect: ${error instanceof Error ? error.message : String(error)}
|
|
1194
|
-
`
|
|
1195
|
-
);
|
|
1713
|
+
await collect(store, channel, now);
|
|
1714
|
+
} catch {
|
|
1196
1715
|
}
|
|
1197
|
-
return status(store, now);
|
|
1716
|
+
return status(store, identity, now);
|
|
1198
1717
|
}
|
|
1199
1718
|
|
|
1200
1719
|
// src/cli/pick.ts
|
|
1201
|
-
var
|
|
1720
|
+
var spawnFzf = (args, input, env) => spawnSync2("fzf", args, {
|
|
1721
|
+
input,
|
|
1722
|
+
encoding: "utf8",
|
|
1723
|
+
stdio: ["pipe", "pipe", "inherit"],
|
|
1724
|
+
env
|
|
1725
|
+
}).stdout ?? "";
|
|
1202
1726
|
var PREVIEW_MESSAGE_MAX = 300;
|
|
1203
1727
|
var GLYPH = {
|
|
1204
1728
|
crashed: "\u2717",
|
|
@@ -1206,7 +1730,7 @@ var GLYPH = {
|
|
|
1206
1730
|
blocked: "!",
|
|
1207
1731
|
done: "\u2713",
|
|
1208
1732
|
// ✓
|
|
1209
|
-
|
|
1733
|
+
running: "\u25B6",
|
|
1210
1734
|
// ▶
|
|
1211
1735
|
idle: "\xB7"
|
|
1212
1736
|
// ·
|
|
@@ -1215,7 +1739,7 @@ var COLOUR = {
|
|
|
1215
1739
|
crashed: "\x1B[31m",
|
|
1216
1740
|
blocked: "\x1B[33m",
|
|
1217
1741
|
done: "\x1B[36m",
|
|
1218
|
-
|
|
1742
|
+
running: "\x1B[37m",
|
|
1219
1743
|
idle: "\x1B[90m"
|
|
1220
1744
|
};
|
|
1221
1745
|
var ANSI_PATTERN = `${String.fromCharCode(27)}\\[[0-9;]*m`;
|
|
@@ -1226,7 +1750,10 @@ var REMOTE = "\x1B[36m";
|
|
|
1226
1750
|
var BOLD = "\x1B[1m";
|
|
1227
1751
|
var DIM = "\x1B[2m";
|
|
1228
1752
|
var RESET = "\x1B[0m";
|
|
1229
|
-
var
|
|
1753
|
+
var CREW_MARK = "crew ";
|
|
1754
|
+
function isVisible(agent) {
|
|
1755
|
+
return agent.driver === "human" || NEEDS_HUMAN.some((kind) => agent.attention.includes(kind));
|
|
1756
|
+
}
|
|
1230
1757
|
var COLUMNS = {
|
|
1231
1758
|
glyph: 3,
|
|
1232
1759
|
// marker + state glyph
|
|
@@ -1248,25 +1775,23 @@ function headerRow(showHost) {
|
|
|
1248
1775
|
].filter(Boolean).join(" ");
|
|
1249
1776
|
}
|
|
1250
1777
|
var FILTER_KEYS = [
|
|
1251
|
-
["
|
|
1778
|
+
["alt-x", "crashed"],
|
|
1779
|
+
["alt-b", "blocked"],
|
|
1780
|
+
["alt-d", "done"],
|
|
1781
|
+
["alt-w", "running"]
|
|
1782
|
+
];
|
|
1783
|
+
var FILTER_ALIASES = [
|
|
1252
1784
|
["ctrl-x", "crashed"],
|
|
1253
|
-
["ctrl-b", "blocked"],
|
|
1254
1785
|
["ctrl-d", "done"],
|
|
1255
|
-
["ctrl-w", "
|
|
1786
|
+
["ctrl-w", "running"]
|
|
1256
1787
|
];
|
|
1257
|
-
function
|
|
1788
|
+
function timestamp2(ts) {
|
|
1258
1789
|
return new Date(ts).toLocaleTimeString([], {
|
|
1259
1790
|
hour: "2-digit",
|
|
1260
1791
|
minute: "2-digit",
|
|
1261
1792
|
second: "2-digit"
|
|
1262
1793
|
});
|
|
1263
1794
|
}
|
|
1264
|
-
function age(ms) {
|
|
1265
|
-
if (ms === null || ms < 6e4) return "";
|
|
1266
|
-
if (ms < 36e5) return `${Math.floor(ms / 6e4)}m`;
|
|
1267
|
-
if (ms < 864e5) return `${Math.floor(ms / 36e5)}h`;
|
|
1268
|
-
return `${Math.floor(ms / 864e5)}d`;
|
|
1269
|
-
}
|
|
1270
1795
|
function pad(value, width) {
|
|
1271
1796
|
const visible = [...value.replace(ANSI_ESCAPE, "")].length;
|
|
1272
1797
|
if (visible <= width) return value + " ".repeat(width - visible);
|
|
@@ -1291,8 +1816,8 @@ function pad(value, width) {
|
|
|
1291
1816
|
function isPopup(env) {
|
|
1292
1817
|
return Boolean(env.TMUX) && !env.TMUX_PANE;
|
|
1293
1818
|
}
|
|
1294
|
-
function pickerRow(agent, showHost, current, local =
|
|
1295
|
-
const state = agent
|
|
1819
|
+
function pickerRow(agent, showHost, current, local = agent.local) {
|
|
1820
|
+
const state = renderState(agent);
|
|
1296
1821
|
const colour = COLOUR[state] ?? "";
|
|
1297
1822
|
const glyph = GLYPH[state] ?? "?";
|
|
1298
1823
|
const marker = current ? `${BOLD}\u25C6${RESET}` : " ";
|
|
@@ -1300,13 +1825,16 @@ function pickerRow(agent, showHost, current, local = true) {
|
|
|
1300
1825
|
const host = showHost ? local ? `${DIM} here${RESET}` : `${REMOTE}\u2192 ${terminalText(agent.host)}${RESET}` : "";
|
|
1301
1826
|
const group = agent.workstream ?? agent.session_name;
|
|
1302
1827
|
const workstream = group ? `${DIM}${terminalText(group)}${RESET}` : "";
|
|
1828
|
+
const extra = agent.attention.filter((kind) => kind !== state);
|
|
1303
1829
|
const flags = [
|
|
1304
1830
|
agent.driver === "orchestrated" ? "crew" : "",
|
|
1305
|
-
|
|
1306
|
-
//
|
|
1307
|
-
// the
|
|
1308
|
-
agent.
|
|
1309
|
-
|
|
1831
|
+
// Freshness is a property of the NODE, and it is stated explicitly rather
|
|
1832
|
+
// than inferred from an age: a stale node keeps its last-known fields, and
|
|
1833
|
+
// the reader has to be told those fields are old.
|
|
1834
|
+
agent.freshness === "stale" ? "stale host" : "",
|
|
1835
|
+
...extra,
|
|
1836
|
+
agent.activity === "running" && state !== "running" ? "running" : "",
|
|
1837
|
+
age(agent.updated_at === null ? null : Date.now() - agent.updated_at)
|
|
1310
1838
|
].filter(Boolean).join(" ");
|
|
1311
1839
|
const label = [
|
|
1312
1840
|
`${marker} ${colour}${glyph}${RESET}`,
|
|
@@ -1316,51 +1844,62 @@ function pickerRow(agent, showHost, current, local = true) {
|
|
|
1316
1844
|
showHost ? pad(host, COLUMNS.host) : "",
|
|
1317
1845
|
flags ? `${DIM}${flags}${RESET}` : ""
|
|
1318
1846
|
].filter(Boolean).join(" ");
|
|
1319
|
-
return `${agent.
|
|
1847
|
+
return `${agent.host_id} ${agent.pane} ${label}`;
|
|
1320
1848
|
}
|
|
1321
1849
|
function previewText(store, agent) {
|
|
1322
|
-
const state = agent
|
|
1850
|
+
const state = renderState(agent);
|
|
1323
1851
|
const colour = COLOUR[state] ?? "";
|
|
1324
1852
|
const head = [
|
|
1325
1853
|
`${colour}${GLYPH[state] ?? "?"} ${state}${RESET} ${BOLD}${agent.agent_name ? terminalText(agent.agent_name) : agentLabel(agent)}${RESET}`,
|
|
1326
1854
|
// Says where, and whether "where" is this machine. The glance below is a
|
|
1327
1855
|
// local capture-pane or an ssh depending on this one fact, so it belongs in
|
|
1328
1856
|
// the header rather than being inferred from a hostname.
|
|
1329
|
-
agent.
|
|
1857
|
+
agent.local ? `${DIM}here ${agentLocation(agent)}${RESET}` : `${REMOTE}\u2192 ${terminalText(agent.host)}${RESET} ${DIM}${agentLocation(agent)}${RESET}`
|
|
1330
1858
|
];
|
|
1331
1859
|
const facts = [
|
|
1860
|
+
`activity ${agent.activity ?? "none (attention only)"}`,
|
|
1861
|
+
agent.attention.length ? `wants ${agent.attention.join(", ")}` : "",
|
|
1332
1862
|
agent.workstream ? `stream ${terminalText(agent.workstream)}` : "",
|
|
1333
1863
|
agent.role ? `role ${terminalText(agent.role)}` : "",
|
|
1334
1864
|
agent.pi_session ? `session ${terminalText(agent.pi_session)}` : "",
|
|
1865
|
+
agent.cli ? `cli ${terminalText(agent.cli)}` : "",
|
|
1335
1866
|
agent.driver === "orchestrated" ? "driver orchestrated (crew)" : "",
|
|
1336
|
-
|
|
1867
|
+
// Two ages, never one. A node polled a second ago can be serving a
|
|
1868
|
+
// three-hour-old fact, and collapsing them is how that read as fresh.
|
|
1869
|
+
agent.updated_at === null ? "" : `said ${timestamp2(agent.updated_at)}`,
|
|
1870
|
+
agent.local ? "" : `fetched ${agent.fetched_at === null ? "never" : timestamp2(agent.fetched_at)}`,
|
|
1871
|
+
agent.freshness === "stale" ? `${DIM}host is stale: fields below are last-known${RESET}` : ""
|
|
1337
1872
|
].filter(Boolean);
|
|
1338
1873
|
const pane = glance(store, agent);
|
|
1339
|
-
const live = pane?.trimEnd() ? [
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
return `${DIM}${timestamp(event.ts)}${RESET} ${terminalText(event.state).padEnd(8)}${detail}`;
|
|
1348
|
-
}) : [`${DIM}no recorded events${RESET}`];
|
|
1349
|
-
return [...head, "", ...facts, "", ...live, "", `${DIM}\u2500\u2500 history \u2500\u2500${RESET}`, ...history].join(
|
|
1350
|
-
"\n"
|
|
1351
|
-
);
|
|
1874
|
+
const live = pane?.trimEnd() ? [
|
|
1875
|
+
`${DIM}\u2500\u2500 pane \u2500\u2500${RESET}`,
|
|
1876
|
+
pane.trimEnd().slice(-PREVIEW_MESSAGE_MAX * 20)
|
|
1877
|
+
] : [
|
|
1878
|
+
`${DIM}\u2500\u2500 pane \u2500\u2500${RESET}`,
|
|
1879
|
+
`${DIM}unavailable (host unreachable, or pane gone)${RESET}`
|
|
1880
|
+
];
|
|
1881
|
+
return [...head, "", ...facts, "", ...live].join("\n");
|
|
1352
1882
|
}
|
|
1353
|
-
function runPreview(store,
|
|
1354
|
-
const
|
|
1355
|
-
if (!
|
|
1356
|
-
|
|
1357
|
-
|
|
1883
|
+
function runPreview(store, paneId, hostId) {
|
|
1884
|
+
const identity = requireIdentity();
|
|
1885
|
+
if (!identity) return;
|
|
1886
|
+
const agent = status(store, identity).panes.find(
|
|
1887
|
+
(candidate) => candidate.pane === paneId && (hostId === void 0 || candidate.host_id === hostId)
|
|
1888
|
+
);
|
|
1889
|
+
process.stdout.write(
|
|
1890
|
+
agent ? `${previewText(store, agent)}
|
|
1891
|
+
` : `${DIM}${paneId} is no longer here.${RESET}
|
|
1892
|
+
`
|
|
1893
|
+
);
|
|
1358
1894
|
}
|
|
1359
|
-
async function runPick(store, options = {}) {
|
|
1360
|
-
const
|
|
1361
|
-
const
|
|
1362
|
-
const
|
|
1363
|
-
|
|
1895
|
+
async function runPick(store, options = {}, deps = {}) {
|
|
1896
|
+
const fzf = deps.fzf ?? spawnFzf;
|
|
1897
|
+
const jumpTo = deps.jump ?? jumpToAgent;
|
|
1898
|
+
const identity = requireIdentity();
|
|
1899
|
+
if (!identity) return;
|
|
1900
|
+
const view = await statusWithCollect(store, identity);
|
|
1901
|
+
const agents = view.panes.filter((agent2) => options.all || isVisible(agent2));
|
|
1902
|
+
const hidden = view.panes.length - agents.length;
|
|
1364
1903
|
if (agents.length === 0) {
|
|
1365
1904
|
process.stdout.write(
|
|
1366
1905
|
hidden ? `No human agents (+${hidden} crew \u2014 rerun with --all)
|
|
@@ -1368,34 +1907,35 @@ async function runPick(store, options = {}) {
|
|
|
1368
1907
|
);
|
|
1369
1908
|
return;
|
|
1370
1909
|
}
|
|
1371
|
-
const showHost = agents.some((agent2) => agent2.
|
|
1910
|
+
const showHost = agents.some((agent2) => !agent2.local);
|
|
1372
1911
|
const currentPane = process.env.TMUX_PANE ?? "";
|
|
1373
|
-
const input = agents.map(
|
|
1374
|
-
(agent2) => pickerRow(agent2, showHost, agent2.pane === currentPane, agent2.host_id === identity?.host_id)
|
|
1375
|
-
).join("\n");
|
|
1912
|
+
const input = agents.map((agent2) => pickerRow(agent2, showHost, agent2.pane === currentPane)).join("\n");
|
|
1376
1913
|
const counts = /* @__PURE__ */ new Map();
|
|
1377
1914
|
for (const agent2 of agents) {
|
|
1378
|
-
const state = agent2
|
|
1915
|
+
const state = renderState(agent2);
|
|
1379
1916
|
counts.set(state, (counts.get(state) ?? 0) + 1);
|
|
1380
1917
|
}
|
|
1381
|
-
const prompt =
|
|
1918
|
+
const prompt = RENDER_PRIORITY.filter((state) => counts.get(state)).map((state) => `${COLOUR[state]}${GLYPH[state]}${counts.get(state)}${RESET}`).join(" ");
|
|
1919
|
+
const basePrompt = `${prompt}${prompt ? " " : ""}`;
|
|
1382
1920
|
const self = process.argv[1] ?? "murmur";
|
|
1383
1921
|
const allFlag = options.all ? " --all" : "";
|
|
1384
1922
|
const inPopup = isPopup(process.env);
|
|
1385
1923
|
const width = process.stdout.columns ?? 0;
|
|
1386
1924
|
const previewLayout = width > 0 && width < 150 ? "bottom:60%,border-top,wrap" : "right:58%,border-left,wrap";
|
|
1387
|
-
const preview = `${process.execPath} ${self} pick --preview {1}`;
|
|
1388
|
-
const filterBinds =
|
|
1925
|
+
const preview = `${process.execPath} ${self} pick --preview {2} --host {1}`;
|
|
1926
|
+
const filterBinds = [
|
|
1927
|
+
...FILTER_KEYS.map(([key, query]) => [key, query]),
|
|
1928
|
+
...FILTER_ALIASES
|
|
1929
|
+
].flatMap(([key, query]) => [
|
|
1389
1930
|
"--bind",
|
|
1390
|
-
|
|
1931
|
+
query ? `${key}:change-query(${query})` : `${key}:change-query()`
|
|
1391
1932
|
]);
|
|
1392
|
-
const
|
|
1393
|
-
"fzf",
|
|
1933
|
+
const stdout = fzf(
|
|
1394
1934
|
[
|
|
1395
1935
|
"--delimiter",
|
|
1396
1936
|
" ",
|
|
1397
1937
|
"--with-nth",
|
|
1398
|
-
"
|
|
1938
|
+
"3..",
|
|
1399
1939
|
"--ansi",
|
|
1400
1940
|
// Literal substring matching, and matching only the visible columns.
|
|
1401
1941
|
// Default fuzzy scatters query characters across the row: `re` matched
|
|
@@ -1406,7 +1946,7 @@ async function runPick(store, options = {}) {
|
|
|
1406
1946
|
"--exact",
|
|
1407
1947
|
// `begin` ranks earlier match positions higher, so `scratch` puts the
|
|
1408
1948
|
// scratch workstream above a row that merely mentions it. `index` is the
|
|
1409
|
-
// empty-query fallback and preserves the attention order
|
|
1949
|
+
// empty-query fallback and preserves the attention order `viewSort`
|
|
1410
1950
|
// produced, which is the whole point of the list.
|
|
1411
1951
|
"--tiebreak",
|
|
1412
1952
|
"begin,index",
|
|
@@ -1424,13 +1964,21 @@ async function runPick(store, options = {}) {
|
|
|
1424
1964
|
"--info",
|
|
1425
1965
|
"inline",
|
|
1426
1966
|
"--prompt",
|
|
1427
|
-
`${
|
|
1967
|
+
`${options.all ? CREW_MARK : ""}${basePrompt}`,
|
|
1428
1968
|
"--header",
|
|
1429
1969
|
[
|
|
1430
|
-
`
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1970
|
+
// No `del forget`. There is no replica to evict: a reader holds one
|
|
1971
|
+
// snapshot per peer, and the next fetch replaces it whole -- so a delete
|
|
1972
|
+
// key could only remove a row the next collect would put straight back,
|
|
1973
|
+
// while looking like it had done something.
|
|
1974
|
+
`enter jump ^r refresh ^p preview ^u clear`,
|
|
1975
|
+
// "toggle crew", not "show crew": the header is built once and the
|
|
1976
|
+
// binding flips per keypress, so a directional label would be wrong
|
|
1977
|
+
// half the time. The prompt's `crew` marker says which way it is
|
|
1978
|
+
// currently set.
|
|
1979
|
+
`filter: ${FILTER_KEYS.map(([key, query]) => `${key.replace("alt-", "M-")} ${query}`).join(
|
|
1980
|
+
" "
|
|
1981
|
+
)} M-a toggle crew`,
|
|
1434
1982
|
headerRow(showHost)
|
|
1435
1983
|
].filter(Boolean).join("\n"),
|
|
1436
1984
|
"--preview",
|
|
@@ -1445,83 +1993,89 @@ async function runPick(store, options = {}) {
|
|
|
1445
1993
|
"ctrl-p:change-preview-window(bottom:60%,border-top,wrap|hidden|right:58%,border-left,wrap)",
|
|
1446
1994
|
"--bind",
|
|
1447
1995
|
`ctrl-r:reload(${process.execPath} ${self} pick --rows${allFlag})`,
|
|
1448
|
-
//
|
|
1996
|
+
// M-a toggles the POPULATION, which is what "all" means everywhere else in
|
|
1997
|
+
// murmur: the --all flag, and the "crew hidden (--all)" notice.
|
|
1998
|
+
//
|
|
1999
|
+
// It used to be the "clear the filter" key, labelled "all", which is the
|
|
2000
|
+
// collision that made it look broken: pressing it emptied the query
|
|
2001
|
+
// instead of revealing the hidden crew rows named two lines below, and
|
|
2002
|
+
// nothing said why. One word, two meanings, and the wrong one bound to
|
|
2003
|
+
// the key people reach for. Clearing is fzf's own ctrl-u, which needed no
|
|
2004
|
+
// binding at all.
|
|
2005
|
+
//
|
|
2006
|
+
// `transform` rather than a fixed reload, because a bind string is built
|
|
2007
|
+
// once at launch and cannot know it has already fired: binding
|
|
2008
|
+
// `--rows --all` meant the second press re-ran the same thing and the
|
|
2009
|
+
// toggle only worked one way. transform runs a shell snippet per
|
|
2010
|
+
// keypress, so it can branch on the current state.
|
|
1449
2011
|
//
|
|
1450
|
-
// The
|
|
1451
|
-
//
|
|
1452
|
-
//
|
|
1453
|
-
// header that lists both. One is a filter and the other destroys a row,
|
|
1454
|
-
// so a near-miss is a deleted agent. `delete` is the key that already
|
|
1455
|
-
// means remove this, and it collides with no filter letter.
|
|
2012
|
+
// The state lives in the prompt, which is the only mutable string fzf
|
|
2013
|
+
// exposes to a binding. CREW_MARK is carried at the front of it: visible
|
|
2014
|
+
// as a label, and readable back through $FZF_PROMPT.
|
|
1456
2015
|
"--bind",
|
|
1457
|
-
`
|
|
2016
|
+
`alt-a:transform:[[ $FZF_PROMPT == "${CREW_MARK}"* ]] && echo "reload(${process.execPath} ${self} pick --rows)+change-prompt(${basePrompt})" || echo "reload(${process.execPath} ${self} pick --rows --all)+change-prompt(${CREW_MARK}${basePrompt})"`,
|
|
1458
2017
|
...filterBinds,
|
|
1459
2018
|
"--no-select-1",
|
|
1460
2019
|
"--no-exit-0"
|
|
1461
2020
|
],
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
env: Object.fromEntries(
|
|
1469
|
-
Object.entries(process.env).filter(([key]) => !key.startsWith("FZF_DEFAULT_OPTS"))
|
|
1470
|
-
)
|
|
1471
|
-
}
|
|
2021
|
+
input,
|
|
2022
|
+
// FZF_DEFAULT_OPTS can carry a conflicting layout or bindings from the
|
|
2023
|
+
// user's shell; the old picker stripped it for the same reason.
|
|
2024
|
+
Object.fromEntries(
|
|
2025
|
+
Object.entries(process.env).filter(([key]) => !key.startsWith("FZF_DEFAULT_OPTS"))
|
|
2026
|
+
)
|
|
1472
2027
|
);
|
|
1473
|
-
const selected =
|
|
2028
|
+
const [selectedHost, selected] = stdout.trim().split(" ");
|
|
1474
2029
|
if (!selected) return;
|
|
1475
|
-
const agent =
|
|
1476
|
-
|
|
1477
|
-
|
|
2030
|
+
const agent = view.panes.find(
|
|
2031
|
+
(candidate) => candidate.pane === selected && candidate.host_id === selectedHost
|
|
2032
|
+
);
|
|
2033
|
+
if (!agent) {
|
|
2034
|
+
process.stderr.write(`${selected} is no longer here.
|
|
2035
|
+
`);
|
|
2036
|
+
process.exitCode = 1;
|
|
2037
|
+
return;
|
|
2038
|
+
}
|
|
2039
|
+
const jump = jumpTo(store, agent);
|
|
1478
2040
|
if (!jump.ok) {
|
|
1479
2041
|
process.stderr.write(`${jump.message}
|
|
1480
2042
|
`);
|
|
1481
2043
|
process.exitCode = 1;
|
|
1482
2044
|
}
|
|
1483
2045
|
}
|
|
1484
|
-
async function runForget(store, agentId, options = {}) {
|
|
1485
|
-
const view = status(store);
|
|
1486
|
-
const agent = view.agents.find((candidate) => candidate.agent_id === agentId);
|
|
1487
|
-
if (agent) forgetOneAgent(store, agent);
|
|
1488
|
-
await runRows(store, options);
|
|
1489
|
-
}
|
|
1490
2046
|
async function runRows(store, options = {}) {
|
|
1491
|
-
const identity =
|
|
1492
|
-
|
|
1493
|
-
const
|
|
1494
|
-
const
|
|
2047
|
+
const identity = requireIdentity();
|
|
2048
|
+
if (!identity) return;
|
|
2049
|
+
const view = await statusWithCollect(store, identity);
|
|
2050
|
+
const agents = view.panes.filter((agent) => options.all || isVisible(agent));
|
|
2051
|
+
const showHost = agents.some((agent) => !agent.local);
|
|
1495
2052
|
const currentPane = process.env.TMUX_PANE ?? "";
|
|
1496
2053
|
for (const agent of agents) {
|
|
1497
|
-
process.stdout.write(
|
|
1498
|
-
|
|
1499
|
-
`
|
|
1500
|
-
);
|
|
2054
|
+
process.stdout.write(`${pickerRow(agent, showHost, agent.pane === currentPane)}
|
|
2055
|
+
`);
|
|
1501
2056
|
}
|
|
1502
2057
|
}
|
|
1503
2058
|
function registerPick(program2) {
|
|
1504
|
-
program2.command("pick").description("Pick an agent and jump to it").option("--all", "include orchestrated agents").option("--preview <
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
} finally {
|
|
1513
|
-
store.close();
|
|
1514
|
-
}
|
|
2059
|
+
program2.command("pick").description("Pick an agent and jump to it").option("--all", "include orchestrated agents").option("--preview <pane>", "render the preview pane for one pane (internal)").option("--host <host-id>", "host of the pane being previewed (internal)").option("--rows", "print picker rows only (internal, for reload)").action(async (options) => {
|
|
2060
|
+
const store = openStore();
|
|
2061
|
+
try {
|
|
2062
|
+
if (options.preview) runPreview(store, options.preview, options.host);
|
|
2063
|
+
else if (options.rows) await runRows(store, options);
|
|
2064
|
+
else await runPick(store, options);
|
|
2065
|
+
} finally {
|
|
2066
|
+
store.close();
|
|
1515
2067
|
}
|
|
1516
|
-
);
|
|
2068
|
+
});
|
|
1517
2069
|
}
|
|
1518
2070
|
|
|
1519
2071
|
// src/cli/status.ts
|
|
1520
2072
|
function registerStatus(program2) {
|
|
1521
|
-
program2.command("status").description("Show
|
|
2073
|
+
program2.command("status").description("Show current agent status").option("--json", "print JSON").action(async (options) => {
|
|
2074
|
+
const identity = requireIdentity();
|
|
2075
|
+
if (!identity) return;
|
|
1522
2076
|
const store = openStore();
|
|
1523
2077
|
try {
|
|
1524
|
-
const view = await statusWithCollect(store);
|
|
2078
|
+
const view = await statusWithCollect(store, identity);
|
|
1525
2079
|
process.stdout.write(
|
|
1526
2080
|
options.json ? `${JSON.stringify(view, null, 2)}
|
|
1527
2081
|
` : tmuxStatus(view)
|
|
@@ -1532,19 +2086,15 @@ function registerStatus(program2) {
|
|
|
1532
2086
|
});
|
|
1533
2087
|
}
|
|
1534
2088
|
|
|
1535
|
-
// src/index.ts
|
|
1536
|
-
import { createRequire } from "module";
|
|
1537
|
-
var manifest = createRequire(import.meta.url)("../package.json");
|
|
1538
|
-
var VERSION = manifest.version;
|
|
1539
|
-
|
|
1540
2089
|
// src/cli.ts
|
|
1541
2090
|
var program = new Command();
|
|
1542
|
-
program.name("murmur").description("Agent state across every machine, in one view.").version(
|
|
2091
|
+
program.name("murmur").description("Agent state across every machine, in one view.").version(MURMUR_VERSION);
|
|
1543
2092
|
registerInit(program);
|
|
1544
2093
|
registerLink(program);
|
|
1545
2094
|
registerExport(program);
|
|
1546
2095
|
registerCollect(program);
|
|
1547
2096
|
registerClear(program);
|
|
2097
|
+
registerNotify(program);
|
|
1548
2098
|
registerPeer(program);
|
|
1549
2099
|
registerStatus(program);
|
|
1550
2100
|
registerPick(program);
|