@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,281 @@
1
+ // agents-hub core/registry.mjs at 6b2fb0b. Ported verbatim; every change is a declared seam (test/hub-verbatim.test.js).
2
+ // Actors, presence, and topic → recipients. A registered identity always outranks an inferred one.
3
+ import { ADDRESS, NODE_TYPES, ONLINE_MS, SOURCE_RANK, TOPIC, displayTitle, nodeTypeOf } from './ids.mjs';
4
+ import { jsonCol, parseJson, withTx } from './db.mjs';
5
+
6
+ export { displayTitle, nodeTypeOf };
7
+
8
+ function rowActor(r) {
9
+ if (!r) return null;
10
+ return {
11
+ id: r.id,
12
+ node_type: r.node_type,
13
+ role: r.role,
14
+ parent_id: r.parent_id,
15
+ delivery_id: r.delivery_id,
16
+ stream_key: r.stream_key,
17
+ cursor_title: r.cursor_title,
18
+ prompt_title: r.prompt_title,
19
+ persona_name: r.persona_name,
20
+ persona_first: r.persona_first,
21
+ persona_username: r.persona_username,
22
+ persona_seed: r.persona_seed,
23
+ persona: parseJson(r.persona_json, null),
24
+ source: r.source,
25
+ topics: parseJson(r.topics, []),
26
+ started_at: r.started_at,
27
+ last_at: r.last_at,
28
+ tokens_est: r.tokens_est,
29
+ msgs_in: r.msgs_in,
30
+ msgs_out: r.msgs_out,
31
+ repo: r.repo,
32
+ channel: r.channel,
33
+ synthetic: !!r.synthetic,
34
+ extra: parseJson(r.extra, null),
35
+ title: displayTitle(r),
36
+ };
37
+ }
38
+
39
+ export function createRegistry(db) {
40
+ const getStmt = db.prepare('SELECT * FROM actors WHERE id = ?');
41
+ const allStmt = db.prepare('SELECT * FROM actors');
42
+ const presenceStmt = db.prepare('SELECT * FROM presence WHERE actor_id = ?');
43
+ const allPresenceStmt = db.prepare('SELECT * FROM presence');
44
+
45
+ function get(id) { return rowActor(getStmt.get(id)); }
46
+
47
+ function all() { return allStmt.all().map(rowActor); }
48
+
49
+ function ensure(id, defaults = {}) {
50
+ const existing = getStmt.get(id);
51
+ if (existing) return rowActor(existing);
52
+ const nodeType = defaults.node_type || nodeTypeOf({ id, parentId: defaults.parent_id, synthetic: defaults.synthetic, role: defaults.role });
53
+ db.prepare(`INSERT OR IGNORE INTO actors (id, node_type, source, role, parent_id) VALUES (?, ?, ?, ?, ?)`)
54
+ .run(id, nodeType, defaults.source || 'hook', defaults.role || null, defaults.parent_id || null);
55
+ return get(id);
56
+ }
57
+
58
+ function register({ id, role = null, name = null, topics = [], parent = null, node_type = null }) {
59
+ if (!ADDRESS.test(String(id || ''))) return { error: 'bad address' };
60
+ const cleanTopics = [...new Set((Array.isArray(topics) ? topics : []).filter((t) => TOPIC.test(t)))].slice(0, 30);
61
+ const now = Date.now();
62
+ return withTx(db, () => {
63
+ const prior = getStmt.get(id);
64
+ const nextParent = ADDRESS.test(String(parent || '')) ? parent : (prior && prior.parent_id);
65
+ const nodeType = node_type || nodeTypeOf({
66
+ id, parentId: nextParent, role: role || (prior && prior.role), synthetic: prior && prior.synthetic,
67
+ });
68
+ if (!NODE_TYPES.includes(nodeType)) return { error: 'bad node_type' };
69
+ if (!prior) {
70
+ db.prepare(`INSERT INTO actors (id, node_type, role, parent_id, source, topics, persona_name, last_at)
71
+ VALUES (?, ?, ?, ?, 'registered', ?, ?, ?)`)
72
+ .run(id, nodeType,
73
+ typeof role === 'string' && role ? role.toUpperCase().slice(0, 30) : null,
74
+ ADDRESS.test(String(parent || '')) ? parent : null,
75
+ jsonCol(cleanTopics),
76
+ typeof name === 'string' && name ? name.slice(0, 80) : null,
77
+ now);
78
+ } else {
79
+ const nextRole = typeof role === 'string' && role ? role.toUpperCase().slice(0, 30) : prior.role;
80
+ const nextTopics = cleanTopics.length ? cleanTopics : parseJson(prior.topics, []);
81
+ const nextName = typeof name === 'string' && name ? name.slice(0, 80) : prior.persona_name;
82
+ db.prepare(`UPDATE actors SET node_type = ?, role = ?, parent_id = ?, source = 'registered', topics = ?,
83
+ persona_name = COALESCE(?, persona_name), last_at = ? WHERE id = ?`)
84
+ .run(nodeType, nextRole, nextParent, jsonCol(nextTopics), nextName, now, id);
85
+ }
86
+ heartbeat(id, { event: 'register' });
87
+ const row = get(id);
88
+ return { ...row, online: true, seenAt: now, pending: 0 };
89
+ }, 'register');
90
+ }
91
+
92
+ /**
93
+ * Discovery write. Never demotes a registered identity. Presence is a separate table so a heartbeat cannot
94
+ * rewrite who an agent is.
95
+ */
96
+ function upsertInferred(patch) {
97
+ const id = String(patch.id || '');
98
+ if (!id || id.length > 200) return { error: 'bad id' };
99
+ const source = patch.source || 'transcript';
100
+ const rank = SOURCE_RANK[source] || 0;
101
+ return withTx(db, () => {
102
+ const prior = getStmt.get(id);
103
+ const priorRank = prior ? (SOURCE_RANK[prior.source] || 0) : 0;
104
+ const parentId = patch.parent_id || (prior && prior.parent_id) || null;
105
+ const nodeType = nodeTypeOf({
106
+ id, parentId, synthetic: patch.synthetic || (prior && prior.synthetic),
107
+ role: patch.role || (prior && prior.role),
108
+ });
109
+ if (!prior) {
110
+ db.prepare(`INSERT INTO actors (id, node_type, role, parent_id, delivery_id, stream_key, cursor_title, prompt_title,
111
+ persona_name, persona_first, persona_username, persona_seed, persona_json, source, topics, started_at, last_at,
112
+ tokens_est, msgs_in, msgs_out, repo, channel, synthetic, extra)
113
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
114
+ .run(id, nodeType, patch.role || null, patch.parent_id || null, patch.delivery_id || null, patch.stream_key || null,
115
+ patch.cursor_title || null, patch.prompt_title || null, patch.persona_name || null, patch.persona_first || null,
116
+ patch.persona_username || null, patch.persona_seed ?? null, jsonCol(patch.persona || patch.persona_json),
117
+ source, jsonCol(patch.topics || []), patch.started_at || null, patch.last_at || null,
118
+ patch.tokens_est ?? null, patch.msgs_in || 0, patch.msgs_out || 0, patch.repo || null, patch.channel || null,
119
+ patch.synthetic ? 1 : 0, jsonCol(patch.extra || null));
120
+ return get(id);
121
+ }
122
+ if (rank < priorRank) {
123
+ // A registered or hook-seen actor keeps what it said about itself (role, names, topics); discovery still owns
124
+ // the facts only it can know: streams and status (extra), repo, channel, start time.
125
+ db.prepare(`UPDATE actors SET
126
+ node_type = ?, role = COALESCE(role, ?), parent_id = COALESCE(parent_id, ?), delivery_id = COALESCE(delivery_id, ?),
127
+ stream_key = COALESCE(?, stream_key), cursor_title = COALESCE(cursor_title, ?),
128
+ prompt_title = COALESCE(prompt_title, ?), persona_name = COALESCE(persona_name, ?),
129
+ persona_first = COALESCE(persona_first, ?), persona_username = COALESCE(persona_username, ?),
130
+ persona_seed = COALESCE(persona_seed, ?), persona_json = COALESCE(persona_json, ?),
131
+ started_at = COALESCE(started_at, ?), last_at = MAX(COALESCE(last_at, 0), COALESCE(?, 0)), tokens_est = COALESCE(?, tokens_est),
132
+ msgs_in = COALESCE(?, msgs_in), msgs_out = COALESCE(?, msgs_out),
133
+ repo = COALESCE(?, repo), channel = COALESCE(?, channel), extra = COALESCE(?, extra)
134
+ WHERE id = ?`)
135
+ .run(nodeType, patch.role || null, patch.parent_id || null, patch.delivery_id || null, patch.stream_key || null,
136
+ patch.cursor_title || null, patch.prompt_title || null, patch.persona_name || null,
137
+ patch.persona_first || null, patch.persona_username || null, patch.persona_seed ?? null, jsonCol(patch.persona || patch.persona_json),
138
+ patch.started_at || null, patch.last_at ?? null, patch.tokens_est ?? null, patch.msgs_in ?? null, patch.msgs_out ?? null,
139
+ patch.repo || null, patch.channel || null, jsonCol(patch.extra || null), id);
140
+ return get(id);
141
+ }
142
+ db.prepare(`UPDATE actors SET
143
+ node_type = ?, role = COALESCE(?, role), parent_id = COALESCE(?, parent_id),
144
+ delivery_id = COALESCE(?, delivery_id), stream_key = COALESCE(?, stream_key),
145
+ cursor_title = COALESCE(?, cursor_title), prompt_title = COALESCE(?, prompt_title),
146
+ persona_name = COALESCE(?, persona_name), persona_first = COALESCE(?, persona_first),
147
+ persona_username = COALESCE(?, persona_username), persona_seed = COALESCE(?, persona_seed),
148
+ persona_json = COALESCE(?, persona_json), source = ?,
149
+ started_at = COALESCE(started_at, ?), last_at = COALESCE(?, last_at),
150
+ tokens_est = COALESCE(?, tokens_est), msgs_in = COALESCE(?, msgs_in), msgs_out = COALESCE(?, msgs_out),
151
+ repo = COALESCE(?, repo), channel = COALESCE(?, channel), synthetic = ?, extra = COALESCE(?, extra)
152
+ WHERE id = ?`)
153
+ .run(nodeType, patch.role || null, patch.parent_id || null, patch.delivery_id || null, patch.stream_key || null,
154
+ patch.cursor_title || null, patch.prompt_title || null, patch.persona_name || null, patch.persona_first || null,
155
+ patch.persona_username || null, patch.persona_seed ?? null, jsonCol(patch.persona || patch.persona_json),
156
+ rank >= priorRank ? source : prior.source, patch.started_at || null, patch.last_at || null,
157
+ patch.tokens_est ?? null, patch.msgs_in ?? null, patch.msgs_out ?? null, patch.repo || null, patch.channel || null,
158
+ patch.synthetic ? 1 : (prior.synthetic || 0), jsonCol(patch.extra || null), id);
159
+ return get(id);
160
+ }, 'upsertInferred');
161
+ }
162
+
163
+ function heartbeat(id, { event = null, tool = null, at = Date.now() } = {}) {
164
+ if (!id) return;
165
+ ensure(id, { source: 'hook' });
166
+ db.prepare(`INSERT INTO presence (actor_id, last_seen, last_event, last_tool) VALUES (?, ?, ?, ?)
167
+ ON CONFLICT(actor_id) DO UPDATE SET
168
+ last_seen = MAX(presence.last_seen, excluded.last_seen),
169
+ last_event = CASE WHEN excluded.last_seen >= presence.last_seen THEN excluded.last_event ELSE presence.last_event END,
170
+ last_tool = CASE WHEN excluded.last_seen >= presence.last_seen THEN excluded.last_tool ELSE presence.last_tool END`)
171
+ .run(id, at, event, tool);
172
+ db.prepare('UPDATE actors SET last_at = MAX(COALESCE(last_at, 0), ?) WHERE id = ?').run(at, id);
173
+ }
174
+
175
+ function presence(id) { return presenceStmt.get(id) || null; }
176
+
177
+ function presences() {
178
+ const map = new Map();
179
+ for (const p of allPresenceStmt.all()) map.set(p.actor_id, p);
180
+ return map;
181
+ }
182
+
183
+ function directory(now = Date.now()) {
184
+ const live = presences();
185
+ return all().map((a) => {
186
+ const p = live.get(a.id);
187
+ const seenAt = p ? p.last_seen : (a.last_at || 0);
188
+ const online = !!(p && now - p.last_seen < ONLINE_MS);
189
+ const status = online ? 'working' : ((a.extra && a.extra.status) || 'done');
190
+ return {
191
+ ...a,
192
+ id: a.id,
193
+ role: a.role,
194
+ streamKey: a.stream_key,
195
+ deliveryId: a.delivery_id || (a.node_type === 'orchestrator' ? a.id : a.parent_id) || null,
196
+ parent: a.parent_id,
197
+ isArchitect: a.node_type === 'orchestrator',
198
+ status,
199
+ topics: a.topics || [],
200
+ online: !!(p && now - p.last_seen < ONLINE_MS),
201
+ seenAt,
202
+ streams: parseJson(a.extra && a.extra.streams, a.extra && Array.isArray(a.extra.streams) ? a.extra.streams : []),
203
+ };
204
+ });
205
+ }
206
+
207
+ function registrations(now = Date.now()) {
208
+ return directory(now).filter((a) => a.source === 'registered' || a.online || a.id === 'user' || a.id === 'manager')
209
+ .map((a) => ({ id: a.id, role: a.role, name: a.persona_name, topics: a.topics, parent: a.parent_id, seenAt: a.seenAt, online: a.online }));
210
+ }
211
+
212
+ /**
213
+ * The agents a topic reaches. Finished (not online, status done) agents are skipped for group topics, still
214
+ * addressable directly via agent/<id>.
215
+ */
216
+ function resolveTopic(topic, from, now = Date.now()) {
217
+ if (!TOPIC.test(String(topic || ''))) return [];
218
+ const dir = directory(now);
219
+ const live = dir.filter((a) => a.status !== 'done' && ADDRESS.test(a.id));
220
+ let ids = [];
221
+ if (topic === 'manager' || topic === 'agent/manager') ids = ['manager'];
222
+ else if (topic === 'user' || topic === 'agent/user') ids = ['user'];
223
+ else if (topic.startsWith('agent/')) ids = [topic.slice(6)];
224
+ else if (topic.startsWith('stream/')) {
225
+ const key = topic.slice(7);
226
+ const crew = live.filter((a) => a.streamKey === key);
227
+ const owner = dir.find((a) => a.isArchitect && (a.streams || []).includes(key));
228
+ ids = [...crew.map((a) => a.id), ...(owner ? [owner.id] : [])];
229
+ } else if (topic.startsWith('delivery/')) {
230
+ const id = topic.slice(9);
231
+ ids = dir.filter((a) => a.isArchitect && a.deliveryId === id).map((a) => a.id);
232
+ } else if (topic.startsWith('role/')) {
233
+ const role = topic.slice(5);
234
+ ids = live.filter((a) => (role === 'ARCHITECT' ? a.isArchitect : a.role === role)).map((a) => a.id);
235
+ } else if (topic === 'broadcast') ids = live.map((a) => a.id);
236
+ ids.push(...live.filter((a) => Array.isArray(a.topics) && a.topics.includes(topic)).map((a) => a.id));
237
+ return [...new Set(ids)].filter((id) => id !== from && ADDRESS.test(id));
238
+ }
239
+
240
+ function setFact(kind, key, payload) {
241
+ db.prepare(`INSERT INTO facts (kind, key, payload, updated_at) VALUES (?, ?, ?, ?)
242
+ ON CONFLICT(kind, key) DO UPDATE SET payload = excluded.payload, updated_at = excluded.updated_at`)
243
+ .run(kind, key, jsonCol(payload), Date.now());
244
+ }
245
+
246
+ function dropFact(kind, key) {
247
+ db.prepare('DELETE FROM facts WHERE kind = ? AND key = ?').run(kind, key);
248
+ }
249
+
250
+ /** Drop every fact of a kind whose key is not in `keys` (a discovery pass replaces what it owns). */
251
+ function keepFacts(kind, keys) {
252
+ const keep = new Set(keys);
253
+ const del = db.prepare('DELETE FROM facts WHERE kind = ? AND key = ?');
254
+ for (const r of db.prepare('SELECT key FROM facts WHERE kind = ?').all(kind)) if (!keep.has(r.key)) del.run(kind, r.key);
255
+ }
256
+
257
+ function fact(kind, key) {
258
+ const row = db.prepare('SELECT payload FROM facts WHERE kind = ? AND key = ?').get(kind, key);
259
+ return row ? parseJson(row.payload, null) : null;
260
+ }
261
+
262
+ function facts(kind) {
263
+ return db.prepare('SELECT key, payload, updated_at FROM facts WHERE kind = ?').all(kind)
264
+ .map((r) => ({ key: r.key, ...parseJson(r.payload, {}), updated_at: r.updated_at }));
265
+ }
266
+
267
+ function appendEvent({ at = Date.now(), kind, actor = null, payload = {} }) {
268
+ db.prepare('INSERT INTO events (at, kind, actor, payload) VALUES (?, ?, ?, ?)').run(at, kind, actor, jsonCol(payload));
269
+ }
270
+
271
+ function eventsSince(sinceMs = 0, limit = 400) {
272
+ return db.prepare('SELECT * FROM events WHERE at >= ? ORDER BY at DESC LIMIT ?').all(sinceMs, limit)
273
+ .map((e) => ({ id: e.id, at: e.at, kind: e.kind, actor: e.actor, ...parseJson(e.payload, {}) }))
274
+ .reverse();
275
+ }
276
+
277
+ return {
278
+ get, all, ensure, register, upsertInferred, heartbeat, presence, presences, directory,
279
+ registrations, resolveTopic, setFact, dropFact, keepFacts, fact, facts, appendEvent, eventsSince, displayTitle,
280
+ };
281
+ }
@@ -0,0 +1,58 @@
1
+ // agents-hub core/ticks.mjs at 6b2fb0b. Ported verbatim; every change is a declared seam (test/hub-verbatim.test.js).
2
+ // Worker ticks run from timers, where an uncaught error ends the hub. A guarded tick logs, backs off and carries on.
3
+ import { setTimeout as sleep } from 'node:timers/promises';
4
+
5
+ export const BACKOFF_MIN_MS = 1000;
6
+ export const BACKOFF_MAX_MS = 30e3;
7
+
8
+ /** SQLITE_BUSY and its extended codes (BUSY_RECOVERY, BUSY_SNAPSHOT, BUSY_TIMEOUT). */
9
+ export const isBusy = (e) => !!e && Number.isInteger(e.errcode) && (e.errcode & 0xff) === 5;
10
+
11
+ /** What node:sqlite throws when busy_timeout runs out, for tests that inject it. */
12
+ export const busyError = () => Object.assign(new Error('database is locked'), { code: 'ERR_SQLITE_ERROR', errcode: 5, errstr: 'database is locked' });
13
+
14
+ const messageOf = (e) => (e && e.message ? e.message : String(e));
15
+
16
+ export const RETRY_MS = [250, 500, 1000, 2000, 4000];
17
+
18
+ /** Runs `fn` again after each delay while it throws SQLITE_BUSY; any other error, or the last BUSY, is thrown. */
19
+ export async function retryBusy(fn, { delaysMs = RETRY_MS, label = 'write', log = console.error } = {}) {
20
+ for (let i = 0; ; i++) {
21
+ try { return fn(); } catch (e) {
22
+ if (!isBusy(e) || i >= delaysMs.length) throw e;
23
+ log(`${label}: database busy, retrying in ${delaysMs[i]} ms`);
24
+ await sleep(delaysMs[i]);
25
+ }
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Wraps a sync or async tick. After a failure the next calls are skipped for minMs, doubling per failure in a row up to
31
+ * maxMs; a success resets it. A tick still running is never started twice.
32
+ */
33
+ export function guardTick(name, fn, { minMs = BACKOFF_MIN_MS, maxMs = BACKOFF_MAX_MS, now = Date.now, log = console.error } = {}) {
34
+ let failures = 0;
35
+ let until = 0;
36
+ let pending = null;
37
+ const fail = (e) => {
38
+ failures += 1;
39
+ const wait = Math.min(maxMs, minMs * 2 ** (failures - 1));
40
+ until = now() + wait;
41
+ log(`${name} tick failed (${failures} in a row, next try in ${Math.round(wait / 100) / 10} s): ${messageOf(e)}`);
42
+ };
43
+ const ok = (out) => {
44
+ if (failures) log(`${name} tick recovered after ${failures} failure${failures === 1 ? '' : 's'}`);
45
+ failures = 0;
46
+ until = 0;
47
+ return out;
48
+ };
49
+ return function guarded(...args) {
50
+ if (pending) return pending;
51
+ if (now() < until) return undefined;
52
+ let out;
53
+ try { out = fn(...args); } catch (e) { fail(e); return undefined; }
54
+ if (!out || typeof out.then !== 'function') return ok(out);
55
+ pending = out.then(ok, fail).finally(() => { pending = null; });
56
+ return pending;
57
+ };
58
+ }
@@ -0,0 +1,125 @@
1
+ // agents-hub core/wake.mjs at 6b2fb0b. Ported verbatim; every change is a declared seam (test/hub-verbatim.test.js).
2
+ // Waking a stopped subagent so it can read your message. Nothing outside Cursor can start a turn in a chat; a finished
3
+ // subagent can be resumed only by the session that started it (Task tool, by id). So the hub asks that parent, once,
4
+ // and again after WAKE_AGAIN_MS if your message is still waiting.
5
+ import { ADDRESS, managerChats } from './ids.mjs';
6
+ import { edgeId } from './edgeIds.mjs';
7
+
8
+ export const WAKE_AGAIN_MS = 15 * 60e3;
9
+ /** No hook event for this long: the subagent is not running (long commands can be silent for minutes). */
10
+ export const STOPPED_MS = 5 * 60e3;
11
+
12
+ const wakeText = (name, id) => `The user sent a direct message to your subagent ${name || id} (${id}), which looks stopped. `
13
+ + `Resume it now with the Task tool (resume: "${id}", run_in_background: true). Its message is already queued in its own bus inbox; `
14
+ + 'tell it to answer that message and then carry on if work remains. If it is in fact still running, do nothing. Do not answer in its place.';
15
+
16
+ /** Ask a subagent's parent to resume it. Returns the wake record, or null when it has no parent the hub can reach. */
17
+ export function requestWake(hub, childId, { name = null, now = Date.now() } = {}) {
18
+ const { registry, messaging } = hub;
19
+ const child = registry.get(childId);
20
+ if (!child || !child.parent_id || !ADDRESS.test(child.parent_id) || child.parent_id === childId) return null;
21
+ if (registry.fact('run', childId)) return null;
22
+ const prior = registry.fact('wake', childId);
23
+ if (prior && now - prior.at < WAKE_AGAIN_MS) return prior;
24
+ const env = messaging.publish({
25
+ id: edgeId('w', childId, Math.floor(now / WAKE_AGAIN_MS)), from: 'manager', fromName: 'Manager', topic: `agent/${child.parent_id}`, type: 'WAKE_CHILD', needsAck: true,
26
+ body: wakeText(name || child.persona_name || child.role, childId),
27
+ }, registry.directory());
28
+ if (env.error || !env.to.length) return null;
29
+ const record = { at: now, parent: child.parent_id, messageId: env.id };
30
+ registry.setFact('wake', childId, record);
31
+ return record;
32
+ }
33
+
34
+ /** The scheduled nudge reaches agents heard from this recently, at most NUDGE_CAP per tick. */
35
+ export const NUDGE_LIVE_MS = 3 * 3600e3;
36
+ export const NUDGE_CAP = 50;
37
+
38
+ export const nudgeDue = (last, everyMin, now) => everyMin > 0 && (!Number.isFinite(last) || last > now || now - last >= everyMin * 60e3);
39
+
40
+ /**
41
+ * Who gets this tick's scheduled nudge: agents and orchestrators heard from within NUDGE_LIVE_MS that are not done,
42
+ * not excluded and not still holding an earlier scheduled nudge; the longest silent first.
43
+ */
44
+ export function nudgeTargets({ actors, seen, done = new Set(), pending = new Set(), exclude = new Set(), now, cap = NUDGE_CAP }) {
45
+ return actors
46
+ .filter((a) => (a.node_type === 'agent' || a.node_type === 'orchestrator') && ADDRESS.test(a.id) && a.id !== 'user' && a.id !== 'manager')
47
+ .filter((a) => !done.has(a.id) && !pending.has(a.id) && !exclude.has(a.id))
48
+ .map((a) => ({ id: a.id, seenAt: seen.get(a.id) || 0 }))
49
+ .filter((a) => a.seenAt > 0 && now - a.seenAt < NUDGE_LIVE_MS)
50
+ .sort((a, b) => a.seenAt - b.seenAt)
51
+ .slice(0, cap);
52
+ }
53
+
54
+ const nudgeText = (every) => `Scheduled check-in from the hub (every ${every} min). Status is mandatory: post your current state in your task channel now. `
55
+ + 'Post `STATUS` if you are working or waiting (say on whom), `BLOCKER` if you are stuck, or your terminal post '
56
+ + '(`DONE`, `FINAL: PASS` / `FINAL: FAIL`, `STOPPED — <reason>`, `HANDED OVER — <to whom>`) if you have finished. '
57
+ + 'If you have no task channel yet, open `<repo>/.agents/<task>/channel.md` with a `KICKOFF` post stating your task. Then ack this message.';
58
+
59
+ /**
60
+ * Agents finished by what the Board shows: a stream whose final review passed, or a done agent row or delivery. Rows
61
+ * and deliveries are only as fresh as the last collect, so one older than the agent's last sign of life does not count.
62
+ */
63
+ function doneIds(registry, actors, seen) {
64
+ const rows = new Map(registry.facts('agent-row').map((r) => [r.key, r]));
65
+ const deliveries = new Map(registry.facts('delivery').map((d) => [d.key, d]));
66
+ const done = new Set();
67
+ for (const a of actors) {
68
+ const row = rows.get(a.id);
69
+ const key = a.stream_key || (row && row.streamKey);
70
+ const stream = key && registry.fact('stream', key);
71
+ const d = a.node_type === 'orchestrator' && deliveries.get(a.id);
72
+ const current = (f) => f && f.status === 'done' && f.updated_at >= (seen.get(a.id) || 0);
73
+ if ((stream && stream.done) || current(row) || current(d)) done.add(a.id);
74
+ }
75
+ return done;
76
+ }
77
+
78
+ /** Send the scheduled nudge when it is due. The last run is kept in hub.db so a restart keeps the cadence. */
79
+ export function scheduledNudge(hub, { everyMin, now = Date.now(), skip = null, released = null } = {}) {
80
+ const { db, registry, messaging } = hub;
81
+ const last = (registry.fact('wake', 'scheduled') || {}).at;
82
+ if (!nudgeDue(last, everyMin, now)) return null;
83
+ const actors = registry.all();
84
+ const seen = new Map(actors.map((a) => [a.id, a.last_at || 0]));
85
+ for (const [id, p] of registry.presences()) seen.set(id, Math.max(seen.get(id) || 0, p.last_seen));
86
+ // Scheduled and Board nudges ask for an ack; the manager worker's own NUDGE posts do not, so they never hold one back.
87
+ const pending = new Set(db.prepare(`SELECT DISTINCT d.recipient AS id FROM messages m JOIN deliveries d ON d.message_id = m.id
88
+ WHERE m.created_at > ? AND m.expires_at > ? AND m.sender = 'manager' AND m.type = 'NUDGE' AND m.needs_reply = 1
89
+ AND d.state IN ('queued','leased','delivered')`).all(now - NUDGE_LIVE_MS, now).map((r) => r.id));
90
+ const exclude = new Set(registry.facts('run').map((r) => r.key));
91
+ for (const chat of managerChats(registry.fact('manager', 'config'))) exclude.add(chat);
92
+ if (skip) for (const id of skip(last)) exclude.add(id);
93
+ if (released) for (const id of released()) pending.delete(id);
94
+ const targets = nudgeTargets({ actors, seen, done: doneIds(registry, actors, seen), pending, exclude, now });
95
+ const dir = targets.length ? registry.directory(now) : [];
96
+ const sent = [];
97
+ for (const t of targets) {
98
+ const env = messaging.publish({
99
+ id: edgeId('n', Math.floor(now / (everyMin * 60e3)), t.id), from: 'manager', fromName: 'Manager', topic: `agent/${t.id}`, type: 'NUDGE', body: nudgeText(everyMin),
100
+ needsAck: true, ttlMs: NUDGE_LIVE_MS, meta: { scheduled: true },
101
+ }, dir);
102
+ if (!env.error && env.to.length) sent.push(t.id);
103
+ }
104
+ registry.setFact('wake', 'scheduled', { at: now, sent: sent.length });
105
+ registry.appendEvent({ at: now, kind: 'WAKE', actor: 'manager', payload: {
106
+ from: 'manager', to: '*', type: 'WAKE', count: sent.length,
107
+ text: `Scheduled nudge (every ${everyMin} min): ${sent.length} agent${sent.length === 1 ? '' : 's'}`,
108
+ } });
109
+ return sent;
110
+ }
111
+
112
+ /** For every subagent with a message from you still queued and no hook event since it arrived, ask its parent once. */
113
+ export function sweepWakes(hub, now = Date.now()) {
114
+ const { db, registry } = hub;
115
+ const rows = db.prepare(`SELECT d.recipient AS id, MIN(m.created_at) AS first FROM deliveries d JOIN messages m ON m.id = d.message_id
116
+ WHERE m.sender = 'user' AND d.state = 'queued' AND m.expires_at > ? AND m.created_at < ? GROUP BY d.recipient`).all(now, now - 60e3);
117
+ const woken = [];
118
+ for (const r of rows) {
119
+ const seen = registry.presence(r.id);
120
+ if (seen && (seen.last_seen > r.first || now - seen.last_seen < STOPPED_MS)) continue;
121
+ const w = requestWake(hub, r.id, { now });
122
+ if (w && w.at === now) woken.push(r.id);
123
+ }
124
+ return woken;
125
+ }
@@ -0,0 +1,16 @@
1
+ // Where the ported agents-hub core finds the hub it serves. Only the runner child process loads the core, and it sets
2
+ // both variables before it starts; an unset value is a hard error, never a default inside the plugin tree, so the core
3
+ // can't create state next to the plugin or open the frozen hub.db by accident.
4
+ import path from 'node:path';
5
+
6
+ function required(name) {
7
+ const value = process.env[name];
8
+ if (!value) throw new Error(`${name} is not set: the hub core runs only inside the Pulse runner process`);
9
+ return path.resolve(value);
10
+ }
11
+
12
+ /** The agents-hub folder whose state/ (token, personas, settings, markers, manager files) the runner keeps using. */
13
+ export const hubHome = () => required('PULSE_HUB_HOME');
14
+
15
+ /** The runner's SQLite file; it holds the hub ledger tables in place of state/hub.db. */
16
+ export const runnerDb = () => required('PULSE_RUNNER_DB');
@@ -0,0 +1,167 @@
1
+ #!/usr/bin/env node
2
+ // agents-hub hooks/hub-bridge.mjs at 6b2fb0b. Ported verbatim; every change is a declared seam (test/hub-verbatim.test.js).
3
+ /**
4
+ * Thin Cursor hook adapter over the hub SQLite ledger.
5
+ * Reply rules:
6
+ * 1. Agent-to-agent mail is never auto-captured. The agent answers with `hub bus reply --id <m>`.
7
+ * 2. A direct message from you is shown mid-turn as a side chat (postToolUse): the agent answers it with
8
+ * `bus reply` and keeps working. Nothing it writes is auto-captured for it.
9
+ * 3. A message from you still unanswered when the turn ends comes back as the next prompt (stop hook);
10
+ * only then is the next response captured as the reply to those message ids.
11
+ * 4. Other mail shown mid-turn includes the id and the reply command. Nothing is auto-captured.
12
+ * Always fails open: any problem prints {} so the chat is never blocked.
13
+ */
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+ import { fileURLToPath } from 'node:url';
17
+ import { openHub } from '../core/hub.mjs';
18
+ import { hubHome } from '../home.mjs';
19
+ import { CHANNEL_POST_RULE, DECISION_RULE, WRITING_GUIDE } from '../lib/writing.mjs';
20
+ import { isFyi } from '../core/ids.mjs';
21
+
22
+ const STATE = path.join(hubHome(), 'state');
23
+ const ID = /^[A-Za-z0-9._-]{1,200}$/;
24
+ const REPLY_MAX = 8000;
25
+ /** Cursor stops honouring a stop hook's followup_message after this many automatic follow-ups in a row. */
26
+ const STOP_LOOP_LIMIT = 5;
27
+ const CLI = path.join(hubHome(), 'hub.mjs');
28
+
29
+ const done = (o = {}) => { process.stdout.write(JSON.stringify(o)); process.exit(0); };
30
+ const clock = (t) => new Date(t).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
31
+
32
+ function splitReaction(raw) {
33
+ const m = String(raw).match(/^\s*\**React\**:\s*(\p{Extended_Pictographic}(?:\uFE0F|\u200D\p{Extended_Pictographic}|\p{Emoji_Modifier})*)\s*(?:\n|$)/u);
34
+ return m ? { reaction: m[1], text: raw.slice(m[0].length).trim() } : { reaction: null, text: raw };
35
+ }
36
+
37
+ function replyHint(ids) {
38
+ return ids.map((id) => `node ${CLI} bus reply --id ${id} --as <your-address> "<answer>"`).join('\n');
39
+ }
40
+
41
+ /** A multi-line markdown answer survives the shell only inside a quoted heredoc. */
42
+ function markdownReplyHint(ids) {
43
+ return ids.map((id) => `node ${CLI} bus reply --id ${id} --as <your-address> "$(cat <<'EOF'\n<your answer in markdown>\nEOF\n)"`).join('\n');
44
+ }
45
+
46
+ const mailLines = (pending) => pending.map((m) => `[${clock(m.at)}] ${m.fromName || m.from || 'User'} · ${m.type || 'MESSAGE'} · ${m.topic || 'direct'} · id ${m.id}\n${m.text}`).join('\n\n');
47
+
48
+ /** How to answer the user: the agent's persona voice (attached to each direct message) and the markdown guide. */
49
+ function howToAnswer(pending) {
50
+ const voice = pending.map((m) => m.meta && m.meta.voice).find((v) => typeof v === 'string' && v);
51
+ return [voice, WRITING_GUIDE].filter(Boolean).join('\n');
52
+ }
53
+
54
+ function formatMail(pending, { autoCapture }) {
55
+ const parts = mailLines(pending);
56
+ if (autoCapture) {
57
+ return `Messages delivered by the Agent Hub bus (answer these in this turn; the hub will capture this reply only for these ids):\n${howToAnswer(pending)}\n\n${parts}`;
58
+ }
59
+ return `Messages from the Agent Hub bus. They are not auto-captured. Reply with:\n${replyHint(pending.map((m) => m.id))}\n\n${parts}`;
60
+ }
61
+
62
+ function formatSideChat(pending) {
63
+ return 'Side chat from the user while you work. Answer now in one short reply each (status, a question, a quick fact), '
64
+ + 'then carry on with your task exactly where you were; do not stop or change course unless the message asks you to.\n'
65
+ + `${howToAnswer(pending)}\nReply with:\n${markdownReplyHint(pending.map((m) => m.id))}\n\n${mailLines(pending)}`;
66
+ }
67
+
68
+ function intro(input, id) {
69
+ const roots = Array.isArray(input.workspace_roots) ? input.workspace_roots : [];
70
+ if (!roots.some((r) => String(r).startsWith('/Volumes/docs/My Experiments'))) return null;
71
+ const marker = path.join(STATE, 'live', `${id}.introduced`);
72
+ if (fs.existsSync(marker)) return null;
73
+ fs.mkdirSync(path.dirname(marker), { recursive: true, mode: 0o700 });
74
+ fs.writeFileSync(marker, String(Date.now()), { mode: 0o600 });
75
+ return `Agent Hub connected. Your unique bus address is ${id}. Use the always-applied Agent Hub rule for topics and commands. `
76
+ + `Register your assigned role when convenient. Direct messages from the user arrive between your steps as a side chat: `
77
+ + `answer them with the reply command and keep working; any left unanswered come back when your turn ends. `
78
+ + `Other mail includes a reply command and is never auto-captured. Your role and delivery gates do not change. `
79
+ + `${WRITING_GUIDE} ${CHANNEL_POST_RULE} ${DECISION_RULE}`;
80
+ }
81
+
82
+ export function handleHook(input, hub) {
83
+ const event = input.hook_event_name;
84
+ const id = input.conversation_id;
85
+ if (!event || !ID.test(String(id || ''))) return {};
86
+ const { registry, messaging } = hub;
87
+ registry.ensure(id, { source: 'hook' });
88
+ registry.heartbeat(id, {
89
+ event,
90
+ tool: typeof input.tool_name === 'string' ? input.tool_name.slice(0, 60) : null,
91
+ });
92
+
93
+ if (event === 'afterAgentResponse') {
94
+ const waiting = messaging.awaiting(id).filter((w) => w.sender === 'user');
95
+ if (waiting.length && typeof input.text === 'string' && input.text.trim()) {
96
+ const { reaction, text } = splitReaction(input.text);
97
+ waiting.forEach((w, i) => {
98
+ messaging.ack(id, w.id, {
99
+ reply: i === waiting.length - 1 ? text.slice(0, REPLY_MAX) : null,
100
+ reaction: i === waiting.length - 1 ? reaction : null,
101
+ });
102
+ });
103
+ }
104
+ return {};
105
+ }
106
+
107
+ if (event === 'postToolUse' || event === 'postToolUseFailure') {
108
+ const introduction = intro(input, id);
109
+ const pending = messaging.peek(id);
110
+ const others = pending.filter((m) => m.from !== 'user');
111
+ const fromUser = pending.filter((m) => m.from === 'user');
112
+ if (others.length) messaging.markDelivered(id, others.map((m) => m.id), { via: 'postToolUse', awaitingReply: false });
113
+ if (fromUser.length) messaging.markDelivered(id, fromUser.map((m) => m.id), { via: 'side', awaitingReply: false });
114
+ if (!others.length && !fromUser.length && !introduction) return {};
115
+ const parts = [introduction];
116
+ if (fromUser.length) parts.push(formatSideChat(fromUser));
117
+ if (others.length) parts.push(formatMail(others, { autoCapture: false }));
118
+ return { additional_context: parts.filter(Boolean).join('\n\n') };
119
+ }
120
+
121
+ if (event === 'stop') {
122
+ messaging.releaseAwaiting(id);
123
+ const willRun = (input.status === undefined || input.status === 'completed') && !(Number(input.loop_count) >= STOP_LOOP_LIMIT);
124
+ if (!willRun) return {};
125
+ const pending = messaging.peek(id);
126
+ const unanswered = messaging.sideUnanswered(id);
127
+ const fromUser = pending.filter((m) => m.from === 'user');
128
+ const others = pending.filter((m) => m.from !== 'user');
129
+ if (fromUser.length || unanswered.length) {
130
+ const claimed = pending.length ? messaging.claim(id, { via: 'stop', limit: pending.length }) : [];
131
+ const userMail = [...unanswered, ...claimed.filter((m) => m.from === 'user')];
132
+ messaging.markDelivered(id, userMail.map((m) => m.id), { via: 'stop', awaitingReply: true });
133
+ const otherMail = claimed.filter((m) => m.from !== 'user');
134
+ if (otherMail.length) messaging.markDelivered(id, otherMail.map((m) => m.id), { via: 'stop', awaitingReply: false });
135
+ const parts = [formatMail(userMail, { autoCapture: true })];
136
+ if (otherMail.length) parts.push(formatMail(otherMail, { autoCapture: false }));
137
+ return { followup_message: parts.join('\n\n---\n\n') };
138
+ }
139
+ // Only mail that asks for an answer is worth a new turn; an FYI reply waits for this chat's next step.
140
+ const actionable = others.filter((m) => !isFyi(m));
141
+ if (actionable.length) {
142
+ const claimed = messaging.claim(id, { via: 'stop', limit: pending.length }).filter((m) => actionable.some((a) => a.id === m.id));
143
+ messaging.markDelivered(id, claimed.map((m) => m.id), { via: 'stop', awaitingReply: false });
144
+ if (claimed.length) return { followup_message: formatMail(claimed, { autoCapture: false }) };
145
+ }
146
+ return {};
147
+ }
148
+ return {};
149
+ }
150
+
151
+ function main(input) {
152
+ try {
153
+ const hub = openHub(STATE);
154
+ done(handleHook(input, hub));
155
+ } catch {
156
+ done();
157
+ }
158
+ }
159
+
160
+ const invoked = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
161
+ if (invoked) {
162
+ let raw = '';
163
+ process.stdin.setEncoding('utf8');
164
+ process.stdin.on('data', (c) => { raw += c; });
165
+ process.stdin.on('end', () => { try { main(JSON.parse(raw || '{}')); } catch { done(); } });
166
+ setTimeout(() => done(), 4000).unref();
167
+ }