@agentpulselabs/cli-plugin-pulse 1.0.0 → 1.1.2

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.
Files changed (101) hide show
  1. package/README.md +46 -0
  2. package/package.json +7 -4
  3. package/src/clients/hub-bridge.mjs +99 -0
  4. package/src/clients/hub.mjs +324 -0
  5. package/src/commands/pulse/cutover.js +39 -0
  6. package/src/commands/pulse/doctor.js +1 -0
  7. package/src/commands/pulse/import.js +62 -0
  8. package/src/commands/pulse/log-write.js +26 -0
  9. package/src/commands/pulse/parity.js +20 -0
  10. package/src/commands/pulse/shadow.js +19 -0
  11. package/src/daemon/main.js +19 -12
  12. package/src/hub/core/auth.mjs +80 -0
  13. package/src/hub/core/changes.mjs +38 -0
  14. package/src/hub/core/conversations.mjs +121 -0
  15. package/src/hub/core/db.mjs +263 -0
  16. package/src/hub/core/edgeIds.mjs +9 -0
  17. package/src/hub/core/hub.mjs +20 -0
  18. package/src/hub/core/ids.mjs +39 -0
  19. package/src/hub/core/leases.mjs +55 -0
  20. package/src/hub/core/messaging.mjs +322 -0
  21. package/src/hub/core/registry.mjs +281 -0
  22. package/src/hub/core/ticks.mjs +58 -0
  23. package/src/hub/core/wake.mjs +125 -0
  24. package/src/hub/home.mjs +16 -0
  25. package/src/hub/hooks/hub-bridge.mjs +167 -0
  26. package/src/hub/keepawake/agent.mjs +96 -0
  27. package/src/hub/lib/brief.mjs +133 -0
  28. package/src/hub/lib/channels.mjs +268 -0
  29. package/src/hub/lib/chat.mjs +163 -0
  30. package/src/hub/lib/closure.mjs +92 -0
  31. package/src/hub/lib/collect-worker.mjs +20 -0
  32. package/src/hub/lib/collect.mjs +342 -0
  33. package/src/hub/lib/deliveries.mjs +222 -0
  34. package/src/hub/lib/git.mjs +93 -0
  35. package/src/hub/lib/me.mjs +123 -0
  36. package/src/hub/lib/personas.mjs +143 -0
  37. package/src/hub/lib/status.mjs +147 -0
  38. package/src/hub/lib/store.mjs +106 -0
  39. package/src/hub/lib/titles.mjs +29 -0
  40. package/src/hub/lib/transcript-feed.mjs +72 -0
  41. package/src/hub/lib/transcripts.mjs +364 -0
  42. package/src/hub/lib/writing.mjs +18 -0
  43. package/src/hub/projections/deliveries.mjs +31 -0
  44. package/src/hub/projections/graph.mjs +149 -0
  45. package/src/hub/runners/drivers.mjs +162 -0
  46. package/src/hub/workers/collector.mjs +128 -0
  47. package/src/hub/workers/manager.mjs +366 -0
  48. package/src/hub/workers/runner.mjs +145 -0
  49. package/src/lib/application.js +82 -34
  50. package/src/lib/channelScanner.js +99 -0
  51. package/src/lib/clientInstaller.js +111 -0
  52. package/src/lib/cloudFirstCommands.js +293 -0
  53. package/src/lib/contract.sha256 +1 -1
  54. package/src/lib/cutover.js +376 -0
  55. package/src/lib/cutoverWiring.js +137 -0
  56. package/src/lib/daemonLock.js +2 -0
  57. package/src/lib/daemonLoop.js +5 -2
  58. package/src/lib/daemonProcess.js +16 -2
  59. package/src/lib/daemonRuntime.js +8 -0
  60. package/src/lib/doctorWarnings.js +53 -0
  61. package/src/lib/errors.js +14 -0
  62. package/src/lib/hubDbReader.js +132 -0
  63. package/src/lib/hubMapping.js +108 -0
  64. package/src/lib/hubScan.js +611 -0
  65. package/src/lib/hubSnapshot.js +64 -0
  66. package/src/lib/mirrorContract.js +1 -1
  67. package/src/lib/mirrorService.js +8 -1
  68. package/src/lib/mirrorSource.js +13 -38
  69. package/src/lib/nodeFloor.js +19 -0
  70. package/src/lib/outboxFlusher.js +75 -0
  71. package/src/lib/pairingLifecycle.js +1 -1
  72. package/src/lib/reconcile.js +316 -0
  73. package/src/lib/reconcileView.js +129 -0
  74. package/src/lib/runnerStore.js +418 -0
  75. package/src/lib/serviceManager.js +61 -0
  76. package/src/lib/shadowService.js +435 -0
  77. package/src/lib/sqlite.js +28 -0
  78. package/src/lib/tokenStore.js +43 -4
  79. package/src/lib/transport.js +53 -5
  80. package/src/lib/transportContract.js +3 -0
  81. package/src/lib/uploadRedactor.js +102 -0
  82. package/src/runner/applier.js +297 -0
  83. package/src/runner/bridge.js +289 -0
  84. package/src/runner/busIdentity.js +499 -0
  85. package/src/runner/capture.js +103 -0
  86. package/src/runner/captureMapping.js +21 -0
  87. package/src/runner/channelDiscipline.js +425 -0
  88. package/src/runner/deliveryStates.js +220 -0
  89. package/src/runner/facade.js +876 -0
  90. package/src/runner/hubCapture.js +160 -0
  91. package/src/runner/lease.js +46 -0
  92. package/src/runner/main.js +35 -0
  93. package/src/runner/render.js +91 -0
  94. package/src/runner/runnerChild.js +57 -0
  95. package/src/runner/runnerHub.js +104 -0
  96. package/src/runner/runnerProcess.js +124 -0
  97. package/src/runner/runnerSchema.js +51 -0
  98. package/src/runner/runnerService.js +140 -0
  99. package/src/runner/spool.js +86 -0
  100. package/src/runner/spoolInto.js +22 -0
  101. package/src/runner/statusPage.js +30 -0
@@ -0,0 +1,499 @@
1
+ import { ADDRESS, ONLINE_MS } from '../hub/core/ids.mjs';
2
+
3
+ /**
4
+ * Pulse 1.1.1 bus identity (F1, F1+, F4, F6). One identity per session: the Cursor hook's conversation_id is the
5
+ * address, and `bus register|listen --as X` run inside a session binds the alias X to it. Bindings, hook activity,
6
+ * register/listen proofs and other machines' presence live in runner.db tables of their own: no capture trigger, never
7
+ * sent to the cloud. Mail rows keep the address their sender used; delivery routes them (routedMessaging).
8
+ */
9
+
10
+ /** After this long with no hook activity a session that ended its turn is stale: its aliases can be taken, mail to it is undeliverable. */
11
+ export const STALE_MS = 30 * 60e3;
12
+ /** `bus who` lists sessions active this recently, by default. */
13
+ export const ACTIVE_MS = 15 * 60e3;
14
+ /** A session mid-turn (or in a tool call) stays live this long without a hook event: one long Shell command. */
15
+ export const MID_TURN_CAP_MS = 4 * 3600e3;
16
+ /** A register or listen the runner handled this recently is the proof a binding needs; each proof binds once. */
17
+ export const PROOF_MS = 120e3;
18
+ /** Pulse 1.1.2 (E1): mail to an address idle this long is still stored, but its receipt says `offline since <t>`. */
19
+ export const OFFLINE_MS = 2 * 3600e3;
20
+ /** A session id or a Task id: the shape a `bus register --as` of a real session takes, unlike a nickname. */
21
+ const SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
22
+ /** `did you mean` lists at most this many addresses, from sessions seen this recently. */
23
+ const SUGGESTIONS = 3;
24
+ const SUGGEST_FROM_MS = 7 * 24 * 3600e3;
25
+
26
+ const HOOK_EVENTS = ['postToolUse', 'postToolUseFailure', 'preToolUse', 'stop', 'afterAgentResponse', 'sessionStart', 'sessionEnd', 'beforeSubmitPrompt', 'subagentStart', 'subagentStop'];
27
+ const MID_TURN_EVENTS = new Set(['postToolUse', 'postToolUseFailure']);
28
+ const RESERVED = new Set(['user', 'manager']);
29
+ const COMMAND_MAX = 4000;
30
+
31
+ const SCHEMA = [
32
+ `CREATE TABLE IF NOT EXISTS pulse_sessions (
33
+ session TEXT PRIMARY KEY, last_hook INTEGER NOT NULL, last_event TEXT, tool TEXT, tool_since INTEGER, ended_at INTEGER)`,
34
+ `CREATE TABLE IF NOT EXISTS pulse_bindings (
35
+ alias TEXT PRIMARY KEY, session TEXT NOT NULL, bound_at INTEGER NOT NULL, previous TEXT)`,
36
+ 'CREATE INDEX IF NOT EXISTS pulse_bindings_session ON pulse_bindings (session)',
37
+ 'CREATE TABLE IF NOT EXISTS pulse_proofs (address TEXT PRIMARY KEY, at INTEGER NOT NULL, via TEXT NOT NULL, used INTEGER NOT NULL DEFAULT 0)',
38
+ 'CREATE TABLE IF NOT EXISTS pulse_remote_presence (actor_id TEXT PRIMARY KEY, machine_id TEXT, last_seen INTEGER NOT NULL, last_event TEXT)',
39
+ ];
40
+
41
+ /** The bus identity tables, on the runner's hub connection (runnerSchema) or a bare hub (tests). */
42
+ export function installBusIdentity(db) {
43
+ for (const sql of SCHEMA) db.prepare(sql).run();
44
+ }
45
+
46
+ /**
47
+ * Other machines' presence beats from a sync page (`{ actorId, machineId, lastSeen, lastEvent }`), kept apart from the
48
+ * hub's own presence table so this machine never sends them up as its own. Its own machine's beats are skipped.
49
+ */
50
+ export function applyRemotePresence(db, beats, ownMachine) {
51
+ let statement = null;
52
+ let n = 0;
53
+ for (const b of beats || []) {
54
+ if (!b || !ADDRESS.test(String(b.actorId || '')) || !b.machineId || b.machineId === ownMachine) continue;
55
+ const seen = Number(b.lastSeen);
56
+ if (!Number.isFinite(seen)) continue;
57
+ statement ??= db.prepare(`INSERT INTO pulse_remote_presence (actor_id, machine_id, last_seen, last_event) VALUES (?, ?, ?, ?)
58
+ ON CONFLICT (actor_id) DO UPDATE SET machine_id = excluded.machine_id, last_seen = excluded.last_seen, last_event = excluded.last_event
59
+ WHERE excluded.last_seen > pulse_remote_presence.last_seen`);
60
+ n += Number(statement.run(b.actorId, b.machineId, seen, typeof b.lastEvent === 'string' ? b.lastEvent.slice(0, 40) : null).changes);
61
+ }
62
+ return n;
63
+ }
64
+
65
+ const minutes = (ms) => Math.max(0, Math.floor(ms / 60e3));
66
+ const clockOf = (at) => new Date(at).toISOString().slice(11, 16);
67
+
68
+ /** Levenshtein distance, stopping early once it passes `cap`. */
69
+ function editDistance(a, b, cap = 40) {
70
+ if (Math.abs(a.length - b.length) > cap) return cap + 1;
71
+ let prev = Array.from({ length: b.length + 1 }, (_, j) => j);
72
+ for (let i = 1; i <= a.length; i++) {
73
+ const next = [i];
74
+ let best = i;
75
+ for (let j = 1; j <= b.length; j++) {
76
+ next[j] = Math.min(prev[j] + 1, next[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
77
+ best = Math.min(best, next[j]);
78
+ }
79
+ if (best > cap) return cap + 1;
80
+ prev = next;
81
+ }
82
+ return prev[b.length];
83
+ }
84
+
85
+ const commonPrefix = (a, b) => { let i = 0; while (i < a.length && i < b.length && a[i] === b[i]) i++; return i; };
86
+
87
+ /**
88
+ * The closest of `candidates` (`{ key, address, label }`) to `wanted`: by the longest common prefix, then the smallest
89
+ * edit distance, each address once. A candidate is close when it shares 4 characters of prefix or is within about a
90
+ * third of its length in edits.
91
+ */
92
+ export function closestAddresses(wanted, candidates, limit = SUGGESTIONS) {
93
+ const w = String(wanted || '').toLowerCase();
94
+ const ranked = [];
95
+ for (const c of candidates) {
96
+ const key = String(c.key || '').toLowerCase();
97
+ if (!key || key === w) continue;
98
+ const prefix = commonPrefix(w, key);
99
+ const cap = Math.max(2, Math.floor(Math.max(w.length, key.length) / 3));
100
+ const distance = editDistance(w, key, cap);
101
+ if (prefix < 4 && distance > cap) continue;
102
+ ranked.push({ ...c, prefix, distance });
103
+ }
104
+ ranked.sort((a, b) => b.prefix - a.prefix || a.distance - b.distance || a.address.localeCompare(b.address));
105
+ const out = [];
106
+ for (const r of ranked) {
107
+ if (out.some((o) => o.address === r.address)) continue;
108
+ out.push({ address: r.address, ...(r.label && r.label !== r.address ? { label: r.label } : {}) });
109
+ if (out.length >= limit) break;
110
+ }
111
+ return out;
112
+ }
113
+
114
+ /**
115
+ * Simple commands of a shell line, each as words with quotes removed: split on unquoted `&&`, `||`, `;`, `|` and new
116
+ * lines. Heredoc bodies are dropped first, so a register line inside one is never a command.
117
+ */
118
+ export function shellCommands(text) {
119
+ const lines = String(text || '').slice(0, COMMAND_MAX).split('\n');
120
+ const kept = [];
121
+ for (let i = 0; i < lines.length; i++) {
122
+ kept.push(lines[i]);
123
+ const heredoc = lines[i].match(/<<-?\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\1/);
124
+ if (!heredoc) continue;
125
+ while (i + 1 < lines.length && lines[i + 1].trim() !== heredoc[2]) i++;
126
+ i++;
127
+ }
128
+ const commands = [];
129
+ let words = [];
130
+ let word = null;
131
+ let quote = null;
132
+ const line = kept.join('\n');
133
+ const endWord = () => { if (word !== null) words.push(word); word = null; };
134
+ const endCommand = () => { endWord(); if (words.length) commands.push(words); words = []; };
135
+ for (let i = 0; i < line.length; i++) {
136
+ const c = line[i];
137
+ if (quote) {
138
+ if (c === quote) quote = null;
139
+ else if (c === '\\' && quote === '"' && i + 1 < line.length) word += line[++i];
140
+ else word += c;
141
+ continue;
142
+ }
143
+ if (c === '"' || c === "'") { quote = c; word = word ?? ''; continue; }
144
+ if (c === '\\' && i + 1 < line.length) { word = (word ?? '') + line[++i]; continue; }
145
+ if (c === '\n' || c === ';' || c === '|' || (c === '&' && line[i + 1] === '&')) { if (c === '&' || (c === '|' && line[i + 1] === '|')) i++; endCommand(); continue; }
146
+ if (c === ' ' || c === '\t') { endWord(); continue; }
147
+ word = (word ?? '') + c;
148
+ }
149
+ endCommand();
150
+ return commands;
151
+ }
152
+
153
+ const basename = (word) => String(word).split('/').pop();
154
+
155
+ /**
156
+ * The aliases a Shell command registers or listens as: only a real hub CLI call, `node <…/hub.mjs> bus register|listen
157
+ * … --as <X>`. An echo, a grep, a publish body or a heredoc never counts.
158
+ */
159
+ export function busAliases(command) {
160
+ const out = [];
161
+ for (let words of shellCommands(command)) {
162
+ while (words.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0])) words = words.slice(1);
163
+ if (!words.length || basename(words[0]) !== 'node') continue;
164
+ let i = 1;
165
+ while (i < words.length && words[i].startsWith('-')) i++;
166
+ if (basename(words[i] || '') !== 'hub.mjs' || words[i + 1] !== 'bus' || !['register', 'listen'].includes(words[i + 2])) continue;
167
+ const at = words.indexOf('--as', i + 3);
168
+ const alias = at > 0 ? words[at + 1] : null;
169
+ if (alias && ADDRESS.test(alias)) out.push({ action: words[i + 2], alias });
170
+ }
171
+ return out;
172
+ }
173
+
174
+ /** A session's turn from its last hook event: in a tool call, mid-turn, turn ended, or ended (sessionEnd). */
175
+ function turnOf(row, now) {
176
+ if (!row) return null;
177
+ if (row.ended_at && row.ended_at >= row.last_hook) return 'ended';
178
+ if (row.last_event === 'preToolUse' && now - (row.tool_since || row.last_hook) < MID_TURN_CAP_MS) return 'in-tool';
179
+ if (MID_TURN_EVENTS.has(row.last_event) && now - row.last_hook < MID_TURN_CAP_MS) return 'mid-turn';
180
+ return 'turn-ended';
181
+ }
182
+
183
+ /** Live: a reader now. In a tool call or mid-turn up to the cap; a turn that ended, for STALE_MS. */
184
+ const liveTurn = (turn, idle) => turn === 'in-tool' || turn === 'mid-turn' || (turn === 'turn-ended' && idle < STALE_MS);
185
+
186
+ export function createBusIdentity(hub, { now: clock = Date.now } = {}) {
187
+ const { db, registry } = hub;
188
+ installBusIdentity(db);
189
+ const q = {
190
+ session: db.prepare('SELECT * FROM pulse_sessions WHERE session = ?'),
191
+ sessions: db.prepare('SELECT * FROM pulse_sessions'),
192
+ touch: db.prepare(`INSERT INTO pulse_sessions (session, last_hook, last_event, tool, tool_since, ended_at) VALUES (?, ?, ?, ?, ?, ?)
193
+ ON CONFLICT (session) DO UPDATE SET last_hook = MAX(pulse_sessions.last_hook, excluded.last_hook), last_event = excluded.last_event,
194
+ tool = excluded.tool, tool_since = excluded.tool_since, ended_at = COALESCE(excluded.ended_at, pulse_sessions.ended_at)`),
195
+ seedSession: db.prepare('INSERT OR IGNORE INTO pulse_sessions (session, last_hook, last_event) VALUES (?, ?, ?)'),
196
+ binding: db.prepare('SELECT * FROM pulse_bindings WHERE alias = ?'),
197
+ bindings: db.prepare('SELECT * FROM pulse_bindings'),
198
+ aliases: db.prepare('SELECT alias FROM pulse_bindings WHERE session = ? ORDER BY bound_at'),
199
+ bind: db.prepare(`INSERT INTO pulse_bindings (alias, session, bound_at, previous) VALUES (?, ?, ?, ?)
200
+ ON CONFLICT (alias) DO UPDATE SET session = excluded.session, bound_at = excluded.bound_at, previous = excluded.previous`),
201
+ proof: db.prepare('SELECT * FROM pulse_proofs WHERE address = ?'),
202
+ prove: db.prepare(`INSERT INTO pulse_proofs (address, at, via, used) VALUES (?, ?, ?, 0)
203
+ ON CONFLICT (address) DO UPDATE SET at = excluded.at, via = excluded.via, used = 0`),
204
+ refreshProof: db.prepare("INSERT INTO pulse_proofs (address, at, via, used) VALUES (?, ?, 'listen', 0) ON CONFLICT (address) DO UPDATE SET at = MAX(pulse_proofs.at, excluded.at)"),
205
+ useProof: db.prepare('UPDATE pulse_proofs SET used = 1 WHERE address = ?'),
206
+ remote: db.prepare('SELECT * FROM pulse_remote_presence WHERE actor_id = ?'),
207
+ };
208
+
209
+ /**
210
+ * Every chat the hub already knows to be a session, before its next hook: hook-seen and transcript-found actors, and
211
+ * presence rows whose last event came from a hook. Without this, right after an upgrade another session could take a
212
+ * live session's own id as its alias. A bound alias is never one: its heartbeats copy its session's hook events.
213
+ */
214
+ function seed() {
215
+ const marks = HOOK_EVENTS.map(() => '?').join(',');
216
+ const rows = db.prepare(`SELECT a.id AS id, COALESCE(p.last_seen, a.last_at, 0) AS seen, CASE WHEN p.last_event IN (${marks}) THEN p.last_event END AS event
217
+ FROM actors a LEFT JOIN presence p ON p.actor_id = a.id
218
+ WHERE (a.source IN ('hook', 'transcript') OR a.cursor_title IS NOT NULL OR a.prompt_title IS NOT NULL OR p.last_event IN (${marks}))
219
+ AND a.id NOT IN (SELECT alias FROM pulse_bindings)`).all(...HOOK_EVENTS, ...HOOK_EVENTS);
220
+ for (const r of rows) if (ADDRESS.test(r.id) && !RESERVED.has(r.id)) q.seedSession.run(r.id, Number(r.seen) || 0, r.event);
221
+ }
222
+ seed();
223
+
224
+ const sessionRow = (id) => q.session.get(id) || null;
225
+ const sessionOf = (address) => {
226
+ const bound = q.binding.get(address);
227
+ if (bound) return bound.session;
228
+ return sessionRow(address) ? address : null;
229
+ };
230
+ const aliasesOf = (session) => q.aliases.all(session).map((r) => r.alias).filter((a) => a !== session);
231
+
232
+ /**
233
+ * The addresses a session's hook serves as one inbox: the session and every alias bound to it. Anything else (an
234
+ * alias, an unknown address) is only itself.
235
+ */
236
+ function addressesOf(address) {
237
+ if (q.binding.get(address) || !sessionRow(address)) return [address];
238
+ return [address, ...aliasesOf(address)];
239
+ }
240
+
241
+ /**
242
+ * Whose mail a `bus listen` or `bus ack` as `principal` may take, given the session that ran it (`caller`, the CLI's
243
+ * CURSOR_CONVERSATION_ID, or null outside Cursor). The holding session itself gets its whole inbox, whether it names
244
+ * itself or one of its aliases. Another session is refused (`held`) on an alias a live session holds, before any
245
+ * claim. Everyone else, a caller outside Cursor included, gets the mail sent to `principal` literally.
246
+ */
247
+ function inboxFor(principal, caller = null, now = clock()) {
248
+ const bound = q.binding.get(principal);
249
+ const holder = bound ? bound.session : sessionRow(principal) ? principal : null;
250
+ if (holder && caller === holder) return { addresses: [...new Set([principal, holder, ...aliasesOf(holder)])] };
251
+ if (caller && bound && bound.session !== principal) {
252
+ const act = activity(bound.session, now);
253
+ if (act && act.live) return { held: { session: bound.session, idleMin: minutes(act.idle) } };
254
+ }
255
+ return { addresses: [principal] };
256
+ }
257
+
258
+ /** A session's turn and idle time, from this machine's hooks or another machine's presence. */
259
+ function activity(session, now = clock()) {
260
+ const row = sessionRow(session);
261
+ if (row) {
262
+ const turn = turnOf(row, now);
263
+ const idle = now - row.last_hook;
264
+ return { turn, idle, live: liveTurn(turn, idle), tool: turn === 'in-tool' ? row.tool : null, toolSince: turn === 'in-tool' ? row.tool_since : null, remote: false };
265
+ }
266
+ const beat = q.remote.get(session);
267
+ if (beat && HOOK_EVENTS.includes(beat.last_event)) {
268
+ const turn = turnOf({ last_hook: beat.last_seen, last_event: beat.last_event, tool_since: beat.last_seen }, now);
269
+ const idle = now - beat.last_seen;
270
+ return { turn, idle, live: liveTurn(turn, idle), tool: null, toolSince: null, remote: true };
271
+ }
272
+ return null;
273
+ }
274
+
275
+ const listening = (address, now) => {
276
+ const local = registry.presence(address);
277
+ if (local && local.last_event === 'listen' && now - local.last_seen < ONLINE_MS) return true;
278
+ const beat = q.remote.get(address);
279
+ return !!(beat && beat.last_event === 'listen' && now - beat.last_seen < ONLINE_MS);
280
+ };
281
+
282
+ /** When `address` last listened, from this machine's presence or another machine's; null if it never did. */
283
+ const lastListen = (address) => {
284
+ const local = registry.presence(address);
285
+ const beat = q.remote.get(address);
286
+ return Math.max(local && local.last_event === 'listen' ? local.last_seen : 0, beat && beat.last_event === 'listen' ? beat.last_seen : 0) || null;
287
+ };
288
+
289
+ /**
290
+ * Pulse 1.1.2 (E1): whether `address` is one the bus knows, so mail to `agent/<address>` can ever be read. Known: the
291
+ * hub's own addresses, a bound alias, a session a hook has shown (here, or by another machine's presence), an address
292
+ * that has listened, and a session or Task id that registered itself. A nickname nobody bound is not. A reply's
293
+ * destination is the original sender, never typed, so there any session or Task id passes and only a nickname is checked.
294
+ */
295
+ function known(address, { reply = false } = {}) {
296
+ if (RESERVED.has(address) || q.binding.get(address) || sessionRow(address)) return true;
297
+ if (reply && SESSION_ID.test(address)) return true;
298
+ const beat = q.remote.get(address);
299
+ if (beat && (HOOK_EVENTS.includes(beat.last_event) || beat.last_event === 'listen')) return true;
300
+ if (lastListen(address)) return true;
301
+ const actor = registry.get(address);
302
+ return !!actor && (actor.source === 'hook' || actor.source === 'transcript' || (actor.source === 'registered' && SESSION_ID.test(address)));
303
+ }
304
+
305
+ /** E1's `did you mean`: the closest session ids, bound aliases and the names those sessions registered. */
306
+ function suggest(address, now = clock()) {
307
+ const candidates = [];
308
+ const named = (id) => { const a = registry.get(id); return a ? a.persona_name || a.cursor_title || null : null; };
309
+ for (const s of q.sessions.all()) {
310
+ if (now - s.last_hook > SUGGEST_FROM_MS) continue;
311
+ const label = named(s.session);
312
+ candidates.push({ key: s.session, address: s.session, label });
313
+ if (label) candidates.push({ key: label.replace(/\s+/g, '-'), address: s.session, label });
314
+ }
315
+ for (const b of q.bindings.all()) candidates.push({ key: b.alias, address: b.alias, label: `bound to ${b.session}` });
316
+ for (const a of registry.all()) {
317
+ if (a.source === 'registered' && SESSION_ID.test(a.id)) candidates.push({ key: a.id, address: a.id, label: a.persona_name });
318
+ }
319
+ return closestAddresses(address, candidates);
320
+ }
321
+
322
+ /**
323
+ * Whether anyone can read mail to `address` now (F1, F2+): `queued` with how it is reached, or `undeliverable` with
324
+ * why. A reader is the hub (user, manager), a `bus listen` in the last 90 s, or the session holding the address while
325
+ * live. A register heartbeat alone never is one. Idle more than OFFLINE_MS: `offline`, with when it was last seen.
326
+ */
327
+ function reach(address, now = clock()) {
328
+ if (RESERVED.has(address)) return { state: 'queued', reach: 'hub' };
329
+ if (listening(address, now)) return { state: 'queued', reach: 'listener' };
330
+ const bound = q.binding.get(address);
331
+ const session = bound ? bound.session : address;
332
+ const act = activity(session, now);
333
+ if (!act) {
334
+ const listened = lastListen(address);
335
+ return listened && now - listened > OFFLINE_MS ? { state: 'undeliverable', reason: 'offline', offlineSince: listened } : { state: 'undeliverable', reason: 'no-reader' };
336
+ }
337
+ const facts = { session, idleMin: minutes(act.idle) };
338
+ if (!act.live && act.idle > OFFLINE_MS) return { state: 'undeliverable', reason: 'offline', offlineSince: now - act.idle, ...facts };
339
+ if (!act.live) return { state: 'undeliverable', reason: 'stale', ...facts };
340
+ return {
341
+ state: 'queued', reach: act.remote ? 'remote' : 'session', ...facts,
342
+ ...(act.turn === 'turn-ended' ? { turnEnded: true } : {}), ...(act.turn === 'mid-turn' || act.turn === 'in-tool' ? { midTurn: true } : {}),
343
+ };
344
+ }
345
+
346
+ /** A hook event from `session`: its activity, before handleHook. Returns the time of its previous hook, or null. */
347
+ function sawHook(input, now = clock()) {
348
+ const session = input.conversation_id;
349
+ const prior = sessionRow(session);
350
+ const event = String(input.hook_event_name);
351
+ const tool = typeof input.tool_name === 'string' ? input.tool_name.slice(0, 60) : null;
352
+ q.touch.run(session, now, event, event === 'preToolUse' ? tool : null, event === 'preToolUse' ? now : null, event === 'sessionEnd' ? now : null);
353
+ for (const alias of aliasesOf(session)) registry.heartbeat(alias, { event, tool, at: now });
354
+ return prior ? prior.last_hook : null;
355
+ }
356
+
357
+ /** The runner handled `bus register --as X` (or a listen's own register): one proof, for one binding. */
358
+ const proved = (address, via = 'register', now = clock()) => { if (ADDRESS.test(String(address || ''))) q.prove.run(address, now, via); };
359
+ /** A `bus listen --as X` poll: keeps a long listen's proof fresh without re-arming a used one. */
360
+ const listened = (address, now = clock()) => { if (ADDRESS.test(String(address || ''))) q.refreshProof.run(address, now); };
361
+
362
+ /**
363
+ * The aliases a Shell command in this hook registered or listened as, bound to the hook's session or refused. Each
364
+ * result is reported back to the session (notices), since the CLI ran before the hook could know.
365
+ */
366
+ function bindFromHook(input, now = clock()) {
367
+ const event = input.hook_event_name;
368
+ if (event !== 'postToolUse' && event !== 'postToolUseFailure') return [];
369
+ const command = input.tool_input && typeof input.tool_input.command === 'string' ? input.tool_input.command : null;
370
+ if (!command) return [];
371
+ const session = input.conversation_id;
372
+ const results = [];
373
+ for (const { alias } of busAliases(command)) {
374
+ if (results.some((r) => r.alias === alias)) continue;
375
+ results.push({ alias, ...bindOne(session, alias, now) });
376
+ }
377
+ return results;
378
+ }
379
+
380
+ function bindOne(session, alias, now) {
381
+ if (alias === session) return { result: 'self' };
382
+ if (RESERVED.has(alias)) return { result: 'refused', reason: 'reserved' };
383
+ const held = q.binding.get(alias);
384
+ if (held && held.session === session) return { result: 'already' };
385
+ if (sessionRow(alias)) return { result: 'refused', reason: 'session-id' };
386
+ if (held) {
387
+ const act = activity(held.session, now);
388
+ if (act && act.live) return { result: 'refused', reason: 'held', holder: held.session, idleMin: minutes(act.idle) };
389
+ } else {
390
+ const elsewhere = activity(alias, now);
391
+ if (elsewhere && elsewhere.live) return { result: 'refused', reason: 'held', holder: 'a session on another machine', idleMin: minutes(elsewhere.idle) };
392
+ }
393
+ const proof = q.proof.get(alias);
394
+ if (!proof || proof.used || now - proof.at > PROOF_MS) return { result: 'refused', reason: 'unproven' };
395
+ q.bind.run(alias, session, now, held ? held.session : null);
396
+ q.useProof.run(alias);
397
+ if (held) {
398
+ registry.appendEvent({ at: now, kind: 'BUS REBIND', actor: session, payload: { from: session, to: alias, type: 'BUS REBIND', text: `${alias} moved from ${held.session} to ${session}` } });
399
+ return { result: 'rebound', previous: held.session, idleMin: minutes(now - (sessionRow(held.session)?.last_hook ?? held.bound_at)) };
400
+ }
401
+ return { result: 'bound' };
402
+ }
403
+
404
+ /** What the session is told about each binding in its hook answer. */
405
+ function notices(results, session) {
406
+ const lines = [];
407
+ for (const r of results) {
408
+ if (r.result === 'bound') lines.push(`Bus alias \`${r.alias}\` is now bound to this session (${session}): mail to it reaches you here. Reply with \`--as ${r.alias}\` or \`--as ${session}\`.`);
409
+ else if (r.result === 'rebound') lines.push(`Bus alias \`${r.alias}\` is now bound to this session (${session}). It was held by ${r.previous}, idle ${r.idleMin} min; mail still queued for it comes to you.`);
410
+ else if (r.reason === 'held') lines.push(`Not bound: bus alias \`${r.alias}\` is held by live session ${r.holder} (active ${r.idleMin} min ago). Use your own address ${session}, or pick another alias.`);
411
+ else if (r.reason === 'session-id') lines.push(`Not bound: \`${r.alias}\` is another session's own bus address. Use your own address ${session}.`);
412
+ else if (r.reason === 'reserved') lines.push(`Not bound: \`${r.alias}\` is the hub's own address. Use your own address ${session}.`);
413
+ else if (r.reason === 'unproven') lines.push(`Not bound: the hub did not see \`bus register --as ${r.alias}\` succeed just now. Run it again as a command of its own.`);
414
+ }
415
+ return lines.join('\n');
416
+ }
417
+
418
+ /**
419
+ * `bus who` (F4, F6): active sessions by default, grouped by repo and task, each with its aliases folded in and an
420
+ * activity line; `all` adds every registration and session seen in the last day. The registration fields are the
421
+ * ones `registrations()` gives, so old readers keep working.
422
+ */
423
+ function who({ all = false, minutes: windowMin = ACTIVE_MS / 60e3, now = clock() } = {}) {
424
+ const windowMs = Math.max(1, Number(windowMin) || ACTIVE_MS / 60e3) * 60e3;
425
+ const dir = new Map(registry.directory(now).map((a) => [a.id, a]));
426
+ const presence = registry.presences();
427
+ const bindings = q.bindings.all();
428
+ const boundTo = new Map(bindings.map((b) => [b.alias, b.session]));
429
+ const sessions = new Map(q.sessions.all().map((s) => [s.session, s]));
430
+ const ids = new Set([...registry.registrations(now).map((r) => r.id), ...[...sessions.values()].filter((s) => now - s.last_hook < 24 * 3600e3).map((s) => s.session)]);
431
+ for (const b of bindings) ids.add(b.session);
432
+ const entries = [];
433
+ for (const id of ids) {
434
+ if (boundTo.has(id) && boundTo.get(id) !== id) continue;
435
+ const actor = dir.get(id) || null;
436
+ const aliases = aliasesOf(id);
437
+ const named = [actor, ...aliases.map((a) => dir.get(a))].find((a) => a && a.source === 'registered') || actor;
438
+ const act = activity(id, now);
439
+ const seenAt = Math.max(act ? now - act.idle : 0, presence.get(id)?.last_seen || 0, actor ? actor.seenAt || 0 : 0);
440
+ const listen = listening(id, now) || aliases.some((a) => listening(a, now));
441
+ const idle = now - seenAt;
442
+ const [repo, task] = String((actor && actor.stream_key) || '').split('/');
443
+ const entry = {
444
+ id, role: named ? named.role : null, name: named ? named.persona_name : null, topics: named ? named.topics || [] : [],
445
+ parent: actor ? actor.parent_id : null, seenAt, online: !!(actor && actor.online) || listen,
446
+ label: (named && named.persona_name) || (actor && actor.title) || id, aliases,
447
+ repo: (actor && actor.repo) || repo || null, task: task || null, idleMin: minutes(idle), turn: act ? act.turn : null,
448
+ };
449
+ if (act && act.turn === 'in-tool') entry.activity = `in a tool call since ${clockOf(act.toolSince)} (${act.tool || 'tool'})`;
450
+ else if (act && act.turn === 'mid-turn' && act.idle >= STALE_MS) entry.activity = `mid-turn, no activity ${minutes(act.idle)} min`;
451
+ if (idle >= STALE_MS && !(act && act.live) && !listen && !RESERVED.has(id)) entry.stale = `no activity ${minutes(idle)} min`;
452
+ entry.active = RESERVED.has(id) || listen || idle < windowMs || !!(act && (act.turn === 'mid-turn' || act.turn === 'in-tool'));
453
+ entries.push(entry);
454
+ }
455
+ const activeIds = new Set(entries.filter((e) => e.active).map((e) => e.id));
456
+ for (const e of entries) if (!e.active && entries.some((c) => c.parent === e.id && activeIds.has(c.id))) e.active = true;
457
+ const shown = entries.filter((e) => all || e.active);
458
+ const groups = new Map();
459
+ for (const e of shown) {
460
+ const key = `${e.repo || ''}\0${e.task || ''}`;
461
+ if (!groups.has(key)) groups.set(key, { repo: e.repo, task: e.task, sessions: [] });
462
+ groups.get(key).sessions.push(e.id);
463
+ }
464
+ return { registrations: shown.map(({ active: _active, ...e }) => e), groups: [...groups.values()], window: { minutes: windowMs / 60e3, all: !!all } };
465
+ }
466
+
467
+ return { addressesOf, inboxFor, sessionOf, aliasesOf, activity, known, suggest, reach, sawHook, proved, listened, bindFromHook, notices, who };
468
+ }
469
+
470
+ /**
471
+ * The hub's messaging as seen by whoever acts as `addresses[0]`: mail to any of `addresses` (a session and its
472
+ * aliases) is peeked, claimed, shown and answered as one inbox. A message with a row for more than one of them is
473
+ * shown once, and every one of its rows is settled together. Calls for any other address pass straight through.
474
+ */
475
+ export function routedMessaging(messaging, addresses) {
476
+ if (!addresses || addresses.length <= 1) return messaging;
477
+ const [self] = addresses;
478
+ const once = (rows) => { const seen = new Set(); return rows.filter((m) => !seen.has(m.id) && seen.add(m.id)); };
479
+ const oldestFirst = (a, b) => (a.at || 0) - (b.at || 0);
480
+ const each = (fn) => addresses.flatMap((a) => fn(a));
481
+ return {
482
+ ...messaging,
483
+ peek: (as, opts = {}) => (as === self ? once(each((a) => messaging.peek(a, opts))).sort(oldestFirst).slice(0, opts.limit ?? 20) : messaging.peek(as, opts)),
484
+ claim: (as, opts = {}) => (as === self ? once(each((a) => messaging.claim(a, opts))).sort(oldestFirst) : messaging.claim(as, opts)),
485
+ markDelivered: (as, ids, opts) => (as === self ? addresses.reduce((n, a) => n + messaging.markDelivered(a, ids, opts), 0) : messaging.markDelivered(as, ids, opts)),
486
+ sideUnanswered: (as) => (as === self ? once(each((a) => messaging.sideUnanswered(a))).sort(oldestFirst) : messaging.sideUnanswered(as)),
487
+ awaiting: (as) => (as === self ? once(each((a) => messaging.awaiting(a))) : messaging.awaiting(as)),
488
+ releaseAwaiting: (as) => (as === self ? addresses.reduce((n, a) => n + messaging.releaseAwaiting(a), 0) : messaging.releaseAwaiting(as)),
489
+ ack: (as, id, opts = {}, directory) => {
490
+ if (as !== self) return messaging.ack(as, id, opts, directory);
491
+ let first = null;
492
+ for (const a of addresses) {
493
+ const r = messaging.ack(a, id, first ? {} : opts, directory);
494
+ if (!r.error && !first) first = r;
495
+ }
496
+ return first || messaging.ack(self, id, opts, directory);
497
+ },
498
+ };
499
+ }
@@ -0,0 +1,103 @@
1
+ import { withTx } from '../hub/core/db.mjs';
2
+
3
+ /**
4
+ * Which hub rows reach the cloud and the key columns that name one. Leases, imports, settings and the hub's file
5
+ * quarantine stay on the machine.
6
+ */
7
+ const CAPTURED = [
8
+ { table: 'messages', keys: ['id'], ops: ['INSERT', 'DELETE'] },
9
+ { table: 'deliveries', keys: ['message_id', 'recipient'], ops: ['INSERT', 'UPDATE', 'DELETE'] },
10
+ { table: 'actors', keys: ['id'], ops: ['INSERT', 'UPDATE'] },
11
+ { table: 'facts', keys: ['kind', 'key'], ops: ['INSERT', 'UPDATE', 'DELETE'] },
12
+ { table: 'events', keys: ['id'], ops: ['INSERT'] },
13
+ ];
14
+
15
+ const SCHEMA = [
16
+ 'CREATE TABLE IF NOT EXISTS pulse_ctx (one INTEGER PRIMARY KEY CHECK (one = 1), applying INTEGER NOT NULL DEFAULT 0)',
17
+ 'INSERT OR IGNORE INTO pulse_ctx (one, applying) VALUES (1, 0)',
18
+ 'CREATE TABLE IF NOT EXISTS pulse_capture (id INTEGER PRIMARY KEY AUTOINCREMENT, tbl TEXT NOT NULL, op TEXT NOT NULL, k1 TEXT NOT NULL, k2 TEXT)',
19
+ ];
20
+
21
+ function triggerSql({ table, keys, ops }) {
22
+ return ops.map((op) => {
23
+ const row = op === 'DELETE' ? 'OLD' : 'NEW';
24
+ const [k1, k2] = keys.map((k) => `${row}.${k}`);
25
+ return `CREATE TRIGGER IF NOT EXISTS pulse_capture_${table}_${op.toLowerCase()} AFTER ${op} ON ${table}
26
+ WHEN (SELECT applying FROM pulse_ctx) = 0
27
+ BEGIN INSERT INTO pulse_capture (tbl, op, k1, k2) VALUES ('${table}', '${op.toLowerCase()}', ${k1}, ${k2 || 'NULL'}); END`;
28
+ });
29
+ }
30
+
31
+ /** The capture tables and triggers, created once on the runner's hub connection. */
32
+ export function installCapture(db) {
33
+ for (const sql of [...SCHEMA, ...CAPTURED.flatMap(triggerSql)]) db.prepare(sql).run();
34
+ }
35
+
36
+ /**
37
+ * Turns every captured hub write into outbox commands inside the transaction that made it.
38
+ *
39
+ * `tx(fn, { guard })` runs fn in one write transaction (the ported core's own withTx calls join it), drains the trigger
40
+ * journal through `mapping.commands(change, db)` into the outbox, then runs `guard`; a guard that throws rolls the
41
+ * hub write and its commands back together. `applying(fn)` writes with the triggers off, for rows that came from the
42
+ * cloud: other connections never see the flag set, because SQLite serializes writers and the flag is set and reset
43
+ * inside one write transaction. `pump()` drains what writes outside any `tx` left in the journal.
44
+ */
45
+ export function createCapture({ db, store, mapping, log = () => {} }) {
46
+ const journal = db.prepare('SELECT id, tbl, op, k1, k2 FROM pulse_capture ORDER BY id');
47
+ const clear = db.prepare('DELETE FROM pulse_capture WHERE id <= ?');
48
+ const setApplying = db.prepare('UPDATE pulse_ctx SET applying = ? WHERE one = 1');
49
+
50
+ function drain() {
51
+ const rows = journal.all();
52
+ if (!rows.length) return 0;
53
+ const latest = new Map();
54
+ for (const row of rows) {
55
+ const id = `${row.tbl}\0${row.k1}\0${row.k2 ?? ''}`;
56
+ latest.delete(id);
57
+ latest.set(id, row);
58
+ }
59
+ const entries = [];
60
+ for (const { tbl, op, k1, k2 } of latest.values()) {
61
+ const change = { tbl, op, k1, k2 };
62
+ try {
63
+ entries.push(...mapping.commands(change, db));
64
+ } catch (error) {
65
+ // A full outbox refuses the write itself (507 pulse_backlog), so the caller is told.
66
+ if (error.code === 'pulse_backlog') throw error;
67
+ // One row the mapping can't turn into a command must not refuse the local write that made it.
68
+ store.refuse(
69
+ { key: `capture:${tbl}:${k1}:${k2 ?? '-'}:${rows.at(-1).id}`, group: `capture:${tbl}`, type: 'capture', payload: change, source: 'capture' },
70
+ { code: 'capture_failed', message: String(error?.message || error).slice(0, 300) },
71
+ );
72
+ log('capture_failed', { tbl, op, code: error?.code || 'capture_failed' });
73
+ }
74
+ }
75
+ if (entries.length) store.enqueue(entries);
76
+ clear.run(rows.at(-1).id);
77
+ return entries.length;
78
+ }
79
+
80
+ function tx(fn, { guard = null, label = 'capture' } = {}) {
81
+ return withTx(db, () => {
82
+ const out = fn();
83
+ drain();
84
+ if (guard) guard();
85
+ return out;
86
+ }, label);
87
+ }
88
+
89
+ function applying(fn) {
90
+ return withTx(db, () => {
91
+ setApplying.run(1);
92
+ try {
93
+ return fn();
94
+ } finally {
95
+ setApplying.run(0);
96
+ }
97
+ }, 'apply');
98
+ }
99
+
100
+ const pump = () => withTx(db, drain, 'capture pump');
101
+
102
+ return { tx, applying, pump, drain };
103
+ }
@@ -0,0 +1,21 @@
1
+ import os from 'node:os';
2
+ import { HubError } from '../lib/errors.js';
3
+ import { createHubCapture } from './hubCapture.js';
4
+
5
+ /** Captures nothing: a runner that only serves the local hub (tests, rehearsals with no cloud). */
6
+ const NONE = Object.freeze({ commands: () => [], sweep: () => null });
7
+
8
+ /**
9
+ * The capture mapping for a runner: `hub` turns every captured row into the commands the Phase 2 shadow would send
10
+ * (src/runner/hubCapture.js); `none` captures nothing. Returns a factory of the runner's store.
11
+ */
12
+ export function mappingFor(name, { hubDir, roots = [], approvedRoots = [], secrets = () => [], home = os.homedir() } = {}) {
13
+ switch (name) {
14
+ case 'none':
15
+ return () => NONE;
16
+ case 'hub':
17
+ return (store) => createHubCapture({ store, hubDir, roots, home, approvedRoots, secrets });
18
+ default:
19
+ throw new HubError('bad_config', `Unknown capture mapping "${name}"`);
20
+ }
21
+ }