@martintrojer/murmur 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,257 @@
1
+ // src/identity.ts
2
+ import { randomUUID } from "crypto";
3
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
4
+ import { hostname } from "os";
5
+ import { join as join2 } from "path";
6
+
7
+ // src/paths.ts
8
+ import { homedir } from "os";
9
+ import { join } from "path";
10
+ function stateDir() {
11
+ return process.env.MURMUR_STATE_DIR ?? join(process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state"), "murmur");
12
+ }
13
+ function dbPath() {
14
+ return join(stateDir(), "events.db");
15
+ }
16
+
17
+ // src/identity.ts
18
+ function loadIdentity() {
19
+ const path = join2(stateDir(), "identity.json");
20
+ return existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) : null;
21
+ }
22
+ function ensureIdentity(displayName = hostname()) {
23
+ const existing = loadIdentity();
24
+ if (existing) return existing;
25
+ const identity = { host_id: randomUUID(), display_name: displayName };
26
+ mkdirSync(stateDir(), { recursive: true });
27
+ writeFileSync(join2(stateDir(), "identity.json"), `${JSON.stringify(identity, null, 2)}
28
+ `);
29
+ return identity;
30
+ }
31
+
32
+ // src/store.ts
33
+ import { rmSync } from "fs";
34
+ import Database from "better-sqlite3";
35
+ var DEFAULT_RETENTION_MS = 7 * 864e5;
36
+ var STORE_VERSION = 2;
37
+ function resetIfStale(path) {
38
+ let salvaged = [];
39
+ try {
40
+ const existing = new Database(path, { fileMustExist: true });
41
+ const version = existing.pragma("user_version", { simple: true }) ?? 0;
42
+ if (version === STORE_VERSION) {
43
+ existing.close();
44
+ return salvaged;
45
+ }
46
+ try {
47
+ salvaged = existing.prepare("SELECT name, target, host_id, display_name FROM peers").all();
48
+ } catch {
49
+ }
50
+ existing.close();
51
+ } catch {
52
+ return salvaged;
53
+ }
54
+ for (const suffix of ["", "-wal", "-shm"]) rmSync(`${path}${suffix}`, { force: true });
55
+ return salvaged;
56
+ }
57
+ function eventValues(event) {
58
+ return [
59
+ event.host_id,
60
+ event.seq,
61
+ event.ts,
62
+ event.agent_id,
63
+ event.session,
64
+ event.window,
65
+ event.pane,
66
+ event.session_name,
67
+ event.window_name,
68
+ event.agent_name,
69
+ event.pi_session,
70
+ event.workstream,
71
+ event.role,
72
+ event.cli,
73
+ event.driver,
74
+ event.kind,
75
+ event.state,
76
+ event.message,
77
+ event.pid,
78
+ Number(event.synthetic),
79
+ event.reason,
80
+ JSON.stringify(event.extra)
81
+ ];
82
+ }
83
+ function toEvent(row) {
84
+ return {
85
+ ...row,
86
+ driver: row.driver,
87
+ synthetic: row.synthetic === 1,
88
+ extra: JSON.parse(row.extra)
89
+ };
90
+ }
91
+ function openStore() {
92
+ const identity = ensureIdentity();
93
+ const path = dbPath();
94
+ const salvagedPeers = resetIfStale(path);
95
+ const database = new Database(path);
96
+ database.pragma("journal_mode = WAL");
97
+ database.pragma(`user_version = ${STORE_VERSION}`);
98
+ database.exec(`
99
+ CREATE TABLE IF NOT EXISTS events (
100
+ host_id TEXT NOT NULL,
101
+ seq INTEGER NOT NULL,
102
+ ts INTEGER NOT NULL,
103
+ agent_id TEXT NOT NULL,
104
+ session TEXT NOT NULL,
105
+ window TEXT NOT NULL,
106
+ pane TEXT NOT NULL,
107
+ session_name TEXT,
108
+ window_name TEXT,
109
+ agent_name TEXT,
110
+ pi_session TEXT,
111
+ workstream TEXT,
112
+ role TEXT,
113
+ cli TEXT,
114
+ driver TEXT,
115
+ kind TEXT NOT NULL,
116
+ state TEXT NOT NULL,
117
+ message TEXT NOT NULL,
118
+ pid INTEGER,
119
+ synthetic INTEGER NOT NULL,
120
+ reason TEXT NOT NULL,
121
+ extra TEXT NOT NULL,
122
+ PRIMARY KEY (host_id, seq)
123
+ );
124
+ CREATE INDEX IF NOT EXISTS events_agent_seq ON events (agent_id, seq);
125
+ CREATE TABLE IF NOT EXISTS peers (
126
+ name TEXT PRIMARY KEY,
127
+ target TEXT NOT NULL,
128
+ host_id TEXT,
129
+ display_name TEXT,
130
+ watermark INTEGER NOT NULL,
131
+ fetched_at INTEGER,
132
+ -- When a jump last proved this peer's tmux was not answering. Reader
133
+ -- state, not an event: this node cannot author facts about another
134
+ -- node's agents, and a jump is a local observation, not something the
135
+ -- peer said. Cleared by the next successful collect.
136
+ tmux_down_at INTEGER
137
+ );
138
+ `);
139
+ try {
140
+ database.exec("ALTER TABLE peers ADD COLUMN tmux_down_at INTEGER");
141
+ } catch {
142
+ }
143
+ if (salvagedPeers.length > 0) {
144
+ const restore = database.prepare(
145
+ `INSERT OR IGNORE INTO peers (name, target, host_id, display_name, watermark, fetched_at)
146
+ VALUES (?, ?, ?, ?, 0, NULL)`
147
+ );
148
+ for (const peer of salvagedPeers) {
149
+ restore.run(peer.name, peer.target, peer.host_id ?? null, peer.display_name ?? null);
150
+ }
151
+ }
152
+ const eventColumns = `
153
+ host_id, seq, ts, agent_id, session, window, pane,
154
+ session_name, window_name, agent_name, pi_session,
155
+ workstream, role, cli, driver, kind, state, message, pid,
156
+ synthetic, reason, extra`;
157
+ const eventPlaceholders = new Array(22).fill("?").join(", ");
158
+ const insertEvent = database.prepare(
159
+ `INSERT INTO events (${eventColumns}) VALUES (${eventPlaceholders})`
160
+ );
161
+ const ingestEvent = database.prepare(
162
+ `INSERT OR IGNORE INTO events (${eventColumns}) VALUES (${eventPlaceholders})`
163
+ );
164
+ const selectMaxSeq = database.prepare(
165
+ "SELECT COALESCE(MAX(seq), 0) AS seq FROM events WHERE host_id = ?"
166
+ );
167
+ const append = database.transaction((event) => {
168
+ const row = selectMaxSeq.get(identity.host_id);
169
+ const stored = {
170
+ ...event,
171
+ host_id: identity.host_id,
172
+ seq: row.seq + 1,
173
+ ts: event.ts ?? Date.now(),
174
+ session_name: event.session_name ?? null,
175
+ window_name: event.window_name ?? null,
176
+ agent_name: event.agent_name ?? null,
177
+ pi_session: event.pi_session ?? null
178
+ };
179
+ insertEvent.run(...eventValues(stored));
180
+ return stored;
181
+ });
182
+ const ingest = database.transaction((events) => {
183
+ let inserted = 0;
184
+ for (const event of events) inserted += ingestEvent.run(...eventValues(event)).changes;
185
+ return inserted;
186
+ });
187
+ return {
188
+ append,
189
+ ingest,
190
+ eventsSince(hostId, seq) {
191
+ const rows = database.prepare("SELECT * FROM events WHERE host_id = ? AND seq > ? ORDER BY seq").all(hostId, seq);
192
+ return rows.map(toEvent);
193
+ },
194
+ allEvents() {
195
+ const rows = database.prepare("SELECT * FROM events ORDER BY ts, host_id, seq").all();
196
+ return rows.map(toEvent);
197
+ },
198
+ maxSeq(hostId) {
199
+ return selectMaxSeq.get(hostId).seq;
200
+ },
201
+ prune(horizonMs = Number(process.env.MURMUR_RETENTION_MS ?? DEFAULT_RETENTION_MS)) {
202
+ return database.prepare(`
203
+ DELETE FROM events
204
+ WHERE ts < ?
205
+ AND (host_id, seq) NOT IN (
206
+ SELECT host_id, seq FROM (
207
+ SELECT host_id, seq,
208
+ ROW_NUMBER() OVER (PARTITION BY agent_id ORDER BY ts DESC, seq DESC) rn
209
+ FROM events
210
+ ) WHERE rn = 1
211
+ )
212
+ `).run(Date.now() - horizonMs).changes;
213
+ },
214
+ peers() {
215
+ return database.prepare("SELECT * FROM peers ORDER BY name").all();
216
+ },
217
+ forgetAgent(agentId) {
218
+ return database.prepare("DELETE FROM events WHERE agent_id = ?").run(agentId).changes;
219
+ },
220
+ forgetHost(hostId) {
221
+ return database.prepare("DELETE FROM events WHERE host_id = ?").run(hostId).changes;
222
+ },
223
+ upsertPeer(peer) {
224
+ const current = database.prepare("SELECT * FROM peers WHERE name = ?").get(peer.name);
225
+ database.prepare(`
226
+ INSERT INTO peers (name, target, host_id, display_name, watermark, fetched_at, tmux_down_at)
227
+ VALUES (?, ?, ?, ?, ?, ?, ?)
228
+ ON CONFLICT(name) DO UPDATE SET
229
+ target = excluded.target,
230
+ host_id = excluded.host_id,
231
+ display_name = excluded.display_name,
232
+ watermark = excluded.watermark,
233
+ fetched_at = excluded.fetched_at,
234
+ tmux_down_at = excluded.tmux_down_at
235
+ `).run(
236
+ peer.name,
237
+ peer.target,
238
+ peer.host_id !== void 0 ? peer.host_id : current?.host_id ?? null,
239
+ peer.display_name !== void 0 ? peer.display_name : current?.display_name ?? null,
240
+ peer.watermark !== void 0 ? peer.watermark : current?.watermark ?? 0,
241
+ peer.fetched_at !== void 0 ? peer.fetched_at : current?.fetched_at ?? null,
242
+ peer.tmux_down_at !== void 0 ? peer.tmux_down_at : current?.tmux_down_at ?? null
243
+ );
244
+ },
245
+ removePeer(name) {
246
+ return database.prepare("DELETE FROM peers WHERE name = ?").run(name).changes > 0;
247
+ },
248
+ close() {
249
+ database.close();
250
+ }
251
+ };
252
+ }
253
+ export {
254
+ loadIdentity,
255
+ openStore
256
+ };
257
+ //# sourceMappingURL=store.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/identity.ts","../../src/paths.ts","../../src/store.ts"],"sourcesContent":["import { randomUUID } from \"node:crypto\";\nimport { existsSync, mkdirSync, readFileSync, writeFileSync } from \"node:fs\";\nimport { hostname } from \"node:os\";\nimport { join } from \"node:path\";\nimport { stateDir } from \"./paths.js\";\n\nexport type NodeIdentity = {\n host_id: string;\n display_name: string;\n};\n\nexport function loadIdentity(): NodeIdentity | null {\n const path = join(stateDir(), \"identity.json\");\n return existsSync(path) ? JSON.parse(readFileSync(path, \"utf8\")) : null;\n}\n\nexport function ensureIdentity(displayName = hostname()): NodeIdentity {\n const existing = loadIdentity();\n if (existing) return existing;\n\n const identity = { host_id: randomUUID(), display_name: displayName };\n mkdirSync(stateDir(), { recursive: true });\n writeFileSync(join(stateDir(), \"identity.json\"), `${JSON.stringify(identity, null, 2)}\\n`);\n return identity;\n}\n","import { homedir } from \"node:os\";\nimport { join } from \"node:path\";\n\nexport function stateDir(): string {\n return (\n process.env.MURMUR_STATE_DIR ??\n join(process.env.XDG_STATE_HOME ?? join(homedir(), \".local\", \"state\"), \"murmur\")\n );\n}\n\nexport function configDir(): string {\n return (\n process.env.MURMUR_CONFIG_DIR ??\n join(process.env.XDG_CONFIG_HOME ?? join(homedir(), \".config\"), \"murmur\")\n );\n}\n\nexport function dbPath(): string {\n return join(stateDir(), \"events.db\");\n}\n","import { rmSync } from \"node:fs\";\nimport Database from \"better-sqlite3\";\nimport { ensureIdentity } from \"./identity.js\";\nimport { dbPath } from \"./paths.js\";\nimport type { Driver, Event, Peer } from \"./types.js\";\n\nconst DEFAULT_RETENTION_MS = 7 * 86_400_000;\n\n/**\n * Local storage shape. Bump on any change to the events or peers tables.\n *\n * Distinct from `SCHEMA_VERSION` in export.ts, which versions the *wire*: a\n * node can change how it stores events without changing what it sends, and a\n * wire change should not throw away local history.\n */\nexport const STORE_VERSION = 2;\n\n/**\n * Migration strategy: there isn't one. A version mismatch deletes the database\n * and starts again.\n *\n * This is only acceptable because nothing in events.db is authoritative or\n * irreplaceable. It is a bounded-retention observability log: remote events\n * re-sync from their authoring peer on the next collect, local agents re-report\n * on their next state change, and node identity deliberately lives in a\n * separate file. If anything durable is ever added here, this stops being safe\n * and a real migration is required.\n *\n * Peers survive, because they are the one thing a human typed. Watermarks are\n * reset with the events they indexed -- keeping them would skip the events the\n * new database no longer has -- and re-reading a peer from zero is free, since\n * ingest is idempotent.\n */\nfunction resetIfStale(path: string): Peer[] {\n let salvaged: Peer[] = [];\n try {\n const existing = new Database(path, { fileMustExist: true });\n const version = (existing.pragma(\"user_version\", { simple: true }) as number) ?? 0;\n if (version === STORE_VERSION) {\n existing.close();\n return salvaged;\n }\n try {\n salvaged = existing\n .prepare(\"SELECT name, target, host_id, display_name FROM peers\")\n .all() as Peer[];\n } catch {\n // Old enough not to have the table, or unreadable. Nothing to save.\n }\n existing.close();\n } catch {\n // No database yet, or one too broken to open. Either way, recreate.\n return salvaged;\n }\n\n // -wal and -shm must go too: a stale sidecar against a fresh main file is a\n // documented way to corrupt sqlite.\n for (const suffix of [\"\", \"-wal\", \"-shm\"]) rmSync(`${path}${suffix}`, { force: true });\n return salvaged;\n}\n\n// The name fields are optional on the way in: a caller that has no name for a\n// thing should not have to say `null` four times, and a non-tmux harness has\n// none of them. They are non-optional on `Event` itself, so a reader never has\n// to distinguish absent from null.\nexport type NewEvent = Omit<\n Event,\n \"host_id\" | \"seq\" | \"ts\" | \"session_name\" | \"window_name\" | \"agent_name\" | \"pi_session\"\n> & {\n ts?: number;\n session_name?: string | null;\n window_name?: string | null;\n agent_name?: string | null;\n pi_session?: string | null;\n};\n\ntype EventRow = Omit<Event, \"synthetic\" | \"extra\"> & {\n synthetic: number;\n extra: string;\n};\n\nfunction eventValues(event: Event): unknown[] {\n return [\n event.host_id,\n event.seq,\n event.ts,\n event.agent_id,\n event.session,\n event.window,\n event.pane,\n event.session_name,\n event.window_name,\n event.agent_name,\n event.pi_session,\n event.workstream,\n event.role,\n event.cli,\n event.driver,\n event.kind,\n event.state,\n event.message,\n event.pid,\n Number(event.synthetic),\n event.reason,\n JSON.stringify(event.extra),\n ];\n}\n\nfunction toEvent(row: EventRow): Event {\n return {\n ...row,\n driver: row.driver as Driver | null,\n synthetic: row.synthetic === 1,\n extra: JSON.parse(row.extra) as Record<string, unknown>,\n };\n}\n\nexport interface Store {\n append(event: NewEvent): Event;\n ingest(events: Event[]): number;\n eventsSince(hostId: string, seq: number): Event[];\n allEvents(): Event[];\n maxSeq(hostId: string): number;\n prune(horizonMs?: number): number;\n peers(): Peer[];\n /**\n * Drop every event for one agent from this node's replica.\n *\n * For a remote agent this is a replica eviction, not a claim about truth: the\n * authoring node still owns it, and a collect re-reads from the watermark if\n * it is still alive.\n */\n forgetAgent(agentId: string): number;\n forgetHost(hostId: string): number;\n upsertPeer(peer: Partial<Peer> & { name: string; target: string }): void;\n removePeer(name: string): boolean;\n close(): void;\n}\n\nexport function openStore(): Store {\n const identity = ensureIdentity();\n const path = dbPath();\n const salvagedPeers = resetIfStale(path);\n const database = new Database(path);\n database.pragma(\"journal_mode = WAL\");\n database.pragma(`user_version = ${STORE_VERSION}`);\n database.exec(`\n CREATE TABLE IF NOT EXISTS events (\n host_id TEXT NOT NULL,\n seq INTEGER NOT NULL,\n ts INTEGER NOT NULL,\n agent_id TEXT NOT NULL,\n session TEXT NOT NULL,\n window TEXT NOT NULL,\n pane TEXT NOT NULL,\n session_name TEXT,\n window_name TEXT,\n agent_name TEXT,\n pi_session TEXT,\n workstream TEXT,\n role TEXT,\n cli TEXT,\n driver TEXT,\n kind TEXT NOT NULL,\n state TEXT NOT NULL,\n message TEXT NOT NULL,\n pid INTEGER,\n synthetic INTEGER NOT NULL,\n reason TEXT NOT NULL,\n extra TEXT NOT NULL,\n PRIMARY KEY (host_id, seq)\n );\n CREATE INDEX IF NOT EXISTS events_agent_seq ON events (agent_id, seq);\n CREATE TABLE IF NOT EXISTS peers (\n name TEXT PRIMARY KEY,\n target TEXT NOT NULL,\n host_id TEXT,\n display_name TEXT,\n watermark INTEGER NOT NULL,\n fetched_at INTEGER,\n -- When a jump last proved this peer's tmux was not answering. Reader\n -- state, not an event: this node cannot author facts about another\n -- node's agents, and a jump is a local observation, not something the\n -- peer said. Cleared by the next successful collect.\n tmux_down_at INTEGER\n );\n `);\n\n // Additive migration: an existing peers table predates tmux_down_at.\n try {\n database.exec(\"ALTER TABLE peers ADD COLUMN tmux_down_at INTEGER\");\n } catch {\n // Already present.\n }\n\n // Put back the peers the wipe took, at watermark 0 so the next collect\n // re-reads each one from the start.\n if (salvagedPeers.length > 0) {\n const restore = database.prepare(\n `INSERT OR IGNORE INTO peers (name, target, host_id, display_name, watermark, fetched_at)\n VALUES (?, ?, ?, ?, 0, NULL)`,\n );\n for (const peer of salvagedPeers) {\n restore.run(peer.name, peer.target, peer.host_id ?? null, peer.display_name ?? null);\n }\n }\n\n const eventColumns = `\n host_id, seq, ts, agent_id, session, window, pane,\n session_name, window_name, agent_name, pi_session,\n workstream, role, cli, driver, kind, state, message, pid,\n synthetic, reason, extra`;\n const eventPlaceholders = new Array(22).fill(\"?\").join(\", \");\n const insertEvent = database.prepare(\n `INSERT INTO events (${eventColumns}) VALUES (${eventPlaceholders})`,\n );\n const ingestEvent = database.prepare(\n `INSERT OR IGNORE INTO events (${eventColumns}) VALUES (${eventPlaceholders})`,\n );\n const selectMaxSeq = database.prepare(\n \"SELECT COALESCE(MAX(seq), 0) AS seq FROM events WHERE host_id = ?\",\n );\n const append = database.transaction((event: NewEvent): Event => {\n const row = selectMaxSeq.get(identity.host_id) as { seq: number };\n const stored: Event = {\n ...event,\n host_id: identity.host_id,\n seq: row.seq + 1,\n ts: event.ts ?? Date.now(),\n session_name: event.session_name ?? null,\n window_name: event.window_name ?? null,\n agent_name: event.agent_name ?? null,\n pi_session: event.pi_session ?? null,\n };\n insertEvent.run(...eventValues(stored));\n return stored;\n });\n const ingest = database.transaction((events: Event[]): number => {\n let inserted = 0;\n for (const event of events) inserted += ingestEvent.run(...eventValues(event)).changes;\n return inserted;\n });\n\n return {\n append,\n ingest,\n eventsSince(hostId, seq) {\n const rows = database\n .prepare(\"SELECT * FROM events WHERE host_id = ? AND seq > ? ORDER BY seq\")\n .all(hostId, seq) as EventRow[];\n return rows.map(toEvent);\n },\n allEvents() {\n const rows = database\n .prepare(\"SELECT * FROM events ORDER BY ts, host_id, seq\")\n .all() as EventRow[];\n return rows.map(toEvent);\n },\n maxSeq(hostId) {\n return (selectMaxSeq.get(hostId) as { seq: number }).seq;\n },\n prune(horizonMs = Number(process.env.MURMUR_RETENTION_MS ?? DEFAULT_RETENTION_MS)) {\n return database\n .prepare(`\n DELETE FROM events\n WHERE ts < ?\n AND (host_id, seq) NOT IN (\n SELECT host_id, seq FROM (\n SELECT host_id, seq,\n ROW_NUMBER() OVER (PARTITION BY agent_id ORDER BY ts DESC, seq DESC) rn\n FROM events\n ) WHERE rn = 1\n )\n `)\n .run(Date.now() - horizonMs).changes;\n },\n peers() {\n return database.prepare(\"SELECT * FROM peers ORDER BY name\").all() as Peer[];\n },\n forgetAgent(agentId) {\n return database.prepare(\"DELETE FROM events WHERE agent_id = ?\").run(agentId).changes;\n },\n forgetHost(hostId) {\n // Every replicated row for one origin node. Only ever called about a\n // REMOTE host: the local host's rows are this node's own authorship and\n // the retention horizon owns them.\n return database.prepare(\"DELETE FROM events WHERE host_id = ?\").run(hostId).changes;\n },\n upsertPeer(peer) {\n const current = database.prepare(\"SELECT * FROM peers WHERE name = ?\").get(peer.name) as\n | Peer\n | undefined;\n database\n .prepare(`\n INSERT INTO peers (name, target, host_id, display_name, watermark, fetched_at, tmux_down_at)\n VALUES (?, ?, ?, ?, ?, ?, ?)\n ON CONFLICT(name) DO UPDATE SET\n target = excluded.target,\n host_id = excluded.host_id,\n display_name = excluded.display_name,\n watermark = excluded.watermark,\n fetched_at = excluded.fetched_at,\n tmux_down_at = excluded.tmux_down_at\n `)\n .run(\n peer.name,\n peer.target,\n peer.host_id !== undefined ? peer.host_id : (current?.host_id ?? null),\n peer.display_name !== undefined ? peer.display_name : (current?.display_name ?? null),\n peer.watermark !== undefined ? peer.watermark : (current?.watermark ?? 0),\n peer.fetched_at !== undefined ? peer.fetched_at : (current?.fetched_at ?? null),\n peer.tmux_down_at !== undefined ? peer.tmux_down_at : (current?.tmux_down_at ?? null),\n );\n },\n removePeer(name) {\n // Drops the peer and its watermark. Replicated events stay: they are\n // real history authored elsewhere, and the retention horizon already\n // ages them out. Re-adding the peer re-syncs from zero, which ingest\n // makes free.\n return database.prepare(\"DELETE FROM peers WHERE name = ?\").run(name).changes > 0;\n },\n close() {\n database.close();\n },\n };\n}\n"],"mappings":"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","names":["join","join"]}
@@ -0,0 +1,216 @@
1
+ type AgentState = "working" | "blocked" | "done" | "crashed" | "cleared";
2
+ type Driver = "human" | "orchestrated";
3
+ declare const DEFAULT_DRIVER: Driver;
4
+ type Event = {
5
+ host_id: string;
6
+ seq: number;
7
+ ts: number;
8
+ agent_id: string;
9
+ session: string;
10
+ window: string;
11
+ pane: string;
12
+ session_name: string | null;
13
+ window_name: string | null;
14
+ agent_name: string | null;
15
+ pi_session: string | null;
16
+ workstream: string | null;
17
+ role: string | null;
18
+ cli: string | null;
19
+ driver: Driver | null;
20
+ kind: string;
21
+ state: AgentState | string;
22
+ message: string;
23
+ pid: number | null;
24
+ synthetic: boolean;
25
+ reason: string;
26
+ extra: Record<string, unknown>;
27
+ };
28
+ type Peer = {
29
+ name: string;
30
+ target: string;
31
+ host_id: string | null;
32
+ display_name: string | null;
33
+ watermark: number;
34
+ fetched_at: number | null;
35
+ /** When a jump last found this peer's tmux server down. Null once it answers. */
36
+ tmux_down_at: number | null;
37
+ };
38
+
39
+ type LiveCheck = (pid: number) => boolean;
40
+ type AgentView = {
41
+ agent_id: string;
42
+ host_id: string;
43
+ state: AgentState | null;
44
+ event: Event | null;
45
+ workstream: string | null;
46
+ role: string | null;
47
+ cli: string | null;
48
+ driver: Driver;
49
+ session: string;
50
+ window: string;
51
+ pane: string;
52
+ session_name: string | null;
53
+ window_name: string | null;
54
+ agent_name: string | null;
55
+ pi_session: string | null;
56
+ fetched_at: number | null;
57
+ };
58
+ declare function foldAgent(events: Event[], isAlive: LiveCheck): {
59
+ state: AgentState | null;
60
+ event: Event | null;
61
+ };
62
+ declare function foldAll(events: Event[], isAlive: LiveCheck): AgentView[];
63
+ declare function attentionSort(views: AgentView[]): AgentView[];
64
+ declare function isStale(fetchedAt: number | null, now: number, thresholdMs?: number): boolean;
65
+
66
+ /**
67
+ * Local storage shape. Bump on any change to the events or peers tables.
68
+ *
69
+ * Distinct from `SCHEMA_VERSION` in export.ts, which versions the *wire*: a
70
+ * node can change how it stores events without changing what it sends, and a
71
+ * wire change should not throw away local history.
72
+ */
73
+ declare const STORE_VERSION = 2;
74
+ type NewEvent = Omit<Event, "host_id" | "seq" | "ts" | "session_name" | "window_name" | "agent_name" | "pi_session"> & {
75
+ ts?: number;
76
+ session_name?: string | null;
77
+ window_name?: string | null;
78
+ agent_name?: string | null;
79
+ pi_session?: string | null;
80
+ };
81
+ interface Store {
82
+ append(event: NewEvent): Event;
83
+ ingest(events: Event[]): number;
84
+ eventsSince(hostId: string, seq: number): Event[];
85
+ allEvents(): Event[];
86
+ maxSeq(hostId: string): number;
87
+ prune(horizonMs?: number): number;
88
+ peers(): Peer[];
89
+ /**
90
+ * Drop every event for one agent from this node's replica.
91
+ *
92
+ * For a remote agent this is a replica eviction, not a claim about truth: the
93
+ * authoring node still owns it, and a collect re-reads from the watermark if
94
+ * it is still alive.
95
+ */
96
+ forgetAgent(agentId: string): number;
97
+ forgetHost(hostId: string): number;
98
+ upsertPeer(peer: Partial<Peer> & {
99
+ name: string;
100
+ target: string;
101
+ }): void;
102
+ removePeer(name: string): boolean;
103
+ close(): void;
104
+ }
105
+ declare function openStore(): Store;
106
+
107
+ type StatusState = "working" | "blocked" | "done" | "crashed" | "idle";
108
+ type Counts = Record<StatusState, number>;
109
+ type Status = {
110
+ counts: Counts;
111
+ orchestrated_counts: Counts;
112
+ agents: (AgentView & {
113
+ stale: boolean;
114
+ age_ms: number | null;
115
+ event_age_ms: number | null;
116
+ tmux_down: boolean;
117
+ host: string;
118
+ })[];
119
+ peers: {
120
+ name: string;
121
+ display_name: string | null;
122
+ fetched_at: number | null;
123
+ stale: boolean;
124
+ }[];
125
+ };
126
+ /**
127
+ * Fold the current view. Pure with respect to the network: the caller decides
128
+ * whether to collect first (see `statusWithCollect`).
129
+ */
130
+ declare function status(store: Store, now?: number): Status;
131
+
132
+ type Agent = Status["agents"][number];
133
+ /**
134
+ * The most specific human-readable name an agent has, never a tmux id.
135
+ *
136
+ * Four sources, most to least specific: mu's agent name, pi's session name,
137
+ * the tmux window name, the tmux session name. The old picker showed window
138
+ * names and that was the thing it did better than raw `$26:@79`; these are all
139
+ * recorded on the event, so this reads the same for a local and a remote agent.
140
+ *
141
+ * Falls back to the window id only when a node recorded no names at all, which
142
+ * means a pre-names event or a non-tmux harness.
143
+ */
144
+ declare function agentLabel(agent: Agent): string;
145
+ /**
146
+ * Where the agent lives, for the second column. Names only -- the ids are what
147
+ * jumps, not what a human reads.
148
+ */
149
+ declare function agentLocation(agent: Agent): string;
150
+ declare function shellQuote(value: string): string;
151
+ type JumpResult = {
152
+ ok: true;
153
+ } | {
154
+ ok: false;
155
+ reason: "no_peer" | "unreachable" | "no_tmux" | "window_gone";
156
+ message: string;
157
+ };
158
+ declare function jumpToAgent(store: Store, agent: Agent): JumpResult;
159
+
160
+ interface Channel {
161
+ exec(target: string, argv: string[]): Promise<string>;
162
+ }
163
+ declare const ssh: Channel;
164
+ declare function hasWarmSocket(target: string): boolean;
165
+
166
+ declare const COLLECT_INTERVAL_MS = 30000;
167
+ declare const STALENESS_MS: number;
168
+ type CollectResult = {
169
+ peer: string;
170
+ ok: boolean;
171
+ ingested: number;
172
+ error?: string;
173
+ };
174
+ declare function collect(store: Store, channel: Channel, now?: number): Promise<CollectResult[]>;
175
+
176
+ declare const SCHEMA_VERSION = 2;
177
+ declare function eventFromWire(wire: Record<string, unknown>): Event;
178
+ declare function exportJsonl(store: Store, since: number, isAlive: LiveCheck, live?: Set<string> | null): string;
179
+
180
+ declare function glance(store: Store, agent: Agent, lines?: number): string | null;
181
+
182
+ type NodeIdentity = {
183
+ host_id: string;
184
+ display_name: string;
185
+ };
186
+ declare function loadIdentity(): NodeIdentity | null;
187
+ declare function ensureIdentity(displayName?: string): NodeIdentity;
188
+
189
+ type Location = {
190
+ session: string;
191
+ window: string;
192
+ pane: string;
193
+ session_name: string | null;
194
+ window_name: string | null;
195
+ };
196
+ interface Mux {
197
+ currentWindow(): Location | null;
198
+ liveWindows(): Set<string> | null;
199
+ setState(window: string, state: AgentState | null): void;
200
+ attach(session: string, window: string): void;
201
+ windowNames(): Map<string, string>;
202
+ windowForPane(pane: string): string | null;
203
+ windowNamed(name: string): string | null;
204
+ selectWindow(window: string): void;
205
+ capture(pane: string, lines?: number): string | null;
206
+ }
207
+ declare const tmux: Mux;
208
+ declare function pidAlive(pid: number): boolean;
209
+
210
+ declare function stateDir(): string;
211
+ declare function configDir(): string;
212
+ declare function dbPath(): string;
213
+
214
+ declare const VERSION = "0.1.0";
215
+
216
+ export { type Agent, type AgentState, type AgentView, COLLECT_INTERVAL_MS, type Channel, type CollectResult, DEFAULT_DRIVER, type Driver, type Event, type JumpResult, type LiveCheck, type Mux, type NewEvent, type NodeIdentity, type Peer, SCHEMA_VERSION, STALENESS_MS, STORE_VERSION, type Status, type Store, VERSION, agentLabel, agentLocation, attentionSort, collect, configDir, dbPath, ensureIdentity, eventFromWire, exportJsonl, foldAgent, foldAll, glance, hasWarmSocket, isStale, jumpToAgent, loadIdentity, openStore, pidAlive, shellQuote, ssh, stateDir, status, tmux };