@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,39 @@
1
+ // agents-hub core/ids.mjs at 6b2fb0b. Ported verbatim; every change is a declared seam (test/hub-verbatim.test.js).
2
+ export const TOPIC = /^(agent\/[A-Za-z0-9-]{8,80}|agent\/(manager|user)|stream\/[A-Za-z0-9._-]{1,80}\/[A-Za-z0-9._-]{1,80}|delivery\/[A-Za-z0-9-]{8,80}|role\/[A-Z][A-Z0-9-]{0,30}|manager|user|broadcast)$/;
3
+ export const TYPE = /^[A-Z][A-Z0-9 _-]{0,30}$/;
4
+ export const ADDRESS = /^([A-Za-z0-9-]{8,80}|manager|user)$/;
5
+ export const MESSAGE_ID = /^m-[a-z0-9-]{4,120}$/;
6
+ export const BODY_MAX = 4000;
7
+ /** A reply or acknowledgement needs no answer; answering it would start a reply-to-a-reply loop. */
8
+ export const isFyi = (m) => /^(REPLY|ACK|DONE|STATUS)$/.test(String((m && m.type) || '').toUpperCase()) || (!!(m && m.replyTo) && !m.needsAck);
9
+ /** Every Cursor chat the headless manager has used (manager.json or the manager/config fact): never a delivery node. */
10
+ export const managerChats = (config) => new Set([config && config.chatId, ...((config && Array.isArray(config.chatIds)) ? config.chatIds : [])]
11
+ .filter((id) => typeof id === 'string' && id));
12
+ export const DEFAULT_TTL_MS = 24 * 3600e3;
13
+ export const ONLINE_MS = 90e3;
14
+ export const LEASE_MS = 30e3;
15
+ export const SOURCE_RANK = { registered: 4, hook: 3, transcript: 2, channel: 1 };
16
+
17
+ export const NODE_TYPES = ['user', 'manager', 'orchestrator', 'agent', 'channel_participant'];
18
+
19
+ export function nodeTypeOf({ id, parentId, synthetic, role } = {}) {
20
+ if (id === 'user') return 'user';
21
+ if (id === 'manager') return 'manager';
22
+ if (synthetic || (id && /[/:]/.test(id))) return 'channel_participant';
23
+ if (parentId) return 'agent';
24
+ if (role === 'MANAGER') return 'manager';
25
+ if (role === 'USER') return 'user';
26
+ return 'orchestrator';
27
+ }
28
+
29
+ /** One title everywhere: Cursor chat title, else prompt title, else persona, else role. */
30
+ export function displayTitle(actor) {
31
+ if (!actor) return '?';
32
+ if (actor.node_type === 'user' || actor.id === 'user') return actor.persona_name || 'You';
33
+ if (actor.node_type === 'manager' || actor.id === 'manager') return actor.persona_name || 'Manager';
34
+ if (actor.node_type === 'orchestrator') return actor.cursor_title || actor.prompt_title || actor.persona_name || 'Delivery';
35
+ if (actor.persona_name) return actor.persona_name;
36
+ if (actor.cursor_title) return actor.cursor_title;
37
+ if (actor.role) return String(actor.role).replace(/-/g, ' ');
38
+ return actor.prompt_title || actor.id;
39
+ }
@@ -0,0 +1,55 @@
1
+ // agents-hub core/leases.mjs at 6b2fb0b. Ported verbatim; every change is a declared seam (test/hub-verbatim.test.js).
2
+ // Singleton leases for the HTTP server and the manager worker. Only one holder of each name is live.
3
+ import crypto from 'node:crypto';
4
+ import { withTx } from './db.mjs';
5
+
6
+ const DEFAULT_TTL_MS = 15e3;
7
+
8
+ function pidAlive(pid) {
9
+ if (!Number.isFinite(pid) || pid <= 0) return false;
10
+ try { process.kill(pid, 0); return true; } catch { return false; }
11
+ }
12
+
13
+ function holderPid(holder) {
14
+ const m = String(holder || '').match(/-(\d+)$/);
15
+ return m ? Number(m[1]) : null;
16
+ }
17
+
18
+ export function createLeases(db) {
19
+ const get = db.prepare('SELECT * FROM leases WHERE name = ?');
20
+
21
+ function acquire(name, holder, { ttlMs = DEFAULT_TTL_MS, now = Date.now(), force = false } = {}) {
22
+ const token = crypto.randomBytes(12).toString('hex');
23
+ const until = now + Math.max(2000, ttlMs);
24
+ return withTx(db, () => {
25
+ const row = get.get(name);
26
+ if (!force && row && row.until > now && row.holder !== holder) {
27
+ const pid = holderPid(row.holder);
28
+ if (pid == null || pidAlive(pid)) return { error: 'lease held', holder: row.holder, until: row.until };
29
+ }
30
+ db.prepare(`INSERT INTO leases (name, holder, token, until) VALUES (?, ?, ?, ?)
31
+ ON CONFLICT(name) DO UPDATE SET holder = excluded.holder, token = excluded.token, until = excluded.until`)
32
+ .run(name, holder, token, until);
33
+ return { ok: true, token, until, holder };
34
+ }, `lease ${name}`);
35
+ }
36
+
37
+ function renew(name, token, { ttlMs = DEFAULT_TTL_MS, now = Date.now() } = {}) {
38
+ const until = now + Math.max(2000, ttlMs);
39
+ const r = db.prepare('UPDATE leases SET until = ? WHERE name = ? AND token = ?').run(until, name, token);
40
+ if (!r.changes) return { error: 'not holder' };
41
+ return { ok: true, until };
42
+ }
43
+
44
+ function release(name, token) {
45
+ db.prepare('DELETE FROM leases WHERE name = ? AND token = ?').run(name, token);
46
+ }
47
+
48
+ function heldBy(name, now = Date.now()) {
49
+ const row = get.get(name);
50
+ if (!row || row.until <= now) return null;
51
+ return row;
52
+ }
53
+
54
+ return { acquire, renew, release, heldBy };
55
+ }
@@ -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
+ }