@agentpulselabs/cli-plugin-pulse 1.0.0 → 1.1.3

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 (102) hide show
  1. package/README.md +55 -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 +63 -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 +20 -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 +411 -0
  55. package/src/lib/cutoverRequest.js +485 -0
  56. package/src/lib/cutoverWiring.js +140 -0
  57. package/src/lib/daemonLock.js +2 -0
  58. package/src/lib/daemonLoop.js +5 -2
  59. package/src/lib/daemonProcess.js +57 -6
  60. package/src/lib/daemonRuntime.js +8 -0
  61. package/src/lib/doctorWarnings.js +53 -0
  62. package/src/lib/errors.js +14 -0
  63. package/src/lib/hubDbReader.js +132 -0
  64. package/src/lib/hubMapping.js +108 -0
  65. package/src/lib/hubScan.js +611 -0
  66. package/src/lib/hubSnapshot.js +64 -0
  67. package/src/lib/mirrorContract.js +1 -1
  68. package/src/lib/mirrorService.js +8 -1
  69. package/src/lib/mirrorSource.js +13 -38
  70. package/src/lib/nodeFloor.js +19 -0
  71. package/src/lib/outboxFlusher.js +75 -0
  72. package/src/lib/pairingLifecycle.js +1 -1
  73. package/src/lib/reconcile.js +316 -0
  74. package/src/lib/reconcileView.js +129 -0
  75. package/src/lib/runnerStore.js +418 -0
  76. package/src/lib/serviceManager.js +64 -1
  77. package/src/lib/shadowService.js +435 -0
  78. package/src/lib/sqlite.js +28 -0
  79. package/src/lib/tokenStore.js +43 -4
  80. package/src/lib/transport.js +53 -5
  81. package/src/lib/transportContract.js +3 -0
  82. package/src/lib/uploadRedactor.js +102 -0
  83. package/src/runner/applier.js +297 -0
  84. package/src/runner/bridge.js +289 -0
  85. package/src/runner/busIdentity.js +499 -0
  86. package/src/runner/capture.js +103 -0
  87. package/src/runner/captureMapping.js +21 -0
  88. package/src/runner/channelDiscipline.js +425 -0
  89. package/src/runner/deliveryStates.js +220 -0
  90. package/src/runner/facade.js +876 -0
  91. package/src/runner/hubCapture.js +160 -0
  92. package/src/runner/lease.js +46 -0
  93. package/src/runner/main.js +35 -0
  94. package/src/runner/render.js +91 -0
  95. package/src/runner/runnerChild.js +57 -0
  96. package/src/runner/runnerHub.js +104 -0
  97. package/src/runner/runnerProcess.js +124 -0
  98. package/src/runner/runnerSchema.js +51 -0
  99. package/src/runner/runnerService.js +149 -0
  100. package/src/runner/spool.js +86 -0
  101. package/src/runner/spoolInto.js +22 -0
  102. package/src/runner/statusPage.js +30 -0
@@ -0,0 +1,322 @@
1
+ // agents-hub core/messaging.mjs at 6b2fb0b. Ported verbatim; every change is a declared seam (test/hub-verbatim.test.js).
2
+ // Transactional publish, leased claims, idempotent ack/reply, expiry. One write, all recipients, or none.
3
+ import crypto from 'node:crypto';
4
+ import { ADDRESS, BODY_MAX, DEFAULT_TTL_MS, LEASE_MS, MESSAGE_ID, TOPIC, TYPE } from './ids.mjs';
5
+ import { jsonCol, parseJson, withTx } from './db.mjs';
6
+ import { edgeId } from './edgeIds.mjs';
7
+
8
+ function newId(at = Date.now()) {
9
+ return `m-${at.toString(36)}-${crypto.randomBytes(3).toString('hex')}`;
10
+ }
11
+
12
+ function envRow(r, status = null) {
13
+ if (!r) return null;
14
+ return {
15
+ id: r.id, at: r.created_at, from: r.sender, fromName: r.from_name, topic: r.topic, type: r.type,
16
+ body: r.body, text: r.body, needsAck: !!r.needs_reply, expiresAt: r.expires_at, replyTo: r.reply_to,
17
+ reaction: r.reaction, meta: parseJson(r.meta, {}), to: status ? status.map((s) => s.to) : parseJson(r.recipients, []),
18
+ status,
19
+ };
20
+ }
21
+
22
+ export function createMessaging(db, registry) {
23
+ const getMsg = db.prepare('SELECT * FROM messages WHERE id = ?');
24
+ const getDel = db.prepare('SELECT * FROM deliveries WHERE message_id = ? AND recipient = ?');
25
+ const delsOf = db.prepare('SELECT * FROM deliveries WHERE message_id = ?');
26
+ const byReply = db.prepare('SELECT * FROM messages WHERE reply_to = ? ORDER BY created_at ASC LIMIT 1');
27
+
28
+ /** A message published again under its edge-chosen id: the envelope as first published. */
29
+ function storedEnvelope(r) {
30
+ const to = db.prepare('SELECT recipient FROM deliveries WHERE message_id = ? ORDER BY rowid').all(r.id).map((d) => d.recipient);
31
+ return {
32
+ id: r.id, at: r.created_at, from: r.sender, fromName: r.from_name, topic: r.topic, type: r.type, body: r.body,
33
+ needsAck: !!r.needs_reply, expiresAt: r.expires_at, replyTo: r.reply_to, reaction: r.reaction, to, meta: parseJson(r.meta, {}),
34
+ };
35
+ }
36
+
37
+ function publish({ id: given = null, from, topic, type = 'MESSAGE', body, needsAck = false, ttlMs = DEFAULT_TTL_MS, replyTo = null, fromName = null, reaction = null, meta = {} } = {}, directory) {
38
+ if (!ADDRESS.test(String(from || ''))) return { error: 'bad sender' };
39
+ if (!TOPIC.test(String(topic || ''))) return { error: 'bad topic' };
40
+ if (!TYPE.test(String(type || ''))) return { error: 'bad type' };
41
+ if (typeof body !== 'string' || !body.trim()) return { error: 'empty body' };
42
+ if (given != null && !MESSAGE_ID.test(String(given))) return { error: 'bad id' };
43
+ const prior = given ? getMsg.get(given) : null;
44
+ if (prior) return prior.sender === from ? storedEnvelope(prior) : { error: 'id taken' };
45
+ const at = Date.now();
46
+ const to = directory ? resolveFromDirectory(topic, directory, from) : registry.resolveTopic(topic, from, at);
47
+ const id = given || newId(at);
48
+ const expiresAt = at + Math.max(60e3, Math.min(Number(ttlMs) || DEFAULT_TTL_MS, 7 * DEFAULT_TTL_MS));
49
+ const reply = typeof replyTo === 'string' && MESSAGE_ID.test(replyTo) ? replyTo : null;
50
+ const env = {
51
+ id, at, from, fromName: typeof fromName === 'string' ? fromName.slice(0, 80) : null,
52
+ topic, type, body: body.slice(0, BODY_MAX), needsAck: !!needsAck, expiresAt, replyTo: reply,
53
+ reaction: typeof reaction === 'string' ? reaction.slice(0, 16) : null, to, meta,
54
+ };
55
+ withTx(db, () => {
56
+ db.prepare(`INSERT INTO messages (id, sender, topic, type, body, needs_reply, expires_at, reply_to, reaction, from_name, created_at, meta)
57
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, from, topic, type, env.body, env.needsAck ? 1 : 0, expiresAt, reply, env.reaction, env.fromName, at, jsonCol(meta));
58
+ const insDel = db.prepare(`INSERT INTO deliveries (message_id, recipient, state, queued_at, attempts) VALUES (?, ?, 'queued', ?, 0)`);
59
+ for (const r of to) insDel.run(id, r, at);
60
+ }, 'publish');
61
+ return env;
62
+ }
63
+
64
+ function resolveFromDirectory(topic, directory, from) {
65
+ const live = directory.filter((a) => a.status !== 'done' && ADDRESS.test(a.id));
66
+ let ids = [];
67
+ if (topic === 'manager' || topic === 'agent/manager') ids = ['manager'];
68
+ else if (topic === 'user' || topic === 'agent/user') ids = ['user'];
69
+ else if (topic.startsWith('agent/')) ids = [topic.slice(6)];
70
+ else if (topic.startsWith('stream/')) {
71
+ const key = topic.slice(7);
72
+ const crew = live.filter((a) => a.streamKey === key);
73
+ const owner = directory.find((a) => a.isArchitect && a.streams && a.streams.includes(key));
74
+ ids = [...crew.map((a) => a.id), ...(owner ? [owner.id] : [])];
75
+ } else if (topic.startsWith('delivery/')) {
76
+ const id = topic.slice(9);
77
+ ids = directory.filter((a) => a.isArchitect && a.deliveryId === id).map((a) => a.id);
78
+ } else if (topic.startsWith('role/')) {
79
+ const role = topic.slice(5);
80
+ ids = live.filter((a) => (role === 'ARCHITECT' ? a.isArchitect : a.role === role)).map((a) => a.id);
81
+ } else if (topic === 'broadcast') ids = live.map((a) => a.id);
82
+ ids.push(...live.filter((a) => Array.isArray(a.topics) && a.topics.includes(topic)).map((a) => a.id));
83
+ return [...new Set(ids)].filter((id) => id !== from && ADDRESS.test(id));
84
+ }
85
+
86
+ // Pending deliveries are few and found by index, their messages by primary key. Scanning `messages` instead, under the
87
+ // write lock, on every claim, peek and pendingCount of every connection starved the hub server past busy_timeout.
88
+ const PAST_DUE = `(SELECT expires_at FROM messages WHERE id = deliveries.message_id) <= ?`;
89
+ const expiredDue = db.prepare(`SELECT 1 FROM deliveries WHERE state IN ('queued','leased') AND ${PAST_DUE} LIMIT 1`);
90
+ const lapsedDue = db.prepare(`SELECT 1 FROM deliveries WHERE state = 'leased' AND lease_until IS NOT NULL AND lease_until < ? LIMIT 1`);
91
+
92
+ /** Marks expired mail and lapsed leases. Reads first: with nothing due it never takes the write lock. */
93
+ function expire(now = Date.now()) {
94
+ if (expiredDue.get(now)) {
95
+ db.prepare(`UPDATE deliveries SET state = 'expired', lease_token = NULL, lease_until = NULL
96
+ WHERE state IN ('queued','leased') AND ${PAST_DUE}`).run(now);
97
+ }
98
+ if (lapsedDue.get(now)) {
99
+ db.prepare(`UPDATE deliveries SET state = 'queued', lease_token = NULL, lease_until = NULL
100
+ WHERE state = 'leased' AND lease_until IS NOT NULL AND lease_until < ?`).run(now);
101
+ }
102
+ }
103
+
104
+ /** A reply, ack, done or status, or a reply that asks for nothing: the SQL twin of isFyi (core/ids.mjs). */
105
+ const FYI_SQL = `(UPPER(m.type) IN ('REPLY','ACK','DONE','STATUS') OR (m.reply_to IS NOT NULL AND m.needs_reply = 0))`;
106
+
107
+ /**
108
+ * Claim up to `limit` queued (or expired-lease) deliveries for `as`. Marks them leased in one transaction. With
109
+ * `fyiOnly`, only mail that needs no answer, so a busy worker can settle it without taking questions it cannot run.
110
+ */
111
+ function claim(as, { via = 'listen', leaseMs = LEASE_MS, limit = 1, now = Date.now(), fyiOnly = false } = {}) {
112
+ if (!ADDRESS.test(as)) return [];
113
+ expire(now);
114
+ return withTx(db, () => {
115
+ const rows = db.prepare(`SELECT d.message_id, d.recipient, d.attempts, m.*
116
+ FROM deliveries d JOIN messages m ON m.id = d.message_id
117
+ WHERE d.recipient = ? AND (
118
+ d.state = 'queued'
119
+ OR (d.state = 'leased' AND d.lease_until < ?)
120
+ ) AND m.expires_at > ?${fyiOnly ? ` AND ${FYI_SQL}` : ''}
121
+ ORDER BY d.queued_at ASC LIMIT ?`).all(as, now, now, Math.max(1, Math.min(limit, 20)));
122
+ const token = crypto.randomBytes(8).toString('hex');
123
+ const until = now + Math.max(1, leaseMs);
124
+ const out = [];
125
+ for (const r of rows) {
126
+ const got = db.prepare(`UPDATE deliveries SET state = 'leased', lease_token = ?, lease_until = ?,
127
+ leased_at = ?, attempts = attempts + 1, delivered_via = ?
128
+ WHERE message_id = ? AND recipient = ? AND (state = 'queued' OR (state = 'leased' AND lease_until < ?))`)
129
+ .run(token, until, now, via, r.message_id, as, now);
130
+ if (!got || !got.changes) continue;
131
+ out.push({
132
+ id: r.id, at: r.created_at, from: r.sender, fromName: r.from_name, topic: r.topic, type: r.type,
133
+ text: r.body, body: r.body, needsAck: !!r.needs_reply, expiresAt: r.expires_at, replyTo: r.reply_to,
134
+ reaction: r.reaction, leaseToken: token, leaseUntil: until, attempts: (Number(r.attempts) || 0) + 1,
135
+ meta: parseJson(r.meta, {}),
136
+ });
137
+ }
138
+ return out;
139
+ }, `claim ${as}`);
140
+ }
141
+
142
+ /** Look at queued mail without claiming it (postToolUse mid-turn context). */
143
+ function peek(as, { now = Date.now(), limit = 20 } = {}) {
144
+ if (!ADDRESS.test(as)) return [];
145
+ expire(now);
146
+ return db.prepare(`SELECT m.* FROM deliveries d JOIN messages m ON m.id = d.message_id
147
+ WHERE d.recipient = ? AND d.state = 'queued' AND m.expires_at > ?
148
+ ORDER BY d.queued_at ASC LIMIT ?`).all(as, now, limit).map((r) => ({
149
+ id: r.id, at: r.created_at, from: r.sender, fromName: r.from_name, topic: r.topic, type: r.type,
150
+ text: r.body, body: r.body, needsAck: !!r.needs_reply, expiresAt: r.expires_at, replyTo: r.reply_to,
151
+ reaction: r.reaction, meta: parseJson(r.meta, {}),
152
+ }));
153
+ }
154
+
155
+ /**
156
+ * Back to the queue at once: everything still leased to `as`, whose holder is gone (a restart). The cut-off run does
157
+ * not count as an attempt. Returns the released envelopes.
158
+ */
159
+ function releaseLeases(as, { now = Date.now() } = {}) {
160
+ if (!ADDRESS.test(as)) return [];
161
+ return withTx(db, () => {
162
+ const rows = db.prepare(`SELECT m.* FROM deliveries d JOIN messages m ON m.id = d.message_id
163
+ WHERE d.recipient = ? AND d.state = 'leased' AND m.expires_at > ? ORDER BY d.queued_at ASC`).all(as, now);
164
+ db.prepare(`UPDATE deliveries SET state = 'queued', lease_token = NULL, lease_until = NULL, attempts = MAX(attempts - 1, 0)
165
+ WHERE recipient = ? AND state = 'leased'`).run(as);
166
+ return rows.map((r) => envRow({ ...r, recipients: jsonCol([as]) }));
167
+ }, 'releaseLeases');
168
+ }
169
+
170
+ /** What waits for `as`: all pending (`depth`), not yet taken (`waiting`), and when the oldest was sent. Read-only. */
171
+ function queueStats(as, now = Date.now()) {
172
+ if (!ADDRESS.test(as)) return { depth: 0, waiting: 0, oldestAt: null };
173
+ const row = db.prepare(`SELECT COUNT(*) AS depth, SUM(d.state = 'queued' OR d.lease_until < ?) AS waiting, MIN(m.created_at) AS oldest
174
+ FROM deliveries d JOIN messages m ON m.id = d.message_id
175
+ WHERE d.recipient = ? AND d.state IN ('queued','leased') AND m.expires_at > ?`).get(now, as, now);
176
+ return { depth: Number(row.depth) || 0, waiting: Number(row.waiting) || 0, oldestAt: row.oldest == null ? null : Number(row.oldest) };
177
+ }
178
+
179
+ function renewClaim(as, id, token, { leaseMs = LEASE_MS, now = Date.now() } = {}) {
180
+ if (!ADDRESS.test(as) || !MESSAGE_ID.test(String(id || '')) || !token) return false;
181
+ const r = db.prepare(`UPDATE deliveries SET lease_until = ?
182
+ WHERE message_id = ? AND recipient = ? AND lease_token = ? AND state = 'leased'`).run(now + Math.max(1, leaseMs), id, as, token);
183
+ return !!(r && r.changes);
184
+ }
185
+
186
+ function markDelivered(as, ids, { via = 'hook', awaitingReply = false, now = Date.now() } = {}) {
187
+ const list = (Array.isArray(ids) ? ids : [ids]).filter((id) => MESSAGE_ID.test(id));
188
+ if (!ADDRESS.test(as) || !list.length) return 0;
189
+ return withTx(db, () => {
190
+ let n = 0;
191
+ for (const id of list) {
192
+ const r = db.prepare(`UPDATE deliveries SET state = 'delivered', delivered_at = ?, delivered_via = ?,
193
+ awaiting_reply = ?, lease_token = NULL, lease_until = NULL
194
+ WHERE message_id = ? AND recipient = ? AND state IN ('queued','leased','delivered')`).run(
195
+ now, via, awaitingReply ? 1 : 0, id, as);
196
+ if (r && r.changes) n++;
197
+ }
198
+ return n;
199
+ }, 'markDelivered');
200
+ }
201
+
202
+ /** Your messages shown mid-turn as a side chat that the agent has not answered yet. */
203
+ function sideUnanswered(as) {
204
+ if (!ADDRESS.test(as)) return [];
205
+ return db.prepare(`SELECT m.* FROM deliveries d JOIN messages m ON m.id = d.message_id
206
+ WHERE d.recipient = ? AND d.state = 'delivered' AND d.delivered_via = 'side' AND m.sender = 'user' AND m.expires_at > ?
207
+ ORDER BY d.queued_at ASC`).all(as, Date.now()).map((r) => ({
208
+ id: r.id, at: r.created_at, from: r.sender, fromName: r.from_name, topic: r.topic, type: r.type,
209
+ text: r.body, body: r.body, needsAck: !!r.needs_reply, expiresAt: r.expires_at, replyTo: r.reply_to,
210
+ reaction: r.reaction, meta: parseJson(r.meta, {}),
211
+ }));
212
+ }
213
+
214
+ function awaiting(as) {
215
+ if (!ADDRESS.test(as)) return [];
216
+ return db.prepare(`SELECT d.message_id AS id, m.sender AS sender FROM deliveries d JOIN messages m ON m.id = d.message_id
217
+ WHERE d.recipient = ? AND d.awaiting_reply = 1 AND d.state = 'delivered' AND m.expires_at > ?
218
+ ORDER BY d.queued_at ASC`).all(as, Date.now());
219
+ }
220
+
221
+ /**
222
+ * A reply was expected for these, but the turn that should have answered them is over. They stop waiting, so a later,
223
+ * unrelated answer is never captured, and are not offered again (they stay delivered, unanswered).
224
+ */
225
+ function releaseAwaiting(as) {
226
+ if (!ADDRESS.test(as)) return 0;
227
+ const r = db.prepare(`UPDATE deliveries SET awaiting_reply = 0, delivered_via = 'retried'
228
+ WHERE recipient = ? AND awaiting_reply = 1 AND state = 'delivered'`).run(as);
229
+ return Number(r && r.changes) || 0;
230
+ }
231
+
232
+ function ack(as, id, { reply = null, fromName = null, reaction = null } = {}, directory) {
233
+ if (!ADDRESS.test(as) || !MESSAGE_ID.test(String(id || ''))) return { error: 'bad ack' };
234
+ return withTx(db, () => {
235
+ const del = getDel.get(id, as);
236
+ if (!del) return { error: 'no such message for you' };
237
+ const orig = getMsg.get(id);
238
+ if (!orig) return { error: 'no such message for you' };
239
+ if (del.state !== 'acked') {
240
+ db.prepare(`UPDATE deliveries SET state = 'acked', acked_at = ?, awaiting_reply = 0, lease_token = NULL, lease_until = NULL
241
+ WHERE message_id = ? AND recipient = ?`).run(Date.now(), id, as);
242
+ }
243
+ let answer = null;
244
+ const wantReply = (typeof reply === 'string' && reply.trim()) || reaction;
245
+ if (wantReply && del.state !== 'acked') {
246
+ const topic = orig.sender === 'user' ? 'user' : orig.sender === 'manager' ? 'manager' : `agent/${orig.sender}`;
247
+ const inner = publish({
248
+ id: edgeId('r', id, as), from: as, fromName, topic, type: 'REPLY',
249
+ body: typeof reply === 'string' && reply.trim() ? reply : 'Reacted',
250
+ reaction, replyTo: id,
251
+ }, directory);
252
+ answer = inner;
253
+ } else if (wantReply && del.state === 'acked') {
254
+ const existing = byReply.get(id);
255
+ if (existing) answer = { id: existing.id };
256
+ else {
257
+ const topic = orig.sender === 'user' ? 'user' : orig.sender === 'manager' ? 'manager' : `agent/${orig.sender}`;
258
+ answer = publish({
259
+ id: edgeId('r', id, as), from: as, fromName, topic, type: 'REPLY',
260
+ body: typeof reply === 'string' && reply.trim() ? reply : 'Reacted',
261
+ reaction, replyTo: id,
262
+ }, directory);
263
+ }
264
+ }
265
+ return { ok: true, reply: answer && !answer.error ? answer.id : null };
266
+ }, 'ack');
267
+ }
268
+
269
+ function replyTo(id) {
270
+ const r = byReply.get(id);
271
+ if (!r) return null;
272
+ const status = statusOf(r.id);
273
+ return envRow({ ...r, recipients: jsonCol(status.map((s) => s.to)) }, status);
274
+ }
275
+
276
+ function statusOf(idOrEnv, now = Date.now()) {
277
+ const id = typeof idOrEnv === 'string' ? idOrEnv : idOrEnv && idOrEnv.id;
278
+ const orig = getMsg.get(id);
279
+ if (!orig) return [];
280
+ return delsOf.all(id).map((d) => {
281
+ let state = d.state;
282
+ if (state === 'queued' && now > orig.expires_at) state = 'expired';
283
+ if (state === 'delivered' && orig.needs_reply && now > orig.expires_at) state = 'unacked';
284
+ if (state === 'leased' && d.lease_until && d.lease_until < now) state = 'queued';
285
+ return { to: d.recipient, state, deliveredAt: d.delivered_at, via: d.delivered_via, ackedAt: d.acked_at, attempts: d.attempts };
286
+ });
287
+ }
288
+
289
+ const withStatus = (rows, now = Date.now()) => rows.map((m) => {
290
+ const status = statusOf(m.id, now);
291
+ return envRow({ ...m, recipients: jsonCol(status.map((s) => s.to)) }, status);
292
+ });
293
+
294
+ /** The newest `limit` messages since `since`, oldest first. */
295
+ function messages({ since = 0, limit = 200 } = {}) {
296
+ const n = Math.max(1, Math.min(Number(limit) || 200, 5000));
297
+ return withStatus(db.prepare('SELECT * FROM messages WHERE created_at >= ? ORDER BY created_at DESC LIMIT ?').all(since, n).reverse());
298
+ }
299
+
300
+ /** Messages sent by or delivered to one address, newest 5000, oldest first. */
301
+ function involving(address, since = 0) {
302
+ return withStatus(db.prepare(`SELECT * FROM messages WHERE created_at >= ? AND (sender = ? OR id IN (SELECT message_id FROM deliveries WHERE recipient = ?))
303
+ ORDER BY created_at DESC LIMIT 5000`).all(since, address, address).reverse());
304
+ }
305
+
306
+ function pendingCount(as) {
307
+ if (!ADDRESS.test(as)) return 0;
308
+ expire();
309
+ const row = db.prepare(`SELECT COUNT(*) AS n FROM deliveries d JOIN messages m ON m.id = d.message_id
310
+ WHERE d.recipient = ? AND d.state IN ('queued','leased') AND m.expires_at > ?`).get(as, Date.now());
311
+ return Number(row && row.n) || 0;
312
+ }
313
+
314
+ /** Compatibility: claim then mark delivered (listen/CLI). */
315
+ function take(as, via = 'listen') {
316
+ const got = claim(as, { via, limit: 50 });
317
+ if (got.length) markDelivered(as, got.map((m) => m.id), { via, awaitingReply: false });
318
+ return got;
319
+ }
320
+
321
+ return { publish, claim, peek, releaseLeases, queueStats, renewClaim, markDelivered, sideUnanswered, awaiting, releaseAwaiting, ack, replyTo, statusOf, messages, involving, pendingCount, take, expire };
322
+ }
@@ -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
+ }