@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,53 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { execFileBounded } from './subprocess.js';
5
+
6
+ /**
7
+ * `pulse doctor` warnings (Pulse 1.1.1): facts about this machine worth knowing that never make it unhealthy. Each
8
+ * probe answers `{ ok: true, warn, message, ... }`, or `{ ok: true, skipped }` when there is nothing to look at.
9
+ */
10
+
11
+ /** In use at or above this share of max_connections, the local Postgres is close to refusing new connections. */
12
+ export const POSTGRES_HEADROOM_WARN = 0.8;
13
+ /** The hook events the Pulse hook needs beyond postToolUse, stop and afterAgentResponse: failed Shell calls bind aliases, the rest say whether a session is in a tool call or gone. */
14
+ export const HUB_HOOK_EVENTS = ['postToolUseFailure', 'preToolUse', 'sessionEnd'];
15
+ const QUERY = "SELECT count(*), current_setting('max_connections') FROM pg_stat_activity";
16
+
17
+ /** Connections in use against max_connections on the local Postgres (PULSE_PG_URL, else localhost), through psql. */
18
+ export async function postgresHeadroom({ url = process.env.PULSE_PG_URL || 'postgres://localhost/postgres', run = execFileBounded } = {}) {
19
+ let stdout;
20
+ try {
21
+ ({ stdout } = await run('psql', [url, '-X', '-A', '-t', '-F', ',', '-c', QUERY], { timeout: 5_000 }));
22
+ } catch (error) {
23
+ return { ok: true, skipped: error && error.code === 'ENOENT' ? 'psql is not installed' : 'no local Postgres answered' };
24
+ }
25
+ const [used, max] = String(stdout).trim().split(',').map(Number);
26
+ if (!Number.isFinite(used) || !Number.isFinite(max) || max <= 0) return { ok: true, skipped: 'unreadable answer from psql' };
27
+ const warn = used / max >= POSTGRES_HEADROOM_WARN;
28
+ return {
29
+ ok: true, used, max, warn,
30
+ message: `Local Postgres: ${used} of ${max} connections in use${warn ? `; past ${Math.round(POSTGRES_HEADROOM_WARN * 100)}%, new connections may soon be refused` : ''}.`,
31
+ };
32
+ }
33
+
34
+ /** Which of HUB_HOOK_EVENTS ~/.cursor/hooks.json sends to the hub hook (hub-bridge.mjs). Read-only. */
35
+ export async function hubHooks({ file = path.join(os.homedir(), '.cursor', 'hooks.json') } = {}) {
36
+ let hooks;
37
+ try {
38
+ hooks = JSON.parse(await readFile(file, 'utf8')).hooks || {};
39
+ } catch {
40
+ return { ok: true, skipped: 'no readable ~/.cursor/hooks.json' };
41
+ }
42
+ const wired = Object.entries(hooks)
43
+ .filter(([, entries]) => Array.isArray(entries) && entries.some((e) => e && typeof e.command === 'string' && e.command.includes('hub-bridge.mjs')))
44
+ .map(([event]) => event);
45
+ if (!wired.length) return { ok: true, skipped: 'the hub hook is not in ~/.cursor/hooks.json' };
46
+ const missing = HUB_HOOK_EVENTS.filter((event) => !wired.includes(event));
47
+ return {
48
+ ok: true, wired, missing, warn: missing.length > 0,
49
+ message: missing.length
50
+ ? `~/.cursor/hooks.json does not send ${missing.join(', ')} to the hub hook: a bus register chained to a failing command does not bind, and a session in a long tool call can look gone. Add them with the same command as postToolUse.`
51
+ : 'The hub hook gets every event it uses.',
52
+ };
53
+ }
package/src/lib/errors.js CHANGED
@@ -8,6 +8,20 @@ export class HubError extends Error {
8
8
  }
9
9
  }
10
10
 
11
+ /** Pulse 1.1.2 (SCOPE-02): what fixes a refusal no retry can fix: a sign-in missing scopes, or a machine grant missing a capability. */
12
+ export const SCOPE_FIX = 'agentpulse pulse login';
13
+ export const GRANT_FIX = 'agentpulse pulse log-write on';
14
+
15
+ /** A refusal for scopes or grant as health shows it (`{ code, missingScopes, fix }`), or null for any other error. */
16
+ export function scopeBlock(error) {
17
+ const code = error?.code;
18
+ if (code === 'HUB_SCOPE_DENIED' || code === 'HUB_SCOPE_MISSING') {
19
+ return { code, missingScopes: Array.isArray(error.details?.missingScopes) ? error.details.missingScopes : [], fix: SCOPE_FIX };
20
+ }
21
+ if (code === 'capability_denied') return { code, missingScopes: [], fix: GRANT_FIX };
22
+ return null;
23
+ }
24
+
11
25
  export function assertNever(value, label = 'value') {
12
26
  throw new HubError('HUB_INTERNAL_UNHANDLED_VARIANT', `Unhandled ${label}: ${String(value)}`);
13
27
  }
@@ -0,0 +1,132 @@
1
+ import path from 'node:path';
2
+ import { openDatabase } from './sqlite.js';
3
+
4
+ const parseJson = (text, fallback) => {
5
+ if (text === null || text === undefined || text === '') return fallback;
6
+ try {
7
+ return JSON.parse(text);
8
+ } catch {
9
+ return fallback;
10
+ }
11
+ };
12
+
13
+ /** An actor row in the local registry's shape (agents-hub core/registry.mjs rowActor at 37a0b2f, without `title`). */
14
+ export function rowActor(r) {
15
+ return {
16
+ id: r.id,
17
+ node_type: r.node_type,
18
+ role: r.role,
19
+ parent_id: r.parent_id,
20
+ delivery_id: r.delivery_id,
21
+ stream_key: r.stream_key,
22
+ cursor_title: r.cursor_title,
23
+ prompt_title: r.prompt_title,
24
+ persona_name: r.persona_name,
25
+ persona_first: r.persona_first,
26
+ persona_username: r.persona_username,
27
+ persona_seed: r.persona_seed,
28
+ persona: parseJson(r.persona_json, null),
29
+ source: r.source,
30
+ topics: parseJson(r.topics, []),
31
+ started_at: r.started_at,
32
+ last_at: r.last_at,
33
+ tokens_est: r.tokens_est,
34
+ msgs_in: r.msgs_in,
35
+ msgs_out: r.msgs_out,
36
+ repo: r.repo,
37
+ channel: r.channel,
38
+ synthetic: !!r.synthetic,
39
+ extra: parseJson(r.extra, null),
40
+ };
41
+ }
42
+
43
+ const plain = (row) => ({ ...row });
44
+
45
+ /**
46
+ * Read-only access to the local hub's `state/hub.db`. Each `read(fn)` opens the file read-only (no `immutable`, no
47
+ * pragma that writes), runs every query of one pass inside a single short read transaction so recipients and their
48
+ * messages come from the same snapshot, and closes the handle, so the hub's writers and WAL checkpoints never wait on
49
+ * a reader across an await. `fn` must be synchronous.
50
+ */
51
+ export class HubDbReader {
52
+ constructor({ hubDir, file = path.join(hubDir, 'state', 'hub.db'), busyMs = 2_000 }) {
53
+ this.file = file;
54
+ this.busyMs = busyMs;
55
+ }
56
+
57
+ read(fn) {
58
+ const db = openDatabase(this.file, { readOnly: true });
59
+ try {
60
+ db.prepare(`PRAGMA busy_timeout = ${Number(this.busyMs)}`).run();
61
+ db.prepare('BEGIN').run();
62
+ try {
63
+ return fn(new HubSnapshot(db));
64
+ } finally {
65
+ db.prepare('COMMIT').run();
66
+ }
67
+ } finally {
68
+ db.close();
69
+ }
70
+ }
71
+ }
72
+
73
+ /** The queries of one read transaction. */
74
+ export class HubSnapshot {
75
+ constructor(db) {
76
+ this.db = db;
77
+ }
78
+
79
+ /** Every actor in table order (the order the local registry lists them). */
80
+ actors() {
81
+ return this.db.prepare('SELECT * FROM actors ORDER BY rowid').all().map(rowActor);
82
+ }
83
+
84
+ presence() {
85
+ return this.db.prepare('SELECT actor_id, last_seen, last_event FROM presence ORDER BY rowid').all().map(plain);
86
+ }
87
+
88
+ /** Messages created at or after `sinceMs`, in (created_at, rowid) order: rowid breaks ties the way the hub inserted them. */
89
+ messagesSince(sinceMs) {
90
+ return this.db.prepare('SELECT rowid AS _rowid, * FROM messages WHERE created_at >= ? ORDER BY created_at, rowid').all(sinceMs)
91
+ .map((row) => ({ ...row, meta: parseJson(row.meta, {}) }));
92
+ }
93
+
94
+ messageIdsSince(sinceMs) {
95
+ return new Set(this.db.prepare('SELECT id FROM messages WHERE created_at >= ?').all(sinceMs).map((row) => row.id));
96
+ }
97
+
98
+ messageExists(id) {
99
+ return Boolean(this.db.prepare('SELECT 1 AS found FROM messages WHERE id = ?').get(id));
100
+ }
101
+
102
+ /** Recipient rows of these messages, in table order. */
103
+ recipients(messageIds) {
104
+ const out = new Map();
105
+ const statement = this.db.prepare(
106
+ 'SELECT message_id, recipient, state, attempts, delivered_at, delivered_via, acked_at FROM deliveries WHERE message_id = ? ORDER BY rowid',
107
+ );
108
+ for (const id of messageIds) out.set(id, statement.all(id).map(plain));
109
+ return out;
110
+ }
111
+
112
+ /** One recipient row with its message's expiry, or null when either is gone. */
113
+ receipt(messageId, recipient) {
114
+ const row = this.db.prepare(
115
+ `SELECT d.message_id, d.recipient, d.state, d.attempts, d.delivered_at, d.delivered_via, d.acked_at, m.expires_at, m.topic
116
+ FROM deliveries d JOIN messages m ON m.id = d.message_id
117
+ WHERE d.message_id = ? AND d.recipient = ?`,
118
+ ).get(messageId, recipient);
119
+ return row ? plain(row) : null;
120
+ }
121
+
122
+ eventsAfter(id) {
123
+ return this.db.prepare('SELECT id, at, kind, actor, payload FROM events WHERE id > ? ORDER BY id').all(id)
124
+ .map((row) => ({ ...row, payload: parseJson(row.payload, {}) }));
125
+ }
126
+
127
+ /** Facts of these kinds with their payload text as stored (parsed by the caller only when it changed). */
128
+ facts(kinds) {
129
+ const statement = this.db.prepare('SELECT kind, key, payload, updated_at FROM facts WHERE kind = ? ORDER BY key');
130
+ return kinds.flatMap((kind) => statement.all(kind).map(plain));
131
+ }
132
+ }
@@ -0,0 +1,108 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { pathRejected } from './mirrorContract.js';
3
+ import { canonicalJson } from './planContract.js';
4
+
5
+ /**
6
+ * The local hub's rows as Pulse commands (design §11 table, cf-phase2 PLAN v2), as pure functions: the import, the
7
+ * shadow feed and anything that captures the same rows later (Phase 3) produce the same keys and payloads. Redaction
8
+ * is not done here: callers pass rows through the UploadRedactor, and content keys are taken from redacted payloads.
9
+ */
10
+ const KEY_PART = /^[A-Za-z0-9._:/@-]{1,200}$/;
11
+ const PERSONA_FIELDS = ['name', 'first', 'username', 'seed', 'personality'];
12
+
13
+ const sha = (text) => createHash('sha256').update(text).digest('hex');
14
+ export const without = (object, ...keys) => Object.fromEntries(Object.entries(object).filter(([key]) => !keys.includes(key)));
15
+
16
+ /** `<label>:<part>` when the part is key-safe, else `<label>:h:<sha40>`, so a key never carries a space, path or secret. */
17
+ export function keyPart(label, part) {
18
+ return KEY_PART.test(String(part)) && !pathRejected(String(part)) ? `${label}:${part}` : `${label}:h:${sha(String(part)).slice(0, 40)}`;
19
+ }
20
+
21
+ /** The content key of a state entity: the redacted payload without its `at`, digested. */
22
+ export function contentSha(redactedPayload) {
23
+ return sha(canonicalJson(without(redactedPayload, 'at'))).slice(0, 12);
24
+ }
25
+
26
+ export function actorPayload(actor) {
27
+ return { id: actor.id, actor, at: Math.floor(Number(actor.last_at) || 0) };
28
+ }
29
+
30
+ export function messagePayload(message, recipients) {
31
+ return {
32
+ id: message.id, from: message.sender, fromName: message.from_name ?? null, topic: message.topic, type: message.type,
33
+ body: message.body, to: [...new Set(recipients.map((row) => row.recipient))], needsAck: Boolean(message.needs_reply),
34
+ expiresAt: Number(message.expires_at), replyTo: message.reply_to ?? null, reaction: message.reaction ?? null,
35
+ meta: message.meta ?? {}, at: Number(message.created_at),
36
+ };
37
+ }
38
+
39
+ /** The receipts a recipient row's current state implies (`leased` is `queued`: leases stay on the machine). */
40
+ export function receiptEvents(row, topic, expiresAt) {
41
+ const base = { messageId: row.message_id, recipient: row.recipient };
42
+ const events = [];
43
+ const state = row.state === 'leased' ? 'queued' : row.state;
44
+ if (row.delivered_at !== null && row.delivered_at !== undefined) {
45
+ events.push({ type: 'receipt.delivered', key: `rcpt:${row.message_id}:${row.recipient}:delivered`, payload: { ...base, at: Number(row.delivered_at), ...(row.delivered_via ? { via: row.delivered_via } : {}) } });
46
+ }
47
+ if (state === 'acked') {
48
+ events.push({ type: 'receipt.acked', key: `rcpt:${row.message_id}:${row.recipient}:acked`, payload: { ...base, at: Number(row.acked_at ?? row.delivered_at ?? row.queued_at ?? expiresAt) } });
49
+ }
50
+ if (state === 'expired') {
51
+ events.push({ type: 'receipt.expired', key: `rcpt:${row.message_id}:${row.recipient}:expired`, payload: { ...base, at: Number(expiresAt) } });
52
+ }
53
+ return { state, events: events.map((event) => ({ ...event, group: `bus:${topic}` })) };
54
+ }
55
+
56
+ export function eventPayload(event) {
57
+ return { kind: event.kind, actor: event.actor ?? null, data: event.payload ?? {}, at: Number(event.at) };
58
+ }
59
+
60
+ export function personaPayload(id, persona) {
61
+ const value = Object.fromEntries(PERSONA_FIELDS.filter((field) => persona?.[field] !== undefined && persona?.[field] !== null).map((field) => [field, persona[field]]));
62
+ return { actorId: id, persona: value };
63
+ }
64
+
65
+ /**
66
+ * The commands of one fact row, each with its entity and key prefix (content keyed): a delivery travels without its
67
+ * streams, which go as stream facts of their own so a large delivery fits a command.
68
+ */
69
+ export function factDescriptors(row, fact) {
70
+ const entity = `fact:${row.kind}:${row.key}`;
71
+ const prefix = keyPart(`fact:${row.kind}`, row.key);
72
+ const at = Number.isFinite(fact.at) ? fact.at : Number(row.updated_at);
73
+ const one = (type, payload) => ({ type, entity, prefix, group: entity, payload });
74
+ switch (row.kind) {
75
+ case 'delivery': {
76
+ const streams = Array.isArray(fact.streams) ? fact.streams : [];
77
+ const delivery = { ...without(fact, 'streams'), streamKeys: streams.map((stream) => stream.key) };
78
+ return [
79
+ one('delivery.updated', { id: row.key, delivery, at: Number(row.updated_at) }),
80
+ ...streams.map((stream) => ({
81
+ type: 'stream.updated', entity: `fact:stream:${stream.key}`, prefix: keyPart('fact:stream', stream.key), group: `fact:stream:${stream.key}`,
82
+ payload: { key: stream.key, deliveryId: row.key, stream: without(stream, 'deliveryId'), at: Number(row.updated_at) },
83
+ })),
84
+ ];
85
+ }
86
+ case 'agent-row':
87
+ return [one('actor.row_updated', { id: row.key, row: fact, at: Number(row.updated_at) })];
88
+ case 'request':
89
+ return [one('request.created', { id: row.key, request: fact, at })];
90
+ case 'accepted':
91
+ return [one('delivery.accepted', { id: row.key, at })];
92
+ case 'settings':
93
+ return [one('settings.changed', { key: row.key, value: fact, at: Number(row.updated_at) })];
94
+ case 'status_override':
95
+ return [one('status.overridden', { key: row.key, override: fact, at })];
96
+ default:
97
+ throw new TypeError(`hub mapping: fact kind ${row.kind} has no command`);
98
+ }
99
+ }
100
+
101
+ /** A task-channel post: its key from its own block's digest and occurrence, never from its inferred time. */
102
+ export function postKey(channelKey, digestId) {
103
+ return `post:${channelKey}:${digestId}`;
104
+ }
105
+
106
+ export function postPayload(channel, post, body, truncated) {
107
+ return { channel, author: post.role, role: post.role, type: post.type, body, at: post.at, ...(truncated ? { truncated } : {}) };
108
+ }