@martintrojer/murmur 0.1.4 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +740 -225
- package/CHANGELOG.md +97 -0
- package/README.md +136 -27
- package/dist/cli.js +1302 -833
- package/dist/cli.js.map +1 -1
- package/dist/extension/murmur-pi.js +189 -107
- package/dist/extension/murmur-pi.js.map +1 -1
- package/dist/extension/store.js +413 -198
- package/dist/extension/store.js.map +1 -1
- package/dist/index.d.ts +479 -187
- package/dist/index.js +848 -607
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
package/dist/cli.js
CHANGED
|
@@ -3,35 +3,15 @@
|
|
|
3
3
|
// src/cli.ts
|
|
4
4
|
import { Command } from "commander";
|
|
5
5
|
|
|
6
|
-
// src/
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
import { hostname } from "os";
|
|
10
|
-
import { join as join2 } from "path";
|
|
11
|
-
|
|
12
|
-
// src/paths.ts
|
|
13
|
-
import { homedir } from "os";
|
|
14
|
-
import { join } from "path";
|
|
15
|
-
function stateDir() {
|
|
16
|
-
return process.env.MURMUR_STATE_DIR ?? join(process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state"), "murmur");
|
|
6
|
+
// src/ids.ts
|
|
7
|
+
function asSessionId(raw) {
|
|
8
|
+
return raw;
|
|
17
9
|
}
|
|
18
|
-
function
|
|
19
|
-
return
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
// src/identity.ts
|
|
23
|
-
function loadIdentity() {
|
|
24
|
-
const path = join2(stateDir(), "identity.json");
|
|
25
|
-
return existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) : null;
|
|
10
|
+
function asWindowId(raw) {
|
|
11
|
+
return raw;
|
|
26
12
|
}
|
|
27
|
-
function
|
|
28
|
-
|
|
29
|
-
if (existing) return existing;
|
|
30
|
-
const identity = { host_id: randomUUID(), display_name: displayName };
|
|
31
|
-
mkdirSync(stateDir(), { recursive: true });
|
|
32
|
-
writeFileSync(join2(stateDir(), "identity.json"), `${JSON.stringify(identity, null, 2)}
|
|
33
|
-
`);
|
|
34
|
-
return identity;
|
|
13
|
+
function asPaneId(raw) {
|
|
14
|
+
return raw;
|
|
35
15
|
}
|
|
36
16
|
|
|
37
17
|
// src/mux.ts
|
|
@@ -47,10 +27,20 @@ function runTmux(args) {
|
|
|
47
27
|
return null;
|
|
48
28
|
}
|
|
49
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
|
+
}
|
|
50
39
|
var tmux = {
|
|
51
40
|
currentWindow() {
|
|
52
|
-
const
|
|
53
|
-
if (!
|
|
41
|
+
const raw = process.env.TMUX_PANE;
|
|
42
|
+
if (!raw) return null;
|
|
43
|
+
const pane = asPaneId(raw);
|
|
54
44
|
const fields = runTmux([
|
|
55
45
|
"display-message",
|
|
56
46
|
"-t",
|
|
@@ -61,38 +51,31 @@ var tmux = {
|
|
|
61
51
|
const [session, window, sessionName, windowName] = fields?.split(" ") ?? [];
|
|
62
52
|
if (!session || !window) return null;
|
|
63
53
|
return {
|
|
64
|
-
session,
|
|
65
|
-
window,
|
|
54
|
+
session: asSessionId(session),
|
|
55
|
+
window: asWindowId(window),
|
|
66
56
|
pane,
|
|
67
57
|
session_name: sessionName || null,
|
|
68
58
|
window_name: windowName || null
|
|
69
59
|
};
|
|
70
60
|
},
|
|
71
|
-
// Which of this host's
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
// null means "could not tell" (no tmux server, tmux missing) and is
|
|
77
|
-
// deliberately distinct from an empty set, which means "tmux answered, and
|
|
78
|
-
// there are no windows". Treating the first as the second would clear every
|
|
79
|
-
// agent on the host the moment tmux was unreachable.
|
|
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.
|
|
80
65
|
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
liveWindows() {
|
|
87
|
-
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}"]);
|
|
88
71
|
if (out === null) return null;
|
|
89
|
-
return new Set(out.split("\n").filter(Boolean));
|
|
72
|
+
return new Set(out.split("\n").filter(Boolean).map(asPaneId));
|
|
90
73
|
},
|
|
91
|
-
|
|
74
|
+
setWindowBadge(window, state) {
|
|
92
75
|
if (state === null) {
|
|
93
76
|
runTmux(["set-window-option", "-qu", "-t", window, "@agent_state"]);
|
|
94
77
|
} else {
|
|
95
|
-
runTmux(["set-window-option", "-q", "-t", window, "@agent_state", state]);
|
|
78
|
+
runTmux(["set-window-option", "-q", "-t", window, "@agent_state", tmuxBadgeState(state)]);
|
|
96
79
|
runTmux(["set-window-option", "-q", "-t", window, "@pane_agent", "1"]);
|
|
97
80
|
}
|
|
98
81
|
runTmux(["refresh-client", "-S"]);
|
|
@@ -101,45 +84,51 @@ var tmux = {
|
|
|
101
84
|
runTmux(["switch-client", "-t", session]);
|
|
102
85
|
return runTmux(["select-window", "-t", window]) !== null;
|
|
103
86
|
},
|
|
104
|
-
// Window ids are what the log stores, because they are stable; names are
|
|
105
|
-
// what a human recognises in a picker. Names are live tmux state, not
|
|
106
|
-
// history, so they are resolved at render time rather than recorded.
|
|
107
|
-
windowNames() {
|
|
108
|
-
const out = runTmux(["list-windows", "-a", "-F", "#{window_id} #{window_name}"]);
|
|
109
|
-
const names = /* @__PURE__ */ new Map();
|
|
110
|
-
for (const line of out?.split("\n") ?? []) {
|
|
111
|
-
const [id, name] = line.split(" ");
|
|
112
|
-
if (id && name) names.set(id, name);
|
|
113
|
-
}
|
|
114
|
-
return names;
|
|
115
|
-
},
|
|
116
|
-
// First window carrying this exact name, or null. Used to reuse a per-host
|
|
117
|
-
// ssh window instead of opening another one.
|
|
118
87
|
// Sibling panes, for deciding whether an unowned pane may clear the window's
|
|
119
88
|
// badge. A window holding an agent and a shell must not lose the badge when
|
|
120
89
|
// you focus the shell.
|
|
121
90
|
panesInWindow(window) {
|
|
122
91
|
const out = runTmux(["list-panes", "-t", window, "-F", "#{pane_id}"]);
|
|
123
|
-
return out?.split("\n").filter(Boolean) ?? [];
|
|
92
|
+
return out?.split("\n").filter(Boolean).map(asPaneId) ?? [];
|
|
124
93
|
},
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
}
|
|
131
|
-
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;
|
|
132
100
|
},
|
|
133
|
-
|
|
134
|
-
|
|
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;
|
|
107
|
+
},
|
|
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]);
|
|
135
121
|
},
|
|
136
|
-
|
|
137
|
-
|
|
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;
|
|
138
126
|
},
|
|
139
|
-
// The window a pane belongs to, for a pane murmur
|
|
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,229 +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
|
-
|
|
327
|
-
`SELECT * FROM events
|
|
328
|
-
WHERE host_id = ? AND agent_id = ?
|
|
329
|
-
ORDER BY seq DESC LIMIT 1`
|
|
330
|
-
).get(hostId, agentId);
|
|
331
|
-
return row ? toEvent(row) : null;
|
|
593
|
+
releaseAgent(release) {
|
|
594
|
+
return deleteAgentOwned.run(release.agent_id, release.owner_pid).changes === 1;
|
|
332
595
|
},
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
) WHERE rn = 1
|
|
346
|
-
)
|
|
347
|
-
`).run(Date.now() - horizonMs).changes;
|
|
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
|
+
});
|
|
348
608
|
},
|
|
349
|
-
|
|
350
|
-
return
|
|
609
|
+
acknowledgePane(pane) {
|
|
610
|
+
return deleteAttentionForPane.run(pane).changes;
|
|
351
611
|
},
|
|
352
|
-
|
|
353
|
-
return
|
|
612
|
+
localPanes() {
|
|
613
|
+
return readLocalPanes();
|
|
354
614
|
},
|
|
355
|
-
|
|
356
|
-
|
|
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
|
+
};
|
|
357
632
|
},
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
INSERT INTO peers (name, target, host_id, display_name, watermark, fetched_at, tmux_down_at)
|
|
362
|
-
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
363
|
-
ON CONFLICT(name) DO UPDATE SET
|
|
364
|
-
target = excluded.target,
|
|
365
|
-
host_id = excluded.host_id,
|
|
366
|
-
display_name = excluded.display_name,
|
|
367
|
-
watermark = excluded.watermark,
|
|
368
|
-
fetched_at = excluded.fetched_at,
|
|
369
|
-
tmux_down_at = excluded.tmux_down_at
|
|
370
|
-
`).run(
|
|
371
|
-
peer.name,
|
|
372
|
-
peer.target,
|
|
373
|
-
peer.host_id !== void 0 ? peer.host_id : current?.host_id ?? null,
|
|
374
|
-
peer.display_name !== void 0 ? peer.display_name : current?.display_name ?? null,
|
|
375
|
-
peer.watermark !== void 0 ? peer.watermark : current?.watermark ?? 0,
|
|
376
|
-
peer.fetched_at !== void 0 ? peer.fetched_at : current?.fetched_at ?? null,
|
|
377
|
-
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
|
|
378
636
|
);
|
|
379
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
|
+
},
|
|
380
644
|
removePeer(name) {
|
|
381
645
|
return database.prepare("DELETE FROM peers WHERE name = ?").run(name).changes > 0;
|
|
382
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
|
+
},
|
|
383
670
|
close() {
|
|
384
671
|
database.close();
|
|
385
672
|
}
|
|
@@ -387,72 +674,32 @@ function openStore() {
|
|
|
387
674
|
}
|
|
388
675
|
|
|
389
676
|
// src/cli/clear.ts
|
|
390
|
-
function
|
|
391
|
-
|
|
392
|
-
const
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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) {
|
|
406
688
|
let store;
|
|
407
689
|
try {
|
|
408
|
-
if (!
|
|
690
|
+
if (!raw) return;
|
|
691
|
+
const pane = asPaneId(raw);
|
|
409
692
|
const window = mux.windowForPane(pane);
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
store = openStore();
|
|
415
|
-
owner = store.latestForAgent(identity.host_id, `${identity.host_id}:${pane}`) ?? void 0;
|
|
416
|
-
} catch {
|
|
417
|
-
}
|
|
418
|
-
}
|
|
419
|
-
if (!owner) {
|
|
420
|
-
if (window && !windowHasAgent(window, pane, identity?.host_id, mux, store)) {
|
|
421
|
-
mux.setState(window, null);
|
|
422
|
-
}
|
|
423
|
-
return;
|
|
424
|
-
}
|
|
425
|
-
if (owner.state === "cleared") {
|
|
426
|
-
mux.setState(owner.window, null);
|
|
427
|
-
return;
|
|
693
|
+
try {
|
|
694
|
+
store = openStore();
|
|
695
|
+
store.acknowledgePane(pane);
|
|
696
|
+
} catch {
|
|
428
697
|
}
|
|
698
|
+
if (!window) return;
|
|
429
699
|
try {
|
|
430
|
-
store
|
|
431
|
-
agent_id: owner.agent_id,
|
|
432
|
-
session: owner.session,
|
|
433
|
-
window: owner.window,
|
|
434
|
-
pane: owner.pane,
|
|
435
|
-
// Carry the names forward: a `cleared` row that drops them makes the
|
|
436
|
-
// agent's last event nameless, which is what left "@75" in the picker.
|
|
437
|
-
session_name: owner.session_name,
|
|
438
|
-
window_name: owner.window_name,
|
|
439
|
-
agent_name: owner.agent_name,
|
|
440
|
-
pi_session: owner.pi_session,
|
|
441
|
-
workstream: owner.workstream,
|
|
442
|
-
role: owner.role,
|
|
443
|
-
cli: owner.cli,
|
|
444
|
-
driver: owner.driver,
|
|
445
|
-
kind: "state",
|
|
446
|
-
state: "cleared",
|
|
447
|
-
message: "",
|
|
448
|
-
pid: null,
|
|
449
|
-
synthetic: false,
|
|
450
|
-
reason: "",
|
|
451
|
-
extra: {}
|
|
452
|
-
});
|
|
700
|
+
mux.setWindowBadge(window, store ? windowBadge(window, mux, store) : null);
|
|
453
701
|
} catch {
|
|
454
702
|
}
|
|
455
|
-
mux.setState(owner.window, null);
|
|
456
703
|
} catch {
|
|
457
704
|
} finally {
|
|
458
705
|
try {
|
|
@@ -462,7 +709,7 @@ function clearPane(pane, mux = tmux) {
|
|
|
462
709
|
}
|
|
463
710
|
}
|
|
464
711
|
function registerClear(program2) {
|
|
465
|
-
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 ?? ""));
|
|
466
713
|
}
|
|
467
714
|
|
|
468
715
|
// src/channel.ts
|
|
@@ -502,193 +749,166 @@ function hasWarmSocket(target) {
|
|
|
502
749
|
}
|
|
503
750
|
}
|
|
504
751
|
|
|
505
|
-
// src/
|
|
506
|
-
var
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
case "working":
|
|
521
|
-
return {
|
|
522
|
-
state: event.pid !== null && event.pid > 0 && isAlive(event.pid) ? "working" : "crashed",
|
|
523
|
-
event
|
|
524
|
-
};
|
|
525
|
-
}
|
|
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";
|
|
758
|
+
}
|
|
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");
|
|
526
767
|
}
|
|
527
|
-
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
for (const event of events) {
|
|
532
|
-
const agentEvents = byAgent.get(event.agent_id);
|
|
533
|
-
if (agentEvents) agentEvents.push(event);
|
|
534
|
-
else byAgent.set(event.agent_id, [event]);
|
|
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}`);
|
|
535
772
|
}
|
|
536
|
-
return
|
|
537
|
-
const folded = foldAgent(agentEvents, isAlive);
|
|
538
|
-
const source = folded.event ?? agentEvents[agentEvents.length - 1];
|
|
539
|
-
if (!source) throw new Error("agent event group cannot be empty");
|
|
540
|
-
return {
|
|
541
|
-
agent_id: source.agent_id,
|
|
542
|
-
host_id: source.host_id,
|
|
543
|
-
state: folded.state,
|
|
544
|
-
event: folded.event,
|
|
545
|
-
workstream: source.workstream,
|
|
546
|
-
role: source.role,
|
|
547
|
-
cli: source.cli,
|
|
548
|
-
driver: source.driver ?? DEFAULT_DRIVER,
|
|
549
|
-
session: source.session,
|
|
550
|
-
window: source.window,
|
|
551
|
-
pane: source.pane,
|
|
552
|
-
session_name: source.session_name,
|
|
553
|
-
window_name: source.window_name,
|
|
554
|
-
agent_name: source.agent_name,
|
|
555
|
-
pi_session: source.pi_session,
|
|
556
|
-
fetched_at: null
|
|
557
|
-
};
|
|
558
|
-
});
|
|
773
|
+
return record;
|
|
559
774
|
}
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
crashed: 2,
|
|
564
|
-
working: 3,
|
|
565
|
-
cleared: 4
|
|
566
|
-
};
|
|
567
|
-
function attentionSort(views) {
|
|
568
|
-
return [...views].sort((left, right) => {
|
|
569
|
-
const stateOrder = (left.state === null ? 4 : ATTENTION_ORDER[left.state]) - (right.state === null ? 4 : ATTENTION_ORDER[right.state]);
|
|
570
|
-
if (stateOrder !== 0) return stateOrder;
|
|
571
|
-
return (right.event?.ts ?? 0) - (left.event?.ts ?? 0);
|
|
572
|
-
});
|
|
775
|
+
function text(value, path) {
|
|
776
|
+
if (typeof value !== "string" || value === "") fail(path, "expected a non-empty string");
|
|
777
|
+
return value;
|
|
573
778
|
}
|
|
574
|
-
function
|
|
575
|
-
|
|
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;
|
|
576
783
|
}
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
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",
|
|
581
805
|
"host_id",
|
|
582
|
-
"
|
|
583
|
-
"
|
|
584
|
-
"
|
|
806
|
+
"display_name",
|
|
807
|
+
"murmur_version",
|
|
808
|
+
"generated_at",
|
|
809
|
+
"panes"
|
|
810
|
+
];
|
|
811
|
+
var PANE_KEYS = [
|
|
812
|
+
"pane",
|
|
585
813
|
"session",
|
|
586
814
|
"window",
|
|
587
|
-
"pane",
|
|
588
815
|
"session_name",
|
|
589
816
|
"window_name",
|
|
817
|
+
"agent",
|
|
818
|
+
"attention"
|
|
819
|
+
];
|
|
820
|
+
var AGENT_KEYS = [
|
|
821
|
+
"agent_id",
|
|
822
|
+
"activity",
|
|
590
823
|
"agent_name",
|
|
591
824
|
"pi_session",
|
|
592
825
|
"workstream",
|
|
593
826
|
"role",
|
|
594
827
|
"cli",
|
|
595
828
|
"driver",
|
|
596
|
-
"
|
|
597
|
-
"
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
function eventToWire(event) {
|
|
604
|
-
const { extra, ...known } = event;
|
|
605
|
-
return { ...extra, ...known };
|
|
606
|
-
}
|
|
607
|
-
function eventFromWire(wire) {
|
|
608
|
-
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);
|
|
609
836
|
return {
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
pi_session: wire.pi_session ?? null,
|
|
621
|
-
workstream: wire.workstream ?? null,
|
|
622
|
-
role: wire.role ?? null,
|
|
623
|
-
cli: wire.cli ?? null,
|
|
624
|
-
driver: wire.driver ?? null,
|
|
625
|
-
kind: wire.kind,
|
|
626
|
-
state: wire.state,
|
|
627
|
-
message: wire.message,
|
|
628
|
-
pid: wire.pid ?? null,
|
|
629
|
-
synthetic: wire.synthetic,
|
|
630
|
-
reason: wire.reason,
|
|
631
|
-
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`)
|
|
632
847
|
};
|
|
633
848
|
}
|
|
634
|
-
function
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
const
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
}
|
|
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");
|
|
649
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
|
+
};
|
|
650
882
|
}
|
|
651
|
-
function
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
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)})`);
|
|
658
889
|
}
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
const { host_id: _hostId, seq: _seq, ts: _ts, ...rest } = event;
|
|
663
|
-
store.append({
|
|
664
|
-
...rest,
|
|
665
|
-
state: "cleared",
|
|
666
|
-
synthetic: true,
|
|
667
|
-
reason: "window_gone",
|
|
668
|
-
message: ""
|
|
669
|
-
});
|
|
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)}`);
|
|
670
893
|
}
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
const
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
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
|
|
681
908
|
};
|
|
682
|
-
const lines = [
|
|
683
|
-
JSON.stringify(envelope),
|
|
684
|
-
...store.eventsSince(identity.host_id, since).map((event) => JSON.stringify(eventToWire(event)))
|
|
685
|
-
];
|
|
686
|
-
return `${lines.join("\n")}
|
|
687
|
-
`;
|
|
688
909
|
}
|
|
689
910
|
|
|
690
911
|
// src/collector.ts
|
|
691
|
-
var STALENESS_MS = 6e4;
|
|
692
912
|
var MAX_CONCURRENT_PEERS = 8;
|
|
693
913
|
var COLLECT_DEADLINE_MS = 4e3;
|
|
694
914
|
async function mapSettled(items, limit, task, deadline) {
|
|
@@ -712,18 +932,28 @@ async function mapSettled(items, limit, task, deadline) {
|
|
|
712
932
|
await (stop ? Promise.race([pool, stop]) : pool);
|
|
713
933
|
return results;
|
|
714
934
|
}
|
|
715
|
-
function
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
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();
|
|
948
|
+
}
|
|
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()})`;
|
|
722
954
|
}
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
events: lines.map((line) => eventFromWire(JSON.parse(line)))
|
|
726
|
-
};
|
|
955
|
+
const detail = collapsed.length > 160 ? `${collapsed.slice(0, 157)}...` : collapsed;
|
|
956
|
+
return `${peer}: ${detail}`;
|
|
727
957
|
}
|
|
728
958
|
async function collect(store, channel, now = Date.now(), deadline) {
|
|
729
959
|
const results = [];
|
|
@@ -737,9 +967,7 @@ async function collect(store, channel, now = Date.now(), deadline) {
|
|
|
737
967
|
const fetches = await mapSettled(
|
|
738
968
|
peers,
|
|
739
969
|
MAX_CONCURRENT_PEERS,
|
|
740
|
-
async (peer) =>
|
|
741
|
-
await channel.exec(peer.target, ["murmur", "export", "--since", String(peer.watermark)])
|
|
742
|
-
),
|
|
970
|
+
async (peer) => parseSnapshot(await channel.exec(peer.target, ["murmur", "export"])),
|
|
743
971
|
bounded
|
|
744
972
|
);
|
|
745
973
|
for (const [index, peer] of peers.entries()) {
|
|
@@ -747,68 +975,98 @@ async function collect(store, channel, now = Date.now(), deadline) {
|
|
|
747
975
|
try {
|
|
748
976
|
if (!fetch) throw new Error("collect deadline passed before this peer answered");
|
|
749
977
|
if (fetch.status === "rejected") throw fetch.reason;
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
const origin = events.filter((event) => event.host_id === envelope.host_id);
|
|
753
|
-
const watermark = origin.reduce(
|
|
754
|
-
(highest, event) => Math.max(highest, event.seq),
|
|
755
|
-
peer.watermark
|
|
756
|
-
);
|
|
757
|
-
store.upsertPeer({
|
|
758
|
-
name: peer.name,
|
|
759
|
-
target: peer.target,
|
|
760
|
-
host_id: envelope.host_id,
|
|
761
|
-
display_name: envelope.display_name,
|
|
762
|
-
watermark,
|
|
763
|
-
fetched_at: now,
|
|
764
|
-
// New events mean the node is authoring again, so whatever a jump
|
|
765
|
-
// observed about its tmux is out of date. Only clear on actual new
|
|
766
|
-
// events: an export that returns nothing proves the binary ran, not
|
|
767
|
-
// that tmux is back, which is the distinction that let a dead host
|
|
768
|
-
// look healthy for three hours.
|
|
769
|
-
//
|
|
770
|
-
// Keyed on the watermark advancing, not on ingest's insert count.
|
|
771
|
-
// Two reasons the count was wrong. Ingest is INSERT OR IGNORE, so a
|
|
772
|
-
// retry after a partial apply re-sees the same events and reports
|
|
773
|
-
// zero -- leaving a recovered host marked down until it happened to
|
|
774
|
-
// author again. And the count includes rows from other origins that
|
|
775
|
-
// this peer merely relayed, which say nothing about whether this
|
|
776
|
-
// peer's tmux is back.
|
|
777
|
-
tmux_down_at: watermark > peer.watermark ? null : peer.tmux_down_at
|
|
778
|
-
});
|
|
779
|
-
results.push({ peer: peer.name, ok: true, ingested });
|
|
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 });
|
|
780
980
|
} catch (error) {
|
|
781
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
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
|
+
});
|
|
785
992
|
}
|
|
786
993
|
}
|
|
787
994
|
} catch (error) {
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
995
|
+
results.push({
|
|
996
|
+
peer: "",
|
|
997
|
+
ok: false,
|
|
998
|
+
panes: 0,
|
|
999
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1000
|
+
});
|
|
792
1001
|
} finally {
|
|
793
1002
|
clearTimeout(timer);
|
|
794
1003
|
}
|
|
795
1004
|
try {
|
|
796
|
-
store.
|
|
797
|
-
} catch
|
|
798
|
-
process.stderr.write(
|
|
799
|
-
`murmur: collect: prune: ${error instanceof Error ? error.message : String(error)}
|
|
800
|
-
`
|
|
801
|
-
);
|
|
1005
|
+
store.reconcileLocal({ panes: tmux.livePanes(), now });
|
|
1006
|
+
} catch {
|
|
802
1007
|
}
|
|
803
1008
|
return results;
|
|
804
1009
|
}
|
|
805
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
|
+
|
|
806
1054
|
// src/cli/collect.ts
|
|
807
1055
|
function registerCollect(program2) {
|
|
808
|
-
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;
|
|
809
1058
|
const store = openStore();
|
|
810
1059
|
try {
|
|
811
|
-
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
|
+
}
|
|
812
1070
|
} finally {
|
|
813
1071
|
store.close();
|
|
814
1072
|
}
|
|
@@ -817,10 +1075,14 @@ function registerCollect(program2) {
|
|
|
817
1075
|
|
|
818
1076
|
// src/cli/export.ts
|
|
819
1077
|
function registerExport(program2) {
|
|
820
|
-
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;
|
|
821
1081
|
const store = openStore();
|
|
822
1082
|
try {
|
|
823
|
-
|
|
1083
|
+
const snapshot = store.buildLocalSnapshot(identity, { panes: tmux.livePanes() });
|
|
1084
|
+
process.stdout.write(`${JSON.stringify(snapshot)}
|
|
1085
|
+
`);
|
|
824
1086
|
} finally {
|
|
825
1087
|
store.close();
|
|
826
1088
|
}
|
|
@@ -830,19 +1092,46 @@ function registerExport(program2) {
|
|
|
830
1092
|
// src/cli/init.ts
|
|
831
1093
|
function registerInit(program2) {
|
|
832
1094
|
program2.command("init").description("Initialize this node's identity").option("--name <name>", "display name").action((opts) => {
|
|
833
|
-
const
|
|
1095
|
+
const existing = loadIdentity();
|
|
1096
|
+
const identity = existing ? opts.name ? setDisplayName(opts.name) : existing : createIdentity(opts.name);
|
|
834
1097
|
console.log(`host_id: ${identity.host_id}`);
|
|
835
1098
|
console.log(`display_name: ${identity.display_name}`);
|
|
836
1099
|
});
|
|
837
1100
|
}
|
|
838
1101
|
|
|
839
1102
|
// src/cli/link.ts
|
|
840
|
-
import { mkdirSync as
|
|
1103
|
+
import { mkdirSync as mkdirSync3, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
841
1104
|
import { homedir as homedir2 } from "os";
|
|
842
|
-
import { dirname, join as join3 } from "path";
|
|
1105
|
+
import { dirname as dirname2, join as join3 } from "path";
|
|
843
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
|
+
}
|
|
844
1130
|
function registerLink(program2) {
|
|
845
|
-
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) => {
|
|
846
1135
|
if (target !== "pi") throw new Error(`unsupported link target: ${target}`);
|
|
847
1136
|
const destination = join3(
|
|
848
1137
|
process.env.MURMUR_PI_HOME ?? homedir2(),
|
|
@@ -851,12 +1140,32 @@ function registerLink(program2) {
|
|
|
851
1140
|
"extensions",
|
|
852
1141
|
"murmur.ts"
|
|
853
1142
|
);
|
|
854
|
-
|
|
855
|
-
const
|
|
856
|
-
fileURLToPath(new URL("./extension/murmur-pi.js", import.meta.url)),
|
|
857
|
-
"utf8"
|
|
858
|
-
);
|
|
1143
|
+
mkdirSync3(dirname2(destination), { recursive: true });
|
|
1144
|
+
const entry = fileURLToPath(new URL("./extension/murmur-pi.js", import.meta.url));
|
|
859
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");
|
|
860
1169
|
const pinned = source.replace(
|
|
861
1170
|
/"@martintrojer\/murmur\/extension-store"/,
|
|
862
1171
|
JSON.stringify(storePath)
|
|
@@ -866,6 +1175,107 @@ function registerLink(program2) {
|
|
|
866
1175
|
}
|
|
867
1176
|
writeFileSync2(destination, pinned);
|
|
868
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
|
+
});
|
|
869
1279
|
});
|
|
870
1280
|
}
|
|
871
1281
|
|
|
@@ -873,6 +1283,7 @@ function registerLink(program2) {
|
|
|
873
1283
|
import { readFileSync as readFileSync3 } from "fs";
|
|
874
1284
|
import { homedir as homedir3 } from "os";
|
|
875
1285
|
import { join as join4 } from "path";
|
|
1286
|
+
var SNAPSHOT_VERSION = 1;
|
|
876
1287
|
function parseSshHosts(config) {
|
|
877
1288
|
const hosts = [];
|
|
878
1289
|
for (const line of config.split("\n")) {
|
|
@@ -891,6 +1302,22 @@ function sshHosts() {
|
|
|
891
1302
|
return [];
|
|
892
1303
|
}
|
|
893
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
|
+
}
|
|
894
1321
|
function formatTable(rows) {
|
|
895
1322
|
const widths = [];
|
|
896
1323
|
for (const row of rows) {
|
|
@@ -904,17 +1331,17 @@ function formatTable(rows) {
|
|
|
904
1331
|
`).join("");
|
|
905
1332
|
}
|
|
906
1333
|
function peerAddDecision(input) {
|
|
907
|
-
const { name, target,
|
|
908
|
-
if (!
|
|
909
|
-
if (
|
|
1334
|
+
const { name, target, snapshot, selfHostId, peers } = input;
|
|
1335
|
+
if (!snapshot) return null;
|
|
1336
|
+
if (snapshot.host_id === selfHostId) {
|
|
910
1337
|
return `${target} is this node; not adding it as a peer
|
|
911
1338
|
`;
|
|
912
1339
|
}
|
|
913
1340
|
const existing = peers.find(
|
|
914
|
-
(candidate) => candidate.host_id ===
|
|
1341
|
+
(candidate) => candidate.host_id === snapshot.host_id && candidate.name !== name
|
|
915
1342
|
);
|
|
916
1343
|
if (existing) {
|
|
917
|
-
return `${target} is already configured as peer "${existing.name}" (${
|
|
1344
|
+
return `${target} is already configured as peer "${existing.name}" (${snapshot.display_name}); remove it first to rename
|
|
918
1345
|
`;
|
|
919
1346
|
}
|
|
920
1347
|
return null;
|
|
@@ -924,17 +1351,16 @@ function registerPeer(program2) {
|
|
|
924
1351
|
peer.command("add").description("Add a peer and discover its identity").argument("<name>").argument("[target]").action(async (name, target = name) => {
|
|
925
1352
|
const store = openStore();
|
|
926
1353
|
try {
|
|
927
|
-
let
|
|
1354
|
+
let snapshot = null;
|
|
928
1355
|
try {
|
|
929
|
-
|
|
930
|
-
envelope = JSON.parse(output.trim().split("\n")[0] ?? "");
|
|
1356
|
+
snapshot = parseSnapshot(await ssh.exec(target, ["murmur", "export"]));
|
|
931
1357
|
} catch {
|
|
932
|
-
|
|
1358
|
+
snapshot = null;
|
|
933
1359
|
}
|
|
934
1360
|
const refusal = peerAddDecision({
|
|
935
1361
|
name,
|
|
936
1362
|
target,
|
|
937
|
-
|
|
1363
|
+
snapshot,
|
|
938
1364
|
selfHostId: loadIdentity()?.host_id ?? null,
|
|
939
1365
|
peers: store.peers()
|
|
940
1366
|
});
|
|
@@ -943,14 +1369,12 @@ function registerPeer(program2) {
|
|
|
943
1369
|
process.exitCode = 1;
|
|
944
1370
|
return;
|
|
945
1371
|
}
|
|
946
|
-
store.
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
display_name: envelope?.display_name ?? null
|
|
951
|
-
});
|
|
1372
|
+
store.addPeer(name, target);
|
|
1373
|
+
if (snapshot) {
|
|
1374
|
+
store.replacePeerSnapshot(name, { ok: true, snapshot, at: Date.now() });
|
|
1375
|
+
}
|
|
952
1376
|
process.stdout.write(
|
|
953
|
-
|
|
1377
|
+
snapshot ? `Added ${name} (${snapshot.display_name})
|
|
954
1378
|
` : `Added ${name} (identity pending)
|
|
955
1379
|
`
|
|
956
1380
|
);
|
|
@@ -972,40 +1396,111 @@ function registerPeer(program2) {
|
|
|
972
1396
|
store.close();
|
|
973
1397
|
}
|
|
974
1398
|
});
|
|
975
|
-
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) => {
|
|
976
1400
|
const store = openStore();
|
|
977
1401
|
try {
|
|
978
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
|
+
});
|
|
979
1443
|
if (options.json) {
|
|
980
|
-
process.stdout.write(`${JSON.stringify(
|
|
1444
|
+
process.stdout.write(`${JSON.stringify(rows)}
|
|
981
1445
|
`);
|
|
982
1446
|
return;
|
|
983
1447
|
}
|
|
984
|
-
if (
|
|
985
|
-
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
|
+
);
|
|
986
1452
|
return;
|
|
987
1453
|
}
|
|
988
|
-
const
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
[
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
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
|
+
])
|
|
996
1476
|
])
|
|
997
|
-
|
|
998
|
-
|
|
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
|
+
}
|
|
999
1500
|
} finally {
|
|
1000
1501
|
store.close();
|
|
1001
1502
|
}
|
|
1002
1503
|
});
|
|
1003
|
-
peer.command("discover").description("Check SSH hosts for warm control sockets").action(() => {
|
|
1004
|
-
for (const host of sshHosts()) {
|
|
1005
|
-
process.stdout.write(`${hasWarmSocket(host) ? "[x]" : "[ ]"} ${host}
|
|
1006
|
-
`);
|
|
1007
|
-
}
|
|
1008
|
-
});
|
|
1009
1504
|
}
|
|
1010
1505
|
|
|
1011
1506
|
// src/cli/pick.ts
|
|
@@ -1031,6 +1526,9 @@ function terminalText(value) {
|
|
|
1031
1526
|
function shellQuote(value) {
|
|
1032
1527
|
return `'${value.replaceAll("'", `'\\''`)}'`;
|
|
1033
1528
|
}
|
|
1529
|
+
function remoteSessionName(peerName) {
|
|
1530
|
+
return `${peerName.replace(/^[@$%=]+/, "")}~`;
|
|
1531
|
+
}
|
|
1034
1532
|
var spawnRunner = (file, args, inherit = false) => {
|
|
1035
1533
|
const result = spawnSync(file, args, {
|
|
1036
1534
|
encoding: "utf8",
|
|
@@ -1046,48 +1544,14 @@ var spawnRunner = (file, args, inherit = false) => {
|
|
|
1046
1544
|
failed: result.error !== void 0
|
|
1047
1545
|
};
|
|
1048
1546
|
};
|
|
1049
|
-
function forgetHostReplica(store, hostId) {
|
|
1050
|
-
try {
|
|
1051
|
-
const peer = store.peers().find((candidate) => candidate.host_id === hostId);
|
|
1052
|
-
store.forgetHost(hostId);
|
|
1053
|
-
if (peer) {
|
|
1054
|
-
store.upsertPeer({
|
|
1055
|
-
name: peer.name,
|
|
1056
|
-
target: peer.target,
|
|
1057
|
-
tmux_down_at: Date.now()
|
|
1058
|
-
});
|
|
1059
|
-
}
|
|
1060
|
-
} catch {
|
|
1061
|
-
}
|
|
1062
|
-
}
|
|
1063
|
-
function forgetReplica(store, agentId, hostId) {
|
|
1064
|
-
try {
|
|
1065
|
-
store.forgetAgent(agentId);
|
|
1066
|
-
const peer = store.peers().find((candidate) => candidate.host_id === hostId);
|
|
1067
|
-
if (peer) store.upsertPeer({ name: peer.name, target: peer.target, watermark: 0 });
|
|
1068
|
-
} catch {
|
|
1069
|
-
}
|
|
1070
|
-
}
|
|
1071
|
-
function forgetOneAgent(store, agent, mux = tmux) {
|
|
1072
|
-
const identity = loadIdentity();
|
|
1073
|
-
if (agent.host_id === identity?.host_id) {
|
|
1074
|
-
try {
|
|
1075
|
-
mux.setState(agent.window, null);
|
|
1076
|
-
} catch {
|
|
1077
|
-
}
|
|
1078
|
-
}
|
|
1079
|
-
forgetReplica(store, agent.agent_id, agent.host_id);
|
|
1080
|
-
}
|
|
1081
1547
|
function jumpToAgent(store, agent, mux = tmux, run = spawnRunner) {
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
if (live && !live.has(agent.window)) {
|
|
1086
|
-
forgetReplica(store, agent.agent_id, agent.host_id);
|
|
1548
|
+
if (agent.local) {
|
|
1549
|
+
const panes = mux.livePanes();
|
|
1550
|
+
if (panes && !panes.has(agent.pane)) {
|
|
1087
1551
|
return {
|
|
1088
1552
|
ok: false,
|
|
1089
|
-
reason: "
|
|
1090
|
-
message: `${agentLabel(agent)} is gone -- its
|
|
1553
|
+
reason: "pane_gone",
|
|
1554
|
+
message: `${agentLabel(agent)} is gone -- its pane no longer exists.`
|
|
1091
1555
|
};
|
|
1092
1556
|
}
|
|
1093
1557
|
if (!mux.attach(agent.session, agent.window)) {
|
|
@@ -1111,7 +1575,7 @@ function jumpToAgent(store, agent, mux = tmux, run = spawnRunner) {
|
|
|
1111
1575
|
const probe = run("ssh", [
|
|
1112
1576
|
...SSH_OPTIONS,
|
|
1113
1577
|
target,
|
|
1114
|
-
`tmux list-
|
|
1578
|
+
`tmux list-panes -a -F ${shellQuote("#{pane_id}")}`
|
|
1115
1579
|
]);
|
|
1116
1580
|
if (probe.status !== 0) {
|
|
1117
1581
|
const sshFailed = probe.status === 255 || probe.failed;
|
|
@@ -1122,38 +1586,48 @@ function jumpToAgent(store, agent, mux = tmux, run = spawnRunner) {
|
|
|
1122
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.`
|
|
1123
1587
|
};
|
|
1124
1588
|
}
|
|
1125
|
-
forgetHostReplica(store, agent.host_id);
|
|
1126
1589
|
return {
|
|
1127
1590
|
ok: false,
|
|
1128
1591
|
reason: "no_tmux",
|
|
1129
|
-
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.`
|
|
1130
1593
|
};
|
|
1131
1594
|
}
|
|
1132
|
-
const
|
|
1133
|
-
if (!
|
|
1134
|
-
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)) {
|
|
1135
1597
|
return {
|
|
1136
1598
|
ok: false,
|
|
1137
|
-
reason: "
|
|
1138
|
-
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.`
|
|
1139
1601
|
};
|
|
1140
1602
|
}
|
|
1141
1603
|
const attachTarget = shellQuote(`${agent.session}:${agent.window}`);
|
|
1142
1604
|
if (process.env.TMUX) {
|
|
1143
|
-
const
|
|
1144
|
-
const
|
|
1145
|
-
const
|
|
1146
|
-
if (
|
|
1147
|
-
return mux.
|
|
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 } : {
|
|
1148
1610
|
ok: false,
|
|
1149
1611
|
reason: "attach_failed",
|
|
1150
|
-
message: `could not switch to the existing ${name}
|
|
1612
|
+
message: `could not switch to the existing ${name} session.`
|
|
1151
1613
|
};
|
|
1152
1614
|
}
|
|
1153
|
-
|
|
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 } : {
|
|
1154
1628
|
ok: false,
|
|
1155
1629
|
reason: "attach_failed",
|
|
1156
|
-
message: `
|
|
1630
|
+
message: `attached to ${target} in session ${name}, but could not switch to it.`
|
|
1157
1631
|
};
|
|
1158
1632
|
}
|
|
1159
1633
|
const attach = run("ssh", ["-t", target, "tmux", "attach", "-t", attachTarget], true);
|
|
@@ -1168,7 +1642,7 @@ function jumpToAgent(store, agent, mux = tmux, run = spawnRunner) {
|
|
|
1168
1642
|
import { execFileSync as execFileSync3 } from "child_process";
|
|
1169
1643
|
var GLANCE_LINES = 40;
|
|
1170
1644
|
function glance(store, agent, lines = GLANCE_LINES) {
|
|
1171
|
-
if (agent.
|
|
1645
|
+
if (agent.local) return tmux.capture(agent.pane, lines);
|
|
1172
1646
|
const peer = store.peers().find((candidate) => candidate.host_id === agent.host_id);
|
|
1173
1647
|
const target = peer?.target ?? peer?.name;
|
|
1174
1648
|
if (!target) return null;
|
|
@@ -1195,91 +1669,60 @@ function glance(store, agent, lines = GLANCE_LINES) {
|
|
|
1195
1669
|
|
|
1196
1670
|
// src/status.ts
|
|
1197
1671
|
function emptyCounts() {
|
|
1198
|
-
|
|
1672
|
+
const counts = {};
|
|
1673
|
+
for (const state of RENDER_PRIORITY) counts[state] = 0;
|
|
1674
|
+
return counts;
|
|
1199
1675
|
}
|
|
1200
1676
|
function tmuxStatus(view) {
|
|
1201
|
-
const
|
|
1202
|
-
|
|
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)}
|
|
1203
1680
|
`).join("");
|
|
1204
1681
|
}
|
|
1205
|
-
function status(store, now = Date.now()) {
|
|
1206
|
-
const identity = loadIdentity();
|
|
1207
|
-
const peers = store.peers();
|
|
1208
|
-
const peersByHost = new Map(
|
|
1209
|
-
peers.flatMap((peer) => peer.host_id === null ? [] : [[peer.host_id, peer]])
|
|
1210
|
-
);
|
|
1211
|
-
const events = store.allEvents();
|
|
1212
|
-
const local = foldAll(
|
|
1213
|
-
events.filter((event) => event.host_id === identity?.host_id),
|
|
1214
|
-
pidAlive
|
|
1215
|
-
);
|
|
1216
|
-
const remote = foldAll(
|
|
1217
|
-
events.filter((event) => event.host_id !== identity?.host_id),
|
|
1218
|
-
() => true
|
|
1219
|
-
);
|
|
1682
|
+
function status(store, identity, now = Date.now()) {
|
|
1220
1683
|
const counts = emptyCounts();
|
|
1221
1684
|
const orchestratedCounts = emptyCounts();
|
|
1222
|
-
const
|
|
1223
|
-
|
|
1224
|
-
const
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
target[state] += 1;
|
|
1228
|
-
return {
|
|
1229
|
-
...agent,
|
|
1230
|
-
fetched_at: fetchedAt,
|
|
1231
|
-
// Replica freshness: how long since we last reached the peer. Local rows
|
|
1232
|
-
// have no fetched_at and are never stale.
|
|
1233
|
-
stale: isStale(fetchedAt, now, STALENESS_MS),
|
|
1234
|
-
age_ms: fetchedAt === null ? null : now - fetchedAt,
|
|
1235
|
-
// Information age: how long since the agent itself said anything. This
|
|
1236
|
-
// is the number a human means by "how stale is that row". A successful
|
|
1237
|
-
// fetch of a three-hour-old event resets age_ms to zero but leaves this
|
|
1238
|
-
// at three hours, which is why they cannot be the same field.
|
|
1239
|
-
event_age_ms: agent.event === null ? null : Math.max(0, now - agent.event.ts),
|
|
1240
|
-
// A jump proved this host's tmux was down and nothing has authored since.
|
|
1241
|
-
// Stronger than staleness: the host answers, its agents are just gone.
|
|
1242
|
-
tmux_down: peer?.tmux_down_at != null,
|
|
1243
|
-
// The name the human typed, not the machine's self-reported hostname. A
|
|
1244
|
-
// peer added as `linuxpc` reported `18c04d69b860` (a container hostname)
|
|
1245
|
-
// and that is what the picker showed — a string that appears nowhere
|
|
1246
|
-
// else in the tool and cannot be typed at `peer remove` or searched for.
|
|
1247
|
-
// Only the local node, which has no peer row, falls back to its own
|
|
1248
|
-
// discovered display_name.
|
|
1249
|
-
host: peer?.name ?? (agent.host_id === identity?.host_id ? identity.display_name : agent.host_id)
|
|
1250
|
-
};
|
|
1251
|
-
});
|
|
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
|
+
}
|
|
1252
1690
|
return {
|
|
1253
1691
|
counts,
|
|
1254
1692
|
orchestrated_counts: orchestratedCounts,
|
|
1255
|
-
|
|
1256
|
-
peers: peers.map((peer) => ({
|
|
1693
|
+
panes,
|
|
1694
|
+
peers: store.peers().map((peer) => ({
|
|
1257
1695
|
name: peer.name,
|
|
1258
1696
|
display_name: peer.display_name,
|
|
1259
1697
|
fetched_at: peer.fetched_at,
|
|
1260
|
-
//
|
|
1261
|
-
//
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
//
|
|
1265
|
-
|
|
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"
|
|
1266
1708
|
}))
|
|
1267
1709
|
};
|
|
1268
1710
|
}
|
|
1269
|
-
async function statusWithCollect(store, now = Date.now(), channel = ssh) {
|
|
1711
|
+
async function statusWithCollect(store, identity, now = Date.now(), channel = ssh) {
|
|
1270
1712
|
try {
|
|
1271
1713
|
await collect(store, channel, now);
|
|
1272
|
-
} catch
|
|
1273
|
-
process.stderr.write(
|
|
1274
|
-
`murmur: status: collect: ${error instanceof Error ? error.message : String(error)}
|
|
1275
|
-
`
|
|
1276
|
-
);
|
|
1714
|
+
} catch {
|
|
1277
1715
|
}
|
|
1278
|
-
return status(store, now);
|
|
1716
|
+
return status(store, identity, now);
|
|
1279
1717
|
}
|
|
1280
1718
|
|
|
1281
1719
|
// src/cli/pick.ts
|
|
1282
|
-
var
|
|
1720
|
+
var spawnFzf = (args, input, env) => spawnSync2("fzf", args, {
|
|
1721
|
+
input,
|
|
1722
|
+
encoding: "utf8",
|
|
1723
|
+
stdio: ["pipe", "pipe", "inherit"],
|
|
1724
|
+
env
|
|
1725
|
+
}).stdout ?? "";
|
|
1283
1726
|
var PREVIEW_MESSAGE_MAX = 300;
|
|
1284
1727
|
var GLYPH = {
|
|
1285
1728
|
crashed: "\u2717",
|
|
@@ -1287,7 +1730,7 @@ var GLYPH = {
|
|
|
1287
1730
|
blocked: "!",
|
|
1288
1731
|
done: "\u2713",
|
|
1289
1732
|
// ✓
|
|
1290
|
-
|
|
1733
|
+
running: "\u25B6",
|
|
1291
1734
|
// ▶
|
|
1292
1735
|
idle: "\xB7"
|
|
1293
1736
|
// ·
|
|
@@ -1296,7 +1739,7 @@ var COLOUR = {
|
|
|
1296
1739
|
crashed: "\x1B[31m",
|
|
1297
1740
|
blocked: "\x1B[33m",
|
|
1298
1741
|
done: "\x1B[36m",
|
|
1299
|
-
|
|
1742
|
+
running: "\x1B[37m",
|
|
1300
1743
|
idle: "\x1B[90m"
|
|
1301
1744
|
};
|
|
1302
1745
|
var ANSI_PATTERN = `${String.fromCharCode(27)}\\[[0-9;]*m`;
|
|
@@ -1307,7 +1750,10 @@ var REMOTE = "\x1B[36m";
|
|
|
1307
1750
|
var BOLD = "\x1B[1m";
|
|
1308
1751
|
var DIM = "\x1B[2m";
|
|
1309
1752
|
var RESET = "\x1B[0m";
|
|
1310
|
-
var
|
|
1753
|
+
var CREW_MARK = "crew ";
|
|
1754
|
+
function isVisible(agent) {
|
|
1755
|
+
return agent.driver === "human" || NEEDS_HUMAN.some((kind) => agent.attention.includes(kind));
|
|
1756
|
+
}
|
|
1311
1757
|
var COLUMNS = {
|
|
1312
1758
|
glyph: 3,
|
|
1313
1759
|
// marker + state glyph
|
|
@@ -1329,25 +1775,23 @@ function headerRow(showHost) {
|
|
|
1329
1775
|
].filter(Boolean).join(" ");
|
|
1330
1776
|
}
|
|
1331
1777
|
var FILTER_KEYS = [
|
|
1332
|
-
["
|
|
1778
|
+
["alt-x", "crashed"],
|
|
1779
|
+
["alt-b", "blocked"],
|
|
1780
|
+
["alt-d", "done"],
|
|
1781
|
+
["alt-w", "running"]
|
|
1782
|
+
];
|
|
1783
|
+
var FILTER_ALIASES = [
|
|
1333
1784
|
["ctrl-x", "crashed"],
|
|
1334
|
-
["ctrl-b", "blocked"],
|
|
1335
1785
|
["ctrl-d", "done"],
|
|
1336
|
-
["ctrl-w", "
|
|
1786
|
+
["ctrl-w", "running"]
|
|
1337
1787
|
];
|
|
1338
|
-
function
|
|
1788
|
+
function timestamp2(ts) {
|
|
1339
1789
|
return new Date(ts).toLocaleTimeString([], {
|
|
1340
1790
|
hour: "2-digit",
|
|
1341
1791
|
minute: "2-digit",
|
|
1342
1792
|
second: "2-digit"
|
|
1343
1793
|
});
|
|
1344
1794
|
}
|
|
1345
|
-
function age(ms) {
|
|
1346
|
-
if (ms === null || ms < 6e4) return "";
|
|
1347
|
-
if (ms < 36e5) return `${Math.floor(ms / 6e4)}m`;
|
|
1348
|
-
if (ms < 864e5) return `${Math.floor(ms / 36e5)}h`;
|
|
1349
|
-
return `${Math.floor(ms / 864e5)}d`;
|
|
1350
|
-
}
|
|
1351
1795
|
function pad(value, width) {
|
|
1352
1796
|
const visible = [...value.replace(ANSI_ESCAPE, "")].length;
|
|
1353
1797
|
if (visible <= width) return value + " ".repeat(width - visible);
|
|
@@ -1372,8 +1816,8 @@ function pad(value, width) {
|
|
|
1372
1816
|
function isPopup(env) {
|
|
1373
1817
|
return Boolean(env.TMUX) && !env.TMUX_PANE;
|
|
1374
1818
|
}
|
|
1375
|
-
function pickerRow(agent, showHost, current, local =
|
|
1376
|
-
const state = agent
|
|
1819
|
+
function pickerRow(agent, showHost, current, local = agent.local) {
|
|
1820
|
+
const state = renderState(agent);
|
|
1377
1821
|
const colour = COLOUR[state] ?? "";
|
|
1378
1822
|
const glyph = GLYPH[state] ?? "?";
|
|
1379
1823
|
const marker = current ? `${BOLD}\u25C6${RESET}` : " ";
|
|
@@ -1381,13 +1825,16 @@ function pickerRow(agent, showHost, current, local = true) {
|
|
|
1381
1825
|
const host = showHost ? local ? `${DIM} here${RESET}` : `${REMOTE}\u2192 ${terminalText(agent.host)}${RESET}` : "";
|
|
1382
1826
|
const group = agent.workstream ?? agent.session_name;
|
|
1383
1827
|
const workstream = group ? `${DIM}${terminalText(group)}${RESET}` : "";
|
|
1828
|
+
const extra = agent.attention.filter((kind) => kind !== state);
|
|
1384
1829
|
const flags = [
|
|
1385
1830
|
agent.driver === "orchestrated" ? "crew" : "",
|
|
1386
|
-
|
|
1387
|
-
//
|
|
1388
|
-
// the
|
|
1389
|
-
agent.
|
|
1390
|
-
|
|
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)
|
|
1391
1838
|
].filter(Boolean).join(" ");
|
|
1392
1839
|
const label = [
|
|
1393
1840
|
`${marker} ${colour}${glyph}${RESET}`,
|
|
@@ -1397,51 +1844,62 @@ function pickerRow(agent, showHost, current, local = true) {
|
|
|
1397
1844
|
showHost ? pad(host, COLUMNS.host) : "",
|
|
1398
1845
|
flags ? `${DIM}${flags}${RESET}` : ""
|
|
1399
1846
|
].filter(Boolean).join(" ");
|
|
1400
|
-
return `${agent.
|
|
1847
|
+
return `${agent.host_id} ${agent.pane} ${label}`;
|
|
1401
1848
|
}
|
|
1402
1849
|
function previewText(store, agent) {
|
|
1403
|
-
const state = agent
|
|
1850
|
+
const state = renderState(agent);
|
|
1404
1851
|
const colour = COLOUR[state] ?? "";
|
|
1405
1852
|
const head = [
|
|
1406
1853
|
`${colour}${GLYPH[state] ?? "?"} ${state}${RESET} ${BOLD}${agent.agent_name ? terminalText(agent.agent_name) : agentLabel(agent)}${RESET}`,
|
|
1407
1854
|
// Says where, and whether "where" is this machine. The glance below is a
|
|
1408
1855
|
// local capture-pane or an ssh depending on this one fact, so it belongs in
|
|
1409
1856
|
// the header rather than being inferred from a hostname.
|
|
1410
|
-
agent.
|
|
1857
|
+
agent.local ? `${DIM}here ${agentLocation(agent)}${RESET}` : `${REMOTE}\u2192 ${terminalText(agent.host)}${RESET} ${DIM}${agentLocation(agent)}${RESET}`
|
|
1411
1858
|
];
|
|
1412
1859
|
const facts = [
|
|
1860
|
+
`activity ${agent.activity ?? "none (attention only)"}`,
|
|
1861
|
+
agent.attention.length ? `wants ${agent.attention.join(", ")}` : "",
|
|
1413
1862
|
agent.workstream ? `stream ${terminalText(agent.workstream)}` : "",
|
|
1414
1863
|
agent.role ? `role ${terminalText(agent.role)}` : "",
|
|
1415
1864
|
agent.pi_session ? `session ${terminalText(agent.pi_session)}` : "",
|
|
1865
|
+
agent.cli ? `cli ${terminalText(agent.cli)}` : "",
|
|
1416
1866
|
agent.driver === "orchestrated" ? "driver orchestrated (crew)" : "",
|
|
1417
|
-
|
|
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}` : ""
|
|
1418
1872
|
].filter(Boolean);
|
|
1419
1873
|
const pane = glance(store, agent);
|
|
1420
|
-
const live = pane?.trimEnd() ? [
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
return `${DIM}${timestamp(event.ts)}${RESET} ${terminalText(event.state).padEnd(8)}${detail}`;
|
|
1429
|
-
}) : [`${DIM}no recorded events${RESET}`];
|
|
1430
|
-
return [...head, "", ...facts, "", ...live, "", `${DIM}\u2500\u2500 history \u2500\u2500${RESET}`, ...history].join(
|
|
1431
|
-
"\n"
|
|
1432
|
-
);
|
|
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");
|
|
1433
1882
|
}
|
|
1434
|
-
function runPreview(store,
|
|
1435
|
-
const
|
|
1436
|
-
if (!
|
|
1437
|
-
|
|
1438
|
-
|
|
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
|
+
);
|
|
1439
1894
|
}
|
|
1440
|
-
async function runPick(store, options = {}) {
|
|
1441
|
-
const
|
|
1442
|
-
const
|
|
1443
|
-
const
|
|
1444
|
-
|
|
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;
|
|
1445
1903
|
if (agents.length === 0) {
|
|
1446
1904
|
process.stdout.write(
|
|
1447
1905
|
hidden ? `No human agents (+${hidden} crew \u2014 rerun with --all)
|
|
@@ -1449,34 +1907,35 @@ async function runPick(store, options = {}) {
|
|
|
1449
1907
|
);
|
|
1450
1908
|
return;
|
|
1451
1909
|
}
|
|
1452
|
-
const showHost = agents.some((agent2) => agent2.
|
|
1910
|
+
const showHost = agents.some((agent2) => !agent2.local);
|
|
1453
1911
|
const currentPane = process.env.TMUX_PANE ?? "";
|
|
1454
|
-
const input = agents.map(
|
|
1455
|
-
(agent2) => pickerRow(agent2, showHost, agent2.pane === currentPane, agent2.host_id === identity?.host_id)
|
|
1456
|
-
).join("\n");
|
|
1912
|
+
const input = agents.map((agent2) => pickerRow(agent2, showHost, agent2.pane === currentPane)).join("\n");
|
|
1457
1913
|
const counts = /* @__PURE__ */ new Map();
|
|
1458
1914
|
for (const agent2 of agents) {
|
|
1459
|
-
const state = agent2
|
|
1915
|
+
const state = renderState(agent2);
|
|
1460
1916
|
counts.set(state, (counts.get(state) ?? 0) + 1);
|
|
1461
1917
|
}
|
|
1462
|
-
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 ? " " : ""}`;
|
|
1463
1920
|
const self = process.argv[1] ?? "murmur";
|
|
1464
1921
|
const allFlag = options.all ? " --all" : "";
|
|
1465
1922
|
const inPopup = isPopup(process.env);
|
|
1466
1923
|
const width = process.stdout.columns ?? 0;
|
|
1467
1924
|
const previewLayout = width > 0 && width < 150 ? "bottom:60%,border-top,wrap" : "right:58%,border-left,wrap";
|
|
1468
|
-
const preview = `${process.execPath} ${self} pick --preview {1}`;
|
|
1469
|
-
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]) => [
|
|
1470
1930
|
"--bind",
|
|
1471
|
-
|
|
1931
|
+
query ? `${key}:change-query(${query})` : `${key}:change-query()`
|
|
1472
1932
|
]);
|
|
1473
|
-
const
|
|
1474
|
-
"fzf",
|
|
1933
|
+
const stdout = fzf(
|
|
1475
1934
|
[
|
|
1476
1935
|
"--delimiter",
|
|
1477
1936
|
" ",
|
|
1478
1937
|
"--with-nth",
|
|
1479
|
-
"
|
|
1938
|
+
"3..",
|
|
1480
1939
|
"--ansi",
|
|
1481
1940
|
// Literal substring matching, and matching only the visible columns.
|
|
1482
1941
|
// Default fuzzy scatters query characters across the row: `re` matched
|
|
@@ -1487,7 +1946,7 @@ async function runPick(store, options = {}) {
|
|
|
1487
1946
|
"--exact",
|
|
1488
1947
|
// `begin` ranks earlier match positions higher, so `scratch` puts the
|
|
1489
1948
|
// scratch workstream above a row that merely mentions it. `index` is the
|
|
1490
|
-
// empty-query fallback and preserves the attention order
|
|
1949
|
+
// empty-query fallback and preserves the attention order `viewSort`
|
|
1491
1950
|
// produced, which is the whole point of the list.
|
|
1492
1951
|
"--tiebreak",
|
|
1493
1952
|
"begin,index",
|
|
@@ -1505,13 +1964,21 @@ async function runPick(store, options = {}) {
|
|
|
1505
1964
|
"--info",
|
|
1506
1965
|
"inline",
|
|
1507
1966
|
"--prompt",
|
|
1508
|
-
`${
|
|
1967
|
+
`${options.all ? CREW_MARK : ""}${basePrompt}`,
|
|
1509
1968
|
"--header",
|
|
1510
1969
|
[
|
|
1511
|
-
`
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
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`,
|
|
1515
1982
|
headerRow(showHost)
|
|
1516
1983
|
].filter(Boolean).join("\n"),
|
|
1517
1984
|
"--preview",
|
|
@@ -1526,83 +1993,89 @@ async function runPick(store, options = {}) {
|
|
|
1526
1993
|
"ctrl-p:change-preview-window(bottom:60%,border-top,wrap|hidden|right:58%,border-left,wrap)",
|
|
1527
1994
|
"--bind",
|
|
1528
1995
|
`ctrl-r:reload(${process.execPath} ${self} pick --rows${allFlag})`,
|
|
1529
|
-
//
|
|
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.
|
|
1530
2005
|
//
|
|
1531
|
-
//
|
|
1532
|
-
//
|
|
1533
|
-
//
|
|
1534
|
-
//
|
|
1535
|
-
// so
|
|
1536
|
-
//
|
|
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.
|
|
2011
|
+
//
|
|
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.
|
|
1537
2015
|
"--bind",
|
|
1538
|
-
`
|
|
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})"`,
|
|
1539
2017
|
...filterBinds,
|
|
1540
2018
|
"--no-select-1",
|
|
1541
2019
|
"--no-exit-0"
|
|
1542
2020
|
],
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
env: Object.fromEntries(
|
|
1550
|
-
Object.entries(process.env).filter(([key]) => !key.startsWith("FZF_DEFAULT_OPTS"))
|
|
1551
|
-
)
|
|
1552
|
-
}
|
|
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
|
+
)
|
|
1553
2027
|
);
|
|
1554
|
-
const selected =
|
|
2028
|
+
const [selectedHost, selected] = stdout.trim().split(" ");
|
|
1555
2029
|
if (!selected) return;
|
|
1556
|
-
const agent =
|
|
1557
|
-
|
|
1558
|
-
|
|
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);
|
|
1559
2040
|
if (!jump.ok) {
|
|
1560
2041
|
process.stderr.write(`${jump.message}
|
|
1561
2042
|
`);
|
|
1562
2043
|
process.exitCode = 1;
|
|
1563
2044
|
}
|
|
1564
2045
|
}
|
|
1565
|
-
async function runForget(store, agentId, options = {}) {
|
|
1566
|
-
const view = status(store);
|
|
1567
|
-
const agent = view.agents.find((candidate) => candidate.agent_id === agentId);
|
|
1568
|
-
if (agent) forgetOneAgent(store, agent);
|
|
1569
|
-
await runRows(store, options);
|
|
1570
|
-
}
|
|
1571
2046
|
async function runRows(store, options = {}) {
|
|
1572
|
-
const identity =
|
|
1573
|
-
|
|
1574
|
-
const
|
|
1575
|
-
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);
|
|
1576
2052
|
const currentPane = process.env.TMUX_PANE ?? "";
|
|
1577
2053
|
for (const agent of agents) {
|
|
1578
|
-
process.stdout.write(
|
|
1579
|
-
|
|
1580
|
-
`
|
|
1581
|
-
);
|
|
2054
|
+
process.stdout.write(`${pickerRow(agent, showHost, agent.pane === currentPane)}
|
|
2055
|
+
`);
|
|
1582
2056
|
}
|
|
1583
2057
|
}
|
|
1584
2058
|
function registerPick(program2) {
|
|
1585
|
-
program2.command("pick").description("Pick an agent and jump to it").option("--all", "include orchestrated agents").option("--preview <
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
} finally {
|
|
1594
|
-
store.close();
|
|
1595
|
-
}
|
|
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();
|
|
1596
2067
|
}
|
|
1597
|
-
);
|
|
2068
|
+
});
|
|
1598
2069
|
}
|
|
1599
2070
|
|
|
1600
2071
|
// src/cli/status.ts
|
|
1601
2072
|
function registerStatus(program2) {
|
|
1602
|
-
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;
|
|
1603
2076
|
const store = openStore();
|
|
1604
2077
|
try {
|
|
1605
|
-
const view = await statusWithCollect(store);
|
|
2078
|
+
const view = await statusWithCollect(store, identity);
|
|
1606
2079
|
process.stdout.write(
|
|
1607
2080
|
options.json ? `${JSON.stringify(view, null, 2)}
|
|
1608
2081
|
` : tmuxStatus(view)
|
|
@@ -1613,19 +2086,15 @@ function registerStatus(program2) {
|
|
|
1613
2086
|
});
|
|
1614
2087
|
}
|
|
1615
2088
|
|
|
1616
|
-
// src/index.ts
|
|
1617
|
-
import { createRequire } from "module";
|
|
1618
|
-
var manifest = createRequire(import.meta.url)("../package.json");
|
|
1619
|
-
var VERSION = manifest.version;
|
|
1620
|
-
|
|
1621
2089
|
// src/cli.ts
|
|
1622
2090
|
var program = new Command();
|
|
1623
|
-
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);
|
|
1624
2092
|
registerInit(program);
|
|
1625
2093
|
registerLink(program);
|
|
1626
2094
|
registerExport(program);
|
|
1627
2095
|
registerCollect(program);
|
|
1628
2096
|
registerClear(program);
|
|
2097
|
+
registerNotify(program);
|
|
1629
2098
|
registerPeer(program);
|
|
1630
2099
|
registerStatus(program);
|
|
1631
2100
|
registerPick(program);
|