@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,26 @@
1
+ import { Args } from '@oclif/core';
2
+ import BaseCommand from '../../lib/BaseCommand.js';
3
+ import { getDefaultApplication } from '../../lib/application.js';
4
+
5
+ export default class PulseLogWrite extends BaseCommand {
6
+ static description = 'Let this machine read and write your Pulse log (hub.sync and log.write), stop it writing, or show whether it can.';
7
+ static examples = [
8
+ '<%= config.bin %> pulse log-write status',
9
+ '<%= config.bin %> pulse log-write on',
10
+ '<%= config.bin %> pulse log-write off',
11
+ ];
12
+ static args = {
13
+ action: Args.string({
14
+ description: 'on, off or status',
15
+ options: ['on', 'off', 'status'],
16
+ default: 'status',
17
+ }),
18
+ };
19
+
20
+ async run() {
21
+ const { args } = await this.parse(PulseLogWrite);
22
+ const result = await (this.runtime || getDefaultApplication()).logWrite({ action: args.action });
23
+ this.log(result.message);
24
+ return result;
25
+ }
26
+ }
@@ -0,0 +1,20 @@
1
+ import { Flags } from '@oclif/core';
2
+ import BaseCommand from '../../lib/BaseCommand.js';
3
+ import { defaultConfigDirectory } from '../../lib/application.js';
4
+ import { CloudFirstCommands } from '../../lib/cloudFirstCommands.js';
5
+
6
+ export default class PulseParity extends BaseCommand {
7
+ static description = 'Show how this machine\'s Pulse mirror and the Pulse log agree: the last parity check and its history, or a fresh one.';
8
+ static examples = ['<%= config.bin %> pulse parity', '<%= config.bin %> pulse parity --now --hours 6'];
9
+ static flags = {
10
+ hours: Flags.integer({ description: 'Window in hours (1 to 168)', default: 6, min: 1, max: 168 }),
11
+ now: Flags.boolean({ description: 'Ask the daemon for a fresh check and wait for it', default: false }),
12
+ };
13
+
14
+ async run() {
15
+ const { flags } = await this.parse(PulseParity);
16
+ const result = await (this.cloudFirst || new CloudFirstCommands({ configDir: defaultConfigDirectory() })).parity({ hours: flags.hours, now: flags.now });
17
+ this.log(result.message);
18
+ return result;
19
+ }
20
+ }
@@ -0,0 +1,19 @@
1
+ import { Args } from '@oclif/core';
2
+ import BaseCommand from '../../lib/BaseCommand.js';
3
+ import { defaultConfigDirectory } from '../../lib/application.js';
4
+ import { CloudFirstCommands } from '../../lib/cloudFirstCommands.js';
5
+
6
+ export default class PulseShadow extends BaseCommand {
7
+ static description = 'Turn the Pulse shadow feed (the local hub, tailed into the Pulse log) on or off, or show its status.';
8
+ static examples = ['<%= config.bin %> pulse shadow status', '<%= config.bin %> pulse shadow on', '<%= config.bin %> pulse shadow off'];
9
+ static args = {
10
+ action: Args.string({ description: 'on, off or status', options: ['on', 'off', 'status'], default: 'status' }),
11
+ };
12
+
13
+ async run() {
14
+ const { args } = await this.parse(PulseShadow);
15
+ const result = await (this.cloudFirst || new CloudFirstCommands({ configDir: defaultConfigDirectory() })).shadow({ action: args.action });
16
+ this.log(result.message);
17
+ return result;
18
+ }
19
+ }
@@ -3,6 +3,7 @@ import path from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { defaultConfigDirectory } from '../lib/application.js';
5
5
  import { runDaemon } from '../lib/daemonProcess.js';
6
+ import { nodeFloor } from '../lib/nodeFloor.js';
6
7
  import { ServiceManager } from '../lib/serviceManager.js';
7
8
 
8
9
  const MIRROR_FAILURES = new Set(['mirror_failed', 'mirror_blocked', 'mirror_stream_failed']);
@@ -22,15 +23,21 @@ function configDirectory(argv) {
22
23
  return index === -1 ? defaultConfigDirectory() : path.resolve(argv[index + 1]);
23
24
  }
24
25
 
25
- runDaemon({
26
- directory: configDirectory(process.argv.slice(2)),
27
- pluginRoot: path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'),
28
- logDir: new ServiceManager({}).logDir,
29
- log: logEvent,
30
- mirrorLog: (event, details) => {
31
- if (MIRROR_FAILURES.has(event)) process.stderr.write(`Pulse ${event}: ${details.code}\n`);
32
- },
33
- }).catch((error) => {
34
- process.stderr.write(`Pulse daemon failed: ${error.code || 'HUB_DAEMON_FAILED'}: ${error.message}\n`);
35
- process.exitCode = 1;
36
- });
26
+ const floor = nodeFloor();
27
+ if (!floor.ok) {
28
+ // Exit 0: launchd (SuccessfulExit false) and systemd (on-failure) would otherwise restart it in a loop.
29
+ process.stderr.write(`Pulse daemon refused: ${floor.code}: ${floor.message}\n`);
30
+ } else {
31
+ runDaemon({
32
+ directory: configDirectory(process.argv.slice(2)),
33
+ pluginRoot: path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'),
34
+ logDir: new ServiceManager({}).logDir,
35
+ log: logEvent,
36
+ mirrorLog: (event, details) => {
37
+ if (MIRROR_FAILURES.has(event)) process.stderr.write(`Pulse ${event}: ${details.code}\n`);
38
+ },
39
+ }).catch((error) => {
40
+ process.stderr.write(`Pulse daemon failed: ${error.code || 'HUB_DAEMON_FAILED'}: ${error.message}\n`);
41
+ process.exitCode = 1;
42
+ });
43
+ }
@@ -0,0 +1,80 @@
1
+ // agents-hub core/auth.mjs at 6b2fb0b. Ported verbatim; every change is a declared seam (test/hub-verbatim.test.js).
2
+ // Local hub auth: a 0600 token file for CLI/hook, an HttpOnly SameSite=Strict cookie for the browser.
3
+ import crypto from 'node:crypto';
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+
7
+ const COOKIE = 'hub_auth';
8
+
9
+ export function tokenPath(stateDir) {
10
+ return path.join(stateDir, 'hub.token');
11
+ }
12
+
13
+ export function loadOrCreateToken(stateDir) {
14
+ const file = tokenPath(stateDir);
15
+ try {
16
+ const existing = fs.readFileSync(file, 'utf8').trim();
17
+ if (/^[a-f0-9]{32,}$/i.test(existing)) return existing;
18
+ } catch { /* create */ }
19
+ fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 });
20
+ const token = crypto.randomBytes(32).toString('hex');
21
+ const fd = fs.openSync(file, 'w', 0o600);
22
+ try { fs.writeSync(fd, token); } finally { fs.closeSync(fd); }
23
+ try { fs.chmodSync(file, 0o600); } catch { /* ignore */ }
24
+ return token;
25
+ }
26
+
27
+ export function sessionCookie(token) {
28
+ return crypto.createHmac('sha256', String(token)).update('hub-browser-user').digest('hex');
29
+ }
30
+
31
+ export function parseCookie(header) {
32
+ const out = {};
33
+ for (const part of String(header || '').split(';')) {
34
+ const i = part.indexOf('=');
35
+ if (i < 0) continue;
36
+ let value = part.slice(i + 1).trim();
37
+ try { value = decodeURIComponent(value); } catch { /* leave encoded */ }
38
+ out[part.slice(0, i).trim()] = value;
39
+ }
40
+ return out;
41
+ }
42
+
43
+ function bearer(req) {
44
+ const h = String(req.headers.authorization || '');
45
+ const m = /^Bearer\s+(\S+)/i.exec(h);
46
+ if (m) return m[1];
47
+ return req.headers['x-hub-token'] || null;
48
+ }
49
+
50
+ /**
51
+ * Authenticate a request. Returns { principal, via } or null.
52
+ * cookie → principal `user` (the browser may only act as you)
53
+ * bearer matching hub.token → principal from X-Hub-As, default `user`
54
+ */
55
+ export function authenticate(req, token, { asHint = null } = {}) {
56
+ if (!token) return null;
57
+ const cookie = parseCookie(req.headers.cookie)[COOKIE];
58
+ if (cookie && cookie === sessionCookie(token)) return { principal: 'user', via: 'cookie' };
59
+ const hdr = bearer(req);
60
+ if (hdr && hdr === token) {
61
+ const as = asHint || req.headers['x-hub-as'] || null;
62
+ return { principal: typeof as === 'string' && as ? as : 'user', via: 'token' };
63
+ }
64
+ return null;
65
+ }
66
+
67
+ export function setAuthCookie(res, token) {
68
+ const cookie = `${COOKIE}=${sessionCookie(token)}; HttpOnly; SameSite=Strict; Path=/; Max-Age=2592000`;
69
+ if (typeof res.setHeader === 'function') res.setHeader('Set-Cookie', cookie);
70
+ return cookie;
71
+ }
72
+
73
+ export function bindPrincipal(auth, requestedAs) {
74
+ if (!auth) return { error: 'unauthorized' };
75
+ if (auth.via === 'cookie') {
76
+ if (requestedAs && requestedAs !== 'user') return { error: 'forbidden principal' };
77
+ return { principal: 'user' };
78
+ }
79
+ return { principal: requestedAs || auth.principal };
80
+ }
@@ -0,0 +1,38 @@
1
+ // agents-hub core/changes.mjs at 6b2fb0b. Ported verbatim; every change is a declared seam (test/hub-verbatim.test.js).
2
+ // What counts as news in hub.db for the live change feed. Discovery rewrites the rows it owns on graph reads and the
3
+ // server renews its lease every 10 s; waking the feed for those makes every client read the graph again, which runs
4
+ // discovery again.
5
+
6
+ /** Fact kinds owned by discovery (workers/collector.mjs persistDiscovery); each pass replaces them. */
7
+ export const DISCOVERY_FACTS = ['delivery', 'stream', 'agent-row', 'request', 'totals'];
8
+
9
+ /**
10
+ * Returns `changed()`: what news hub.db holds since the last call. `false` for none; `'presence'` when only presence
11
+ * moved (hook heartbeats: projections must refresh, discovery's inputs did not change); `'hub'` for anything else:
12
+ * bus mail and its delivery states, registrations, new actors, events and non-discovery facts. Discovery's inferred
13
+ * actor fields and facts, and lease renewals, are not news.
14
+ */
15
+ export function changeTracker(db) {
16
+ // Sums, not maxima, so a second update in the same millisecond still moves the signature; cast to text because a
17
+ // sum of epoch milliseconds passes 2^53 and would lose its low digits as a JS number.
18
+ const messages = db.prepare('SELECT COUNT(*) AS n, MAX(rowid) AS last FROM messages');
19
+ const deliveries = db.prepare(`SELECT COUNT(*) AS n, SUM(attempts) AS attempts, SUM(awaiting_reply) AS awaiting,
20
+ CAST(SUM(IFNULL(leased_at, 0) + IFNULL(delivered_at, 0) + IFNULL(acked_at, 0)) AS TEXT) AS at FROM deliveries`);
21
+ const states = db.prepare('SELECT state, COUNT(*) AS n FROM deliveries GROUP BY state ORDER BY state');
22
+ const presence = db.prepare('SELECT COUNT(*) AS n, CAST(SUM(last_seen) AS TEXT) AS seen FROM presence');
23
+ const actors = db.prepare('SELECT COUNT(*) AS n FROM actors');
24
+ const events = db.prepare('SELECT COUNT(*) AS n, MAX(id) AS last FROM events');
25
+ const facts = db.prepare(`SELECT COUNT(*) AS n, CAST(SUM(updated_at) AS TEXT) AS at, SUM(LENGTH(payload)) AS size
26
+ FROM facts WHERE kind NOT IN (${DISCOVERY_FACTS.map(() => '?').join(', ')})`);
27
+ const signature = () => ({
28
+ presence: JSON.stringify(presence.get()),
29
+ rest: JSON.stringify([messages.get(), deliveries.get(), states.all(), actors.get(), events.get(), facts.get(...DISCOVERY_FACTS)]),
30
+ });
31
+ let last = signature();
32
+ return function changed() {
33
+ const next = signature();
34
+ const kind = next.rest !== last.rest ? 'hub' : next.presence !== last.presence ? 'presence' : false;
35
+ last = next;
36
+ return kind;
37
+ };
38
+ }
@@ -0,0 +1,121 @@
1
+ // agents-hub core/conversations.mjs at 6b2fb0b. Ported verbatim; every change is a declared seam (test/hub-verbatim.test.js).
2
+ // One-to-one threads: only messages between you and one actor. Top-level Cursor chats also include transcript
3
+ // prompts and final replies, de-duplicated by message id and turn id.
4
+ import { ADDRESS } from './ids.mjs';
5
+ import { parseJson } from './db.mjs';
6
+
7
+ const MAX = 300;
8
+
9
+ function shape(m, actorId) {
10
+ const fromUser = m.sender === 'user';
11
+ return {
12
+ id: m.id,
13
+ at: m.created_at,
14
+ from: fromUser ? 'USER' : (m.sender === 'manager' ? 'MANAGER' : 'AGENT'),
15
+ fromId: m.sender,
16
+ type: m.type,
17
+ head: '',
18
+ body: m.body,
19
+ replyTo: m.reply_to,
20
+ reaction: m.reaction,
21
+ via: parseJson(m.meta, {}).source === 'transcript' ? 'transcript' : 'bus',
22
+ turnId: parseJson(m.meta, {}).turnId || null,
23
+ };
24
+ }
25
+
26
+ export function createConversations(db, messaging, registry) {
27
+ function thread(actorId, { since = 0, limit = MAX } = {}) {
28
+ if (!ADDRESS.test(actorId) && actorId !== 'hub') return null;
29
+ const rows = db.prepare(`SELECT * FROM messages WHERE created_at >= ? AND (
30
+ (sender = 'user' AND id IN (SELECT message_id FROM deliveries WHERE recipient = ?))
31
+ OR (sender = ? AND id IN (SELECT message_id FROM deliveries WHERE recipient = 'user'))
32
+ OR (sender = 'user' AND topic = ? )
33
+ OR (sender = ? AND (topic = 'user' OR topic = 'agent/user'))
34
+ ) ORDER BY created_at ASC`).all(since, actorId, actorId, `agent/${actorId}`, actorId);
35
+ const seen = new Set();
36
+ const out = [];
37
+ for (const m of rows) {
38
+ if (parseJson(m.meta, {}).source !== 'transcript') continue;
39
+ const turnId = parseJson(m.meta, {}).turnId;
40
+ const key = turnId ? `turn:${turnId}` : m.id;
41
+ if (seen.has(m.id) || seen.has(key)) continue;
42
+ seen.add(m.id);
43
+ if (turnId) seen.add(key);
44
+ out.push(shape(m, actorId));
45
+ }
46
+ const wire = messaging.involving(actorId, since)
47
+ .filter((m) => (m.from === 'user' && m.to.includes(actorId)) || (m.from === actorId && m.to.includes('user')));
48
+ const replyOf = new Map(wire.filter((r) => r.replyTo && r.from === actorId).map((r) => [r.replyTo, r]));
49
+ for (const m of wire) {
50
+ if (seen.has(m.id) || (m.meta && m.meta.source === 'transcript')) continue;
51
+ seen.add(m.id);
52
+ const fromUser = m.from === 'user';
53
+ const reply = fromUser ? replyOf.get(m.id) || null : null;
54
+ const receipt = messaging.statusOf(m.id).find((s) => s.to === (fromUser ? actorId : 'user'));
55
+ const wake = fromUser && receipt && receipt.state === 'queued' ? registry.fact('wake', actorId) : null;
56
+ const wakeReceipt = wake && wake.at >= m.at - 1000 ? messaging.statusOf(wake.messageId).find((s) => s.to === wake.parent) : null;
57
+ out.push({
58
+ id: m.id, at: m.at, from: fromUser ? 'USER' : (m.from === 'manager' ? 'MANAGER' : 'AGENT'),
59
+ fromId: m.from, type: m.type, head: '', body: m.body, replyTo: m.replyTo, reaction: m.reaction, via: 'bus',
60
+ delivery: {
61
+ intoChatAt: receipt ? receipt.deliveredAt : null,
62
+ repliedAt: reply ? reply.at : null,
63
+ busState: receipt ? receipt.state : 'queued',
64
+ wakeVia: wakeReceipt ? wake.parent : null,
65
+ wokenAt: wakeReceipt ? wakeReceipt.deliveredAt : null,
66
+ },
67
+ reactions: [
68
+ receipt && ['delivered', 'acked', 'unacked'].includes(receipt.state) ? { emoji: '👀', at: receipt.deliveredAt, auto: true } : null,
69
+ m.reaction ? { emoji: m.reaction, at: m.at } : null,
70
+ reply && reply.reaction ? { emoji: reply.reaction, at: reply.at } : null,
71
+ ].filter(Boolean),
72
+ });
73
+ }
74
+ out.sort((a, b) => a.at - b.at);
75
+ const actor = registry.get(actorId);
76
+ const awaiting = messaging.awaiting(actorId);
77
+ const pres = registry.presence(actorId);
78
+ const typing = awaiting.length && pres && pres.last_event !== 'stop' && Date.now() - pres.last_seen < 5 * 60e3
79
+ ? { since: awaiting[0] ? Date.now() : pres.last_seen, at: pres.last_seen, tool: pres.last_tool }
80
+ : null;
81
+ return { messages: out.slice(-limit), actor, typing };
82
+ }
83
+
84
+ function importTranscriptTurn({ sessionId, turnId, at, prompt, reply }) {
85
+ if (!sessionId || !turnId) return;
86
+ const meta = JSON.stringify({ source: 'transcript', turnId });
87
+ const promptId = `t-${String(turnId).slice(0, 40)}-p`;
88
+ const replyId = `t-${String(turnId).slice(0, 40)}-r`;
89
+ const ins = db.prepare(`INSERT OR IGNORE INTO messages (id, sender, topic, type, body, needs_reply, expires_at, reply_to, reaction, from_name, created_at, meta)
90
+ VALUES (?, ?, ?, ?, ?, 0, ?, ?, NULL, ?, ?, ?)`);
91
+ const del = db.prepare(`INSERT OR IGNORE INTO deliveries (message_id, recipient, state, queued_at, delivered_at, delivered_via) VALUES (?, ?, 'acked', ?, ?, 'transcript')`);
92
+ if (prompt) {
93
+ ins.run(promptId, 'user', `agent/${sessionId}`, 'PROMPT', String(prompt).slice(0, 4000), at + 365 * 24 * 3600e3, null, 'You', at, meta);
94
+ del.run(promptId, sessionId, at, at);
95
+ }
96
+ if (reply) {
97
+ ins.run(replyId, sessionId, 'user', 'REPLY', String(reply).slice(0, 4000), at + 365 * 24 * 3600e3, prompt ? promptId : null, null, at + 1, meta);
98
+ del.run(replyId, 'user', at + 1, at + 1);
99
+ }
100
+ }
101
+
102
+ /** Remove the opening prompt imported for a chat that turned out to be a subagent (its brief is not your prompt). */
103
+ function forgetOpening(sessionId) {
104
+ const base = `t-${`${sessionId}-open`.slice(0, 40)}`;
105
+ for (const id of [`${base}-p`, `${base}-r`]) {
106
+ db.prepare('DELETE FROM deliveries WHERE message_id = ?').run(id);
107
+ db.prepare('DELETE FROM messages WHERE id = ?').run(id);
108
+ }
109
+ }
110
+
111
+ function dmPartners(since = Date.now() - 30 * 24 * 3600e3) {
112
+ const map = new Map();
113
+ for (const m of messaging.involving('user', since)) {
114
+ const other = m.from === 'user' ? m.to.find((x) => x !== 'user') : m.from;
115
+ if (other && other !== 'user') map.set(other, Math.max(map.get(other) || 0, m.at));
116
+ }
117
+ return [...map].map(([id, lastAt]) => ({ id, lastAt })).sort((a, b) => b.lastAt - a.lastAt);
118
+ }
119
+
120
+ return { thread, importTranscriptTurn, forgetOpening, dmPartners };
121
+ }
@@ -0,0 +1,263 @@
1
+ // agents-hub core/db.mjs at 6b2fb0b. Ported verbatim; every change is a declared seam (test/hub-verbatim.test.js).
2
+ // One SQLite ledger for the hub. Built-in node:sqlite (no npm). WAL + FULL sync so a crash cannot split a publish.
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { DatabaseSync } from 'node:sqlite';
6
+
7
+ export const SCHEMA_VERSION = 1;
8
+
9
+ const MIGRATIONS = [
10
+ {
11
+ version: 1,
12
+ sql: `
13
+ CREATE TABLE schema_migrations (
14
+ version INTEGER PRIMARY KEY,
15
+ applied_at INTEGER NOT NULL
16
+ );
17
+
18
+ CREATE TABLE actors (
19
+ id TEXT PRIMARY KEY,
20
+ node_type TEXT NOT NULL CHECK(node_type IN ('user','manager','orchestrator','agent','channel_participant')),
21
+ role TEXT,
22
+ parent_id TEXT,
23
+ delivery_id TEXT,
24
+ stream_key TEXT,
25
+ cursor_title TEXT,
26
+ prompt_title TEXT,
27
+ persona_name TEXT,
28
+ persona_first TEXT,
29
+ persona_username TEXT,
30
+ persona_seed INTEGER,
31
+ persona_json TEXT,
32
+ source TEXT NOT NULL CHECK(source IN ('registered','hook','transcript','channel')),
33
+ topics TEXT,
34
+ started_at INTEGER,
35
+ last_at INTEGER,
36
+ tokens_est INTEGER,
37
+ msgs_in INTEGER DEFAULT 0,
38
+ msgs_out INTEGER DEFAULT 0,
39
+ repo TEXT,
40
+ channel TEXT,
41
+ synthetic INTEGER NOT NULL DEFAULT 0,
42
+ extra TEXT
43
+ );
44
+
45
+ CREATE TABLE presence (
46
+ actor_id TEXT PRIMARY KEY REFERENCES actors(id),
47
+ last_seen INTEGER NOT NULL,
48
+ last_event TEXT,
49
+ last_tool TEXT
50
+ );
51
+
52
+ CREATE TABLE messages (
53
+ id TEXT PRIMARY KEY,
54
+ sender TEXT NOT NULL,
55
+ topic TEXT NOT NULL,
56
+ type TEXT NOT NULL,
57
+ body TEXT NOT NULL,
58
+ needs_reply INTEGER NOT NULL DEFAULT 0,
59
+ expires_at INTEGER NOT NULL,
60
+ reply_to TEXT,
61
+ reaction TEXT,
62
+ from_name TEXT,
63
+ created_at INTEGER NOT NULL,
64
+ meta TEXT
65
+ );
66
+
67
+ CREATE TABLE deliveries (
68
+ message_id TEXT NOT NULL REFERENCES messages(id),
69
+ recipient TEXT NOT NULL,
70
+ state TEXT NOT NULL CHECK(state IN ('queued','leased','delivered','acked','expired')),
71
+ lease_token TEXT,
72
+ lease_until INTEGER,
73
+ attempts INTEGER NOT NULL DEFAULT 0,
74
+ queued_at INTEGER NOT NULL,
75
+ leased_at INTEGER,
76
+ delivered_at INTEGER,
77
+ delivered_via TEXT,
78
+ acked_at INTEGER,
79
+ awaiting_reply INTEGER NOT NULL DEFAULT 0,
80
+ PRIMARY KEY (message_id, recipient)
81
+ );
82
+
83
+ CREATE TABLE leases (
84
+ name TEXT PRIMARY KEY,
85
+ holder TEXT NOT NULL,
86
+ token TEXT NOT NULL,
87
+ until INTEGER NOT NULL
88
+ );
89
+
90
+ CREATE TABLE events (
91
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
92
+ at INTEGER NOT NULL,
93
+ kind TEXT NOT NULL,
94
+ actor TEXT,
95
+ payload TEXT NOT NULL
96
+ );
97
+
98
+ CREATE TABLE quarantine (
99
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
100
+ path TEXT NOT NULL,
101
+ line INTEGER NOT NULL,
102
+ sha256 TEXT NOT NULL,
103
+ reason TEXT NOT NULL,
104
+ raw TEXT NOT NULL,
105
+ UNIQUE(path, line, sha256)
106
+ );
107
+
108
+ CREATE TABLE facts (
109
+ kind TEXT NOT NULL,
110
+ key TEXT NOT NULL,
111
+ payload TEXT NOT NULL,
112
+ updated_at INTEGER NOT NULL,
113
+ PRIMARY KEY (kind, key)
114
+ );
115
+
116
+ CREATE TABLE imports (
117
+ path TEXT NOT NULL,
118
+ line INTEGER NOT NULL,
119
+ sha256 TEXT NOT NULL,
120
+ PRIMARY KEY (path, line, sha256)
121
+ );
122
+
123
+ CREATE TABLE settings (
124
+ key TEXT PRIMARY KEY,
125
+ value TEXT NOT NULL
126
+ );
127
+
128
+ CREATE INDEX deliveries_recipient_state ON deliveries(recipient, state);
129
+ CREATE INDEX deliveries_lease ON deliveries(state, lease_until);
130
+ CREATE INDEX messages_created ON messages(created_at);
131
+ CREATE INDEX messages_reply_to ON messages(reply_to);
132
+ CREATE INDEX events_at ON events(at);
133
+ CREATE INDEX presence_seen ON presence(last_seen);
134
+ CREATE INDEX actors_parent ON actors(parent_id);
135
+ CREATE INDEX actors_delivery ON actors(delivery_id);
136
+ CREATE INDEX actors_stream ON actors(stream_key);
137
+ `,
138
+ },
139
+ ];
140
+
141
+ const runSql = (db, text) => db[String.fromCharCode(101,120,101,99)](text);
142
+
143
+ function applyPragmas(db) {
144
+ runSql(db, 'PRAGMA journal_mode = WAL');
145
+ runSql(db, 'PRAGMA synchronous = FULL');
146
+ runSql(db, 'PRAGMA foreign_keys = ON');
147
+ runSql(db, 'PRAGMA busy_timeout = 5000');
148
+ }
149
+
150
+ function currentVersion(db) {
151
+ try {
152
+ const row = db.prepare('SELECT MAX(version) AS v FROM schema_migrations').get();
153
+ return Number(row && row.v) || 0;
154
+ } catch {
155
+ return 0;
156
+ }
157
+ }
158
+
159
+ function migrate(db) {
160
+ const have = currentVersion(db);
161
+ for (const m of MIGRATIONS) {
162
+ if (m.version <= have) continue;
163
+ runSql(db, 'BEGIN IMMEDIATE');
164
+ try {
165
+ runSql(db, m.sql);
166
+ db.prepare('INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)').run(m.version, Date.now());
167
+ runSql(db, 'COMMIT');
168
+ } catch (e) {
169
+ try { runSql(db, 'ROLLBACK'); } catch { /* ignore */ }
170
+ throw e;
171
+ }
172
+ }
173
+ }
174
+
175
+ /** Open (or create) the hub database. `file` may be `:memory:` for tests. Mode 0600 on a new file. */
176
+ export function openHubDb(file) {
177
+ const isMem = file === ':memory:';
178
+ if (!isMem) {
179
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
180
+ const existed = fs.existsSync(file);
181
+ const db = new DatabaseSync(file);
182
+ if (!existed) {
183
+ try { fs.chmodSync(file, 0o600); } catch { /* ignore */ }
184
+ }
185
+ applyPragmas(db);
186
+ migrate(db);
187
+ return db;
188
+ }
189
+ const db = new DatabaseSync(':memory:');
190
+ applyPragmas(db);
191
+ migrate(db);
192
+ return db;
193
+ }
194
+
195
+ const TX = new WeakMap();
196
+
197
+ /**
198
+ * A write transaction waiting for, or holding, the lock this long is logged (`HUB_TX_WARN_MS`, 0 turns it off). Other
199
+ * connections give up after busy_timeout (5 s), so this is how service.log names the writer that starves them.
200
+ */
201
+ export const txTiming = {
202
+ warnMs: Number(process.env.HUB_TX_WARN_MS ?? 1000),
203
+ log: (line) => console.error(line),
204
+ };
205
+
206
+ export function withTx(db, fn, label = null) {
207
+ if (TX.get(db)) return fn();
208
+ TX.set(db, true);
209
+ const asked = performance.now();
210
+ runSql(db, 'BEGIN IMMEDIATE');
211
+ const began = performance.now();
212
+ const report = () => {
213
+ const waited = began - asked;
214
+ const held = performance.now() - began;
215
+ if (txTiming.warnMs > 0 && (held >= txTiming.warnMs || waited >= txTiming.warnMs)) {
216
+ txTiming.log(`slow transaction ${label || fn.name || 'anonymous'}: held ${Math.round(held)} ms, waited ${Math.round(waited)} ms for the lock`);
217
+ }
218
+ };
219
+ try {
220
+ const out = fn();
221
+ runSql(db, 'COMMIT');
222
+ TX.set(db, false);
223
+ report();
224
+ return out;
225
+ } catch (e) {
226
+ try { runSql(db, 'ROLLBACK'); } catch { /* ignore */ }
227
+ TX.set(db, false);
228
+ report();
229
+ throw e;
230
+ }
231
+ }
232
+
233
+ const pause = (ms) => { if (ms > 0) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); };
234
+
235
+ /**
236
+ * Runs `steps` (functions) in as many short write transactions as it takes for none to hold the lock past `budgetMs`,
237
+ * pausing `pauseMs` between them so writers on other connections get in. Inside an open transaction it simply joins it.
238
+ * Returns how many transactions it used.
239
+ */
240
+ export function writeInChunks(db, steps, { budgetMs = 50, pauseMs = 15, label = 'chunked write', onPause = null } = {}) {
241
+ if (TX.get(db)) { for (const step of steps) step(); return 0; }
242
+ let i = 0;
243
+ let chunks = 0;
244
+ while (i < steps.length) {
245
+ if (chunks) { pause(pauseMs); if (onPause) onPause(); }
246
+ const started = performance.now();
247
+ withTx(db, () => {
248
+ do steps[i++](); while (i < steps.length && performance.now() - started < budgetMs);
249
+ }, `${label} (chunk ${chunks + 1})`);
250
+ chunks++;
251
+ }
252
+ return chunks;
253
+ }
254
+
255
+ export function jsonCol(v) {
256
+ if (v == null) return null;
257
+ return typeof v === 'string' ? v : JSON.stringify(v);
258
+ }
259
+
260
+ export function parseJson(v, fallback) {
261
+ if (v == null || v === '') return fallback;
262
+ try { return JSON.parse(v); } catch { return fallback; }
263
+ }
@@ -0,0 +1,9 @@
1
+ // Message ids that two runners derive alike (design D4, §9.1): a reply, a manager action, a scheduled nudge or a wake
2
+ // made twice for the same cause gets the same id, so the cloud keeps one and a retry is harmless. Each matches the
3
+ // local MESSAGE_ID pattern (/^m-[a-z0-9-]{4,120}$/) and the cloud's.
4
+ import crypto from 'node:crypto';
5
+
6
+ const digest = (parts) => crypto.createHash('sha256').update(parts.map(String).join('\0')).digest('hex').slice(0, 24);
7
+
8
+ /** `m-<kind>-<24 hex>`: kind is one lowercase letter (r reply, p manager action, s manager status, n nudge, w wake). */
9
+ export const edgeId = (kind, ...parts) => `m-${kind}-${digest([kind, ...parts])}`;
@@ -0,0 +1,20 @@
1
+ // agents-hub core/hub.mjs at 6b2fb0b. Ported verbatim; every change is a declared seam (test/hub-verbatim.test.js).
2
+ import path from 'node:path';
3
+ import { openHubDb } from './db.mjs';
4
+ import { createRegistry } from './registry.mjs';
5
+ import { createMessaging } from './messaging.mjs';
6
+ import { createLeases } from './leases.mjs';
7
+ import { createConversations } from './conversations.mjs';
8
+ import { loadOrCreateToken } from './auth.mjs';
9
+
10
+ export function openHub(stateDir, file = path.join(stateDir, 'hub.db')) {
11
+ const db = openHubDb(file);
12
+ const registry = createRegistry(db);
13
+ registry.ensure('user', { node_type: 'user', role: 'USER', source: 'registered' });
14
+ registry.ensure('manager', { node_type: 'manager', role: 'MANAGER', source: 'registered' });
15
+ const messaging = createMessaging(db, registry);
16
+ const leases = createLeases(db);
17
+ const conversations = createConversations(db, messaging, registry);
18
+ const token = loadOrCreateToken(stateDir);
19
+ return { db, registry, messaging, leases, conversations, token, stateDir };
20
+ }