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