@agentpulselabs/cli-plugin-pulse 0.1.0 → 1.0.0

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 (68) hide show
  1. package/README.md +69 -32
  2. package/package.json +10 -10
  3. package/src/commands/pulse/doctor.js +14 -0
  4. package/src/commands/pulse/hub-write.js +26 -0
  5. package/src/commands/pulse/install.js +14 -0
  6. package/src/commands/pulse/login.js +20 -0
  7. package/src/commands/pulse/mirror.js +27 -0
  8. package/src/commands/pulse/pair.js +38 -0
  9. package/src/commands/pulse/restart.js +14 -0
  10. package/src/commands/pulse/start.js +8 -18
  11. package/src/commands/pulse/status.js +8 -7
  12. package/src/commands/pulse/stop.js +24 -0
  13. package/src/daemon/main.js +36 -0
  14. package/src/lib/BaseCommand.js +37 -0
  15. package/src/lib/agentSessions.js +279 -0
  16. package/src/lib/application.js +656 -0
  17. package/src/lib/backoff.js +8 -0
  18. package/src/lib/budgets.js +85 -0
  19. package/src/lib/cliDefaults.js +20 -0
  20. package/src/lib/cloudAuth.js +189 -0
  21. package/src/lib/contract.sha256 +2 -0
  22. package/src/lib/daemonComposition.js +338 -0
  23. package/src/lib/daemonLock.js +55 -0
  24. package/src/lib/daemonLoop.js +69 -0
  25. package/src/lib/daemonProcess.js +366 -0
  26. package/src/lib/daemonRuntime.js +820 -0
  27. package/src/lib/drivers.js +280 -0
  28. package/src/lib/errors.js +19 -0
  29. package/src/lib/graphWindows.js +149 -0
  30. package/src/lib/http.js +84 -0
  31. package/src/lib/hubActions.js +74 -0
  32. package/src/lib/identity.js +102 -0
  33. package/src/lib/intents.js +590 -0
  34. package/src/lib/ledger.js +535 -0
  35. package/src/lib/lifecycle.js +123 -0
  36. package/src/lib/localHub.js +217 -0
  37. package/src/lib/localHubWriter.js +69 -0
  38. package/src/lib/mirror-contract.json +4299 -0
  39. package/src/lib/mirrorContract.js +513 -0
  40. package/src/lib/mirrorDaemon.js +127 -0
  41. package/src/lib/mirrorService.js +358 -0
  42. package/src/lib/mirrorSource.js +455 -0
  43. package/src/lib/mirrorSync.js +353 -0
  44. package/src/lib/oauthLogin.js +132 -0
  45. package/src/lib/ownerLock.js +99 -0
  46. package/src/lib/pairedConfig.js +40 -0
  47. package/src/lib/pairingLifecycle.js +179 -0
  48. package/src/lib/pathRedaction.js +89 -0
  49. package/src/lib/planContract.js +141 -0
  50. package/src/lib/priorityLane.js +80 -0
  51. package/src/lib/processOwnership.js +252 -0
  52. package/src/lib/processSupervisor.js +376 -0
  53. package/src/lib/redaction.js +128 -0
  54. package/src/lib/rootPolicy.js +372 -0
  55. package/src/lib/rulePacks.js +162 -0
  56. package/src/lib/secretStore.js +169 -0
  57. package/src/lib/selfRestart.js +261 -0
  58. package/src/lib/serviceManager.js +358 -0
  59. package/src/lib/storage.js +131 -0
  60. package/src/lib/subprocess.js +45 -0
  61. package/src/lib/tokenStore.js +97 -0
  62. package/src/lib/transport.js +286 -0
  63. package/src/lib/transportContract.js +37 -0
  64. package/src/commands/pulse/gate.js +0 -23
  65. package/src/commands/pulse/next.js +0 -13
  66. package/src/commands/pulse/submit.js +0 -19
  67. package/src/commands/pulse/verify.js +0 -18
  68. package/src/runPulse.js +0 -58
@@ -0,0 +1,217 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { HubError } from './errors.js';
5
+
6
+ const DEFAULT_PORT = 8890;
7
+ const HUB_PLIST = 'com.agentpulse.agents-hub.plist';
8
+ const TOKEN = /^[a-f0-9]{32,}$/i;
9
+ const QUERY_KEYS = Object.freeze({
10
+ '/api/deliveries': [],
11
+ '/api/graph': ['hours'],
12
+ '/api/chat/channels': [],
13
+ '/api/chat/messages': ['ch'],
14
+ '/api/inbox': ['since'],
15
+ '/api/task/brief': ['key'],
16
+ '/api/stream': [],
17
+ });
18
+
19
+ export const LOCAL_HUB_ENDPOINTS = Object.freeze(Object.keys(QUERY_KEYS));
20
+
21
+ function notFound(message) {
22
+ return new HubError('HUB_LOCAL_HUB_NOT_FOUND', message);
23
+ }
24
+
25
+ function validPort(port) {
26
+ return Number.isInteger(port) && port > 0 && port < 65536;
27
+ }
28
+
29
+ function unxml(value) {
30
+ return value
31
+ .replaceAll('&apos;', "'")
32
+ .replaceAll('&quot;', '"')
33
+ .replaceAll('&gt;', '>')
34
+ .replaceAll('&lt;', '<')
35
+ .replaceAll('&amp;', '&');
36
+ }
37
+
38
+ export async function discoverLocalHub({
39
+ config = {},
40
+ home = os.homedir(),
41
+ platform = process.platform,
42
+ readText = (file) => readFile(file, 'utf8'),
43
+ } = {}) {
44
+ const configured = config?.mirror?.hub;
45
+ if (configured?.dir) {
46
+ const port = configured.port === undefined ? DEFAULT_PORT : configured.port;
47
+ if (!path.isAbsolute(configured.dir) || !validPort(port)) {
48
+ throw notFound('mirror.hub in the daemon config needs an absolute dir and a valid port');
49
+ }
50
+ return { dir: configured.dir, port };
51
+ }
52
+ if (platform !== 'darwin') {
53
+ throw notFound('Set mirror.hub.dir (and mirror.hub.port) in the daemon config to locate the local Agent Hub');
54
+ }
55
+ let plist;
56
+ try {
57
+ plist = await readText(path.join(home, 'Library', 'LaunchAgents', HUB_PLIST));
58
+ } catch {
59
+ throw notFound(`The local Agent Hub service (${HUB_PLIST}) is not installed`);
60
+ }
61
+ const array = plist.match(/<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/)?.[1] || '';
62
+ const argv = [...array.matchAll(/<string>([^<]*)<\/string>/g)].map((match) => unxml(match[1]));
63
+ const script = argv.find((arg) => path.isAbsolute(arg) && path.basename(arg) === 'hub.mjs');
64
+ if (!script) throw notFound(`${HUB_PLIST} does not start hub.mjs`);
65
+ const portIndex = argv.indexOf('--port');
66
+ const port = portIndex === -1 ? DEFAULT_PORT : Number(argv[portIndex + 1]);
67
+ if (!validPort(port)) throw notFound(`${HUB_PLIST} has an invalid --port`);
68
+ return { dir: path.dirname(script), port };
69
+ }
70
+
71
+ function parseEvents(buffer, onChange) {
72
+ const blocks = buffer.split(/\r?\n\r?\n/);
73
+ const rest = blocks.pop();
74
+ for (const block of blocks) {
75
+ let event = 'message';
76
+ const data = [];
77
+ for (const line of block.split(/\r?\n/)) {
78
+ if (line.startsWith('event:')) event = line.slice(6).trim();
79
+ else if (line.startsWith('data:')) data.push(line.slice(5).trimStart());
80
+ }
81
+ if (event !== 'change' || data.length === 0) continue;
82
+ try {
83
+ const kinds = JSON.parse(data.join('\n')).kinds;
84
+ if (Array.isArray(kinds)) onChange(kinds.filter((kind) => typeof kind === 'string'));
85
+ } catch {
86
+ // A malformed event is ignored; the periodic reconcile still catches the change.
87
+ }
88
+ }
89
+ return rest;
90
+ }
91
+
92
+ export class LocalHubReader {
93
+ constructor({
94
+ dir, port, fetch = globalThis.fetch, readText = (file) => readFile(file, 'utf8'), timeoutMs = 30_000, maxBytes = 32 * 1024 * 1024,
95
+ onRead = () => {},
96
+ }) {
97
+ if (!validPort(port)) throw notFound('Invalid local Agent Hub port');
98
+ this.onRead = onRead;
99
+ this.dir = dir;
100
+ this.origin = `http://127.0.0.1:${port}`;
101
+ this.fetch = fetch;
102
+ this.readText = readText;
103
+ this.timeoutMs = timeoutMs;
104
+ this.maxBytes = maxBytes;
105
+ this.graphReadsInFlight = 0;
106
+ this.lastGraphReadAt = -Infinity;
107
+ }
108
+
109
+ /** The hub writes hub.db on every graph read, so the change feed echoes each one; these say when ours ran. */
110
+ graphReadRecently(withinMs, now = Date.now()) {
111
+ return this.graphReadsInFlight > 0 || now - this.lastGraphReadAt < withinMs;
112
+ }
113
+
114
+ async get(pathname, params = {}) {
115
+ if (pathname !== '/api/graph') return this.#get(pathname, params);
116
+ this.graphReadsInFlight += 1;
117
+ try {
118
+ return await this.#get(pathname, params);
119
+ } finally {
120
+ this.graphReadsInFlight -= 1;
121
+ this.lastGraphReadAt = Date.now();
122
+ }
123
+ }
124
+
125
+ async tokenValue() {
126
+ let token;
127
+ try {
128
+ token = (await this.readText(path.join(this.dir, 'state', 'hub.token'))).trim();
129
+ } catch {
130
+ token = '';
131
+ }
132
+ if (!TOKEN.test(token)) {
133
+ throw new HubError('HUB_LOCAL_HUB_TOKEN_MISSING', 'The local Agent Hub token (state/hub.token) is missing or malformed');
134
+ }
135
+ return token;
136
+ }
137
+
138
+ async #headers() {
139
+ return { authorization: `Bearer ${await this.tokenValue()}`, accept: 'application/json' };
140
+ }
141
+
142
+ #url(pathname, params) {
143
+ if (!Object.hasOwn(QUERY_KEYS, pathname)) {
144
+ throw new HubError('HUB_LOCAL_HUB_ENDPOINT_DENIED', `Local Agent Hub endpoint is not allowed: ${pathname}`);
145
+ }
146
+ const url = new URL(pathname, this.origin);
147
+ for (const [key, value] of Object.entries(params)) {
148
+ if (!QUERY_KEYS[pathname].includes(key)) {
149
+ throw new HubError('HUB_LOCAL_HUB_ENDPOINT_DENIED', `Query parameter ${key} is not allowed on ${pathname}`);
150
+ }
151
+ url.searchParams.set(key, String(value));
152
+ }
153
+ return url;
154
+ }
155
+
156
+ async #get(pathname, params) {
157
+ const url = this.#url(pathname, params);
158
+ this.onRead(pathname);
159
+ const response = await this.fetch(url, {
160
+ method: 'GET',
161
+ headers: await this.#headers(),
162
+ redirect: 'error',
163
+ signal: AbortSignal.timeout(this.timeoutMs),
164
+ });
165
+ if (!response.ok) {
166
+ throw new HubError(`HUB_LOCAL_HUB_HTTP_${response.status}`, `Local Agent Hub returned ${response.status} for ${pathname}`);
167
+ }
168
+ const declared = Number(response.headers.get('content-length'));
169
+ if (Number.isFinite(declared) && declared > this.maxBytes) {
170
+ throw new HubError('HUB_LOCAL_HUB_RESPONSE_TOO_LARGE', `Local Agent Hub response for ${pathname} is too large`);
171
+ }
172
+ const text = await response.text();
173
+ if (Buffer.byteLength(text, 'utf8') > this.maxBytes) {
174
+ throw new HubError('HUB_LOCAL_HUB_RESPONSE_TOO_LARGE', `Local Agent Hub response for ${pathname} is too large`);
175
+ }
176
+ return JSON.parse(text);
177
+ }
178
+
179
+ async subscribe({ onChange, onOpen = () => {}, signal, idleMs = 75_000 } = {}) {
180
+ const idle = new AbortController();
181
+ const forward = () => idle.abort(signal.reason);
182
+ if (signal?.aborted) forward();
183
+ signal?.addEventListener('abort', forward, { once: true });
184
+ let timer = null;
185
+ const arm = () => {
186
+ clearTimeout(timer);
187
+ timer = setTimeout(() => idle.abort(new HubError('HUB_LOCAL_HUB_STREAM_IDLE', 'The local Agent Hub stream went silent')), idleMs);
188
+ timer.unref?.();
189
+ };
190
+ arm();
191
+ try {
192
+ const response = await this.fetch(this.#url('/api/stream', {}), {
193
+ method: 'GET',
194
+ headers: { ...(await this.#headers()), accept: 'text/event-stream' },
195
+ redirect: 'error',
196
+ signal: idle.signal,
197
+ });
198
+ if (!response.ok || !response.body) {
199
+ throw new HubError(`HUB_LOCAL_HUB_HTTP_${response.status}`, `Local Agent Hub stream returned ${response.status}`);
200
+ }
201
+ onOpen();
202
+ const decoder = new TextDecoder();
203
+ let buffer = '';
204
+ for await (const chunk of response.body) {
205
+ arm();
206
+ buffer = parseEvents(buffer + decoder.decode(chunk, { stream: true }), onChange);
207
+ if (buffer.length > 1024 * 1024) buffer = '';
208
+ }
209
+ } catch (error) {
210
+ if (idle.signal.aborted && !signal?.aborted) throw idle.signal.reason;
211
+ throw error;
212
+ } finally {
213
+ clearTimeout(timer);
214
+ signal?.removeEventListener('abort', forward);
215
+ }
216
+ }
217
+ }
@@ -0,0 +1,69 @@
1
+ import { HubError } from './errors.js';
2
+ import { LocalHubReader } from './localHub.js';
3
+
4
+ /** The only local hub writes a Pulse action may make. No /bus/publish: a bus reply goes through /bus/ack. */
5
+ export const LOCAL_HUB_WRITE_PATHS = Object.freeze(['/api/chat/post', '/api/message', '/api/decision', '/api/accept', '/api/status', '/bus/ack']);
6
+ const MAX_RESPONSE_BYTES = 64 * 1024;
7
+ // Refused before any byte reached the hub: the write certainly did not happen.
8
+ const NOT_CONNECTED = new Set(['ECONNREFUSED', 'EHOSTUNREACH', 'ENETUNREACH', 'ENOTFOUND']);
9
+
10
+ const unreachable = (message) => new HubError('hub_unreachable', message);
11
+
12
+ /**
13
+ * POSTs one Pulse action to the local Agent Hub on 127.0.0.1 with the hub token, always as the principal `user`: the
14
+ * person who paired this machine, never an agent.
15
+ */
16
+ export class LocalHubWriter {
17
+ constructor({ dir, port, fetch = globalThis.fetch, readText, timeoutMs = 10_000, log = () => {} }) {
18
+ this.log = log;
19
+ this.reader = new LocalHubReader({ dir, port, fetch, ...(readText ? { readText } : {}) });
20
+ this.origin = this.reader.origin;
21
+ this.fetch = fetch;
22
+ this.timeoutMs = timeoutMs;
23
+ }
24
+
25
+ async post(pathname, body) {
26
+ if (!LOCAL_HUB_WRITE_PATHS.includes(pathname)) {
27
+ throw new HubError('HUB_LOCAL_HUB_ENDPOINT_DENIED', `Local Agent Hub write is not allowed: ${pathname}`);
28
+ }
29
+ let token;
30
+ try {
31
+ token = await this.reader.tokenValue();
32
+ } catch (error) {
33
+ throw unreachable(error.message);
34
+ }
35
+ let response;
36
+ try {
37
+ response = await this.fetch(new URL(pathname, this.origin), {
38
+ method: 'POST',
39
+ headers: {
40
+ authorization: `Bearer ${token}`,
41
+ 'x-hub-as': 'user',
42
+ 'content-type': 'application/json',
43
+ accept: 'application/json',
44
+ },
45
+ body: JSON.stringify(body),
46
+ redirect: 'error',
47
+ signal: AbortSignal.timeout(this.timeoutMs),
48
+ });
49
+ } catch (error) {
50
+ const code = error?.cause?.code || error?.code;
51
+ this.log('hub_response', { path: pathname, error: error?.name === 'TimeoutError' ? 'timeout' : code || 'connection_lost' });
52
+ if (NOT_CONNECTED.has(code)) throw unreachable(`The local Agent Hub is not running on ${this.origin}`);
53
+ throw new HubError('hub_write_unconfirmed', `The local Agent Hub did not answer (${error?.name === 'TimeoutError' ? 'timed out' : code || 'connection lost'}); the write may or may not have landed`);
54
+ }
55
+ this.log('hub_response', { path: pathname, status: response.status });
56
+ const text = await response.text().catch(() => '');
57
+ let parsed = {};
58
+ if (Buffer.byteLength(text, 'utf8') <= MAX_RESPONSE_BYTES) {
59
+ try { parsed = JSON.parse(text || '{}'); } catch { parsed = {}; }
60
+ }
61
+ const reason = typeof parsed.error === 'string' ? parsed.error.slice(0, 200) : `HTTP ${response.status}`;
62
+ // A 5xx can follow a write the hub already appended, so only a 4xx proves nothing landed.
63
+ if (response.status >= 500) {
64
+ throw new HubError('hub_write_unconfirmed', `The local Agent Hub failed while handling it (${reason}); the write may or may not have landed`);
65
+ }
66
+ if (!response.ok) throw new HubError('hub_refused', `The hub refused it: ${reason}`);
67
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
68
+ }
69
+ }