@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,169 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import os from 'node:os';
3
+ import { HubError } from './errors.js';
4
+ import { spawnBounded } from './subprocess.js';
5
+
6
+ const SERVICE = 'com.agentpulse.hub';
7
+ /** A Keychain or libsecret call normally takes well under a second; one that hangs (a locked keychain) is killed. */
8
+ export const SECRET_COMMAND_TIMEOUT_MS = 10_000;
9
+
10
+ /** What a secret-command error may carry: the command's output can be a secret, so only how it ended. */
11
+ const exitOnly = ({ code, signal }) => ({ code, signal });
12
+
13
+ export async function runSecretCommand(bin, args, { input = '', env = process.env, timeoutMs = SECRET_COMMAND_TIMEOUT_MS } = {}) {
14
+ let result;
15
+ try {
16
+ result = await spawnBounded(bin, args, {
17
+ input,
18
+ env,
19
+ timeoutMs,
20
+ onTimeout: () => new HubError('HUB_SECRET_STORE_TIMEOUT', `${bin} did not answer within ${timeoutMs} ms and was stopped`),
21
+ });
22
+ } catch (error) {
23
+ if (error.code === 'ENOENT') {
24
+ throw new HubError(
25
+ 'HUB_SECRET_STORE_UNAVAILABLE',
26
+ `${bin} is not installed; Pulse stores sign-in and keys only in the OS keychain (install libsecret's secret-tool on Linux)`,
27
+ { cause: error },
28
+ );
29
+ }
30
+ if (error.result) error.result = exitOnly(error.result);
31
+ throw error;
32
+ }
33
+ if (result.code === 0) return result;
34
+ const error = new HubError('HUB_SECRET_STORE_FAILED', `${bin} failed with exit ${result.code}`);
35
+ error.result = exitOnly(result);
36
+ throw error;
37
+ }
38
+
39
+ const SECURITY = '/usr/bin/security';
40
+ const CHUNKED_PREFIX = 'b64chunks:';
41
+ const CHUNK_CHARACTERS = 1024;
42
+ const KEYCHAIN_NAME = /^[A-Za-z0-9._@-]+$/;
43
+
44
+ export class KeychainSecretStore {
45
+ constructor({ execute = runSecretCommand, account = os.userInfo().username } = {}) {
46
+ if (!KEYCHAIN_NAME.test(account)) {
47
+ throw new HubError('HUB_SECRET_STORE_UNAVAILABLE', 'The macOS account name cannot be used as a Keychain account');
48
+ }
49
+ this.execute = execute;
50
+ this.account = account;
51
+ }
52
+
53
+ #service(reference) {
54
+ const service = `${SERVICE}.${reference}`;
55
+ if (!KEYCHAIN_NAME.test(service)) throw new HubError('HUB_SECRET_STORE_FAILED', 'Invalid Keychain item name');
56
+ return service;
57
+ }
58
+
59
+ async #find(service) {
60
+ try {
61
+ const result = await this.execute(SECURITY, ['find-generic-password', '-a', this.account, '-s', service, '-w']);
62
+ return result.stdout.replace(/\n$/, '');
63
+ } catch (error) {
64
+ if (error.result?.code === 44) return null;
65
+ throw error;
66
+ }
67
+ }
68
+
69
+ async #remove(service) {
70
+ try {
71
+ await this.execute(SECURITY, ['delete-generic-password', '-a', this.account, '-s', service]);
72
+ } catch (error) {
73
+ if (error.result?.code !== 44) throw error;
74
+ }
75
+ }
76
+
77
+ async #write(entries) {
78
+ const lines = entries.map(([service, data]) => `add-generic-password -U -a "${this.account}" -s "${service}" -w ${data}`);
79
+ await this.execute(SECURITY, ['-i'], { input: `${lines.join('\n')}\n` });
80
+ }
81
+
82
+ static #parts(service, header) {
83
+ if (!header?.startsWith(CHUNKED_PREFIX)) return null;
84
+ const [generation, count] = header.slice(CHUNKED_PREFIX.length).split(':');
85
+ return Array.from({ length: Number(count) || 0 }, (_unused, index) => `${service}.${generation}.${index}`);
86
+ }
87
+
88
+ async get(reference) {
89
+ const service = this.#service(reference);
90
+ const header = await this.#find(service);
91
+ const parts = KeychainSecretStore.#parts(service, header);
92
+ if (!parts) return header;
93
+ const chunks = [];
94
+ for (const part of parts) {
95
+ const chunk = await this.#find(part);
96
+ if (chunk === null) throw new HubError('HUB_SECRET_STORE_CORRUPT', 'A Keychain secret is incomplete');
97
+ chunks.push(chunk);
98
+ }
99
+ return Buffer.from(chunks.join(''), 'base64').toString('utf8');
100
+ }
101
+
102
+ async set(reference, value) {
103
+ const service = this.#service(reference);
104
+ const previous = KeychainSecretStore.#parts(service, await this.#find(service)) || [];
105
+ const encoded = Buffer.from(String(value), 'utf8').toString('base64');
106
+ const chunks = encoded.match(new RegExp(`.{1,${CHUNK_CHARACTERS}}`, 'g')) || [];
107
+ const generation = randomBytes(6).toString('hex');
108
+ if (chunks.length) {
109
+ await this.#write(chunks.map((chunk, index) => [`${service}.${generation}.${index}`, chunk]));
110
+ }
111
+ await this.#write([[service, `${CHUNKED_PREFIX}${generation}:${chunks.length}`]]);
112
+ if ((await this.get(reference)) !== String(value)) {
113
+ throw new HubError('HUB_SECRET_STORE_FAILED', 'The Keychain did not store the secret intact');
114
+ }
115
+ for (const part of previous) await this.#remove(part);
116
+ }
117
+
118
+ async delete(reference) {
119
+ const service = this.#service(reference);
120
+ const parts = KeychainSecretStore.#parts(service, await this.#find(service)) || [];
121
+ await this.#remove(service);
122
+ for (const part of parts) await this.#remove(part);
123
+ }
124
+ }
125
+
126
+ export class LibsecretStore {
127
+ constructor({ execute = runSecretCommand } = {}) {
128
+ this.execute = execute;
129
+ }
130
+
131
+ async get(reference) {
132
+ try {
133
+ const result = await this.execute('secret-tool', [
134
+ 'lookup',
135
+ 'service',
136
+ SERVICE,
137
+ 'reference',
138
+ reference,
139
+ ]);
140
+ return result.stdout.replace(/\n$/, '');
141
+ } catch (error) {
142
+ if (error.result?.code === 1) return null;
143
+ throw error;
144
+ }
145
+ }
146
+
147
+ async set(reference, value) {
148
+ await this.execute(
149
+ 'secret-tool',
150
+ ['store', '--label', 'AgentPulse Pulse connector', 'service', SERVICE, 'reference', reference],
151
+ { input: String(value) }
152
+ );
153
+ }
154
+
155
+ async delete(reference) {
156
+ await this.execute('secret-tool', ['clear', 'service', SERVICE, 'reference', reference]);
157
+ }
158
+ }
159
+
160
+ export function defaultSecretStore(platform = process.platform) {
161
+ switch (platform) {
162
+ case 'darwin':
163
+ return new KeychainSecretStore();
164
+ case 'linux':
165
+ return new LibsecretStore();
166
+ default:
167
+ throw new HubError('HUB_UNSUPPORTED_PLATFORM', `Pulse supports macOS and Linux, not ${platform}`);
168
+ }
169
+ }
@@ -0,0 +1,261 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { watch as fsWatch } from 'node:fs';
3
+ import { access, readdir, readFile, stat, unlink } from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import { readOwnerOnlyJson, writeOwnerOnlyJson } from './storage.js';
6
+
7
+ /** launchd (KeepAlive.SuccessfulExit=false) and systemd (Restart=on-failure) start the daemon again on any non-zero exit. */
8
+ export const RESTART_EXIT_CODE = 75;
9
+ export const RESTART_REQUEST_FILE = 'restart.request';
10
+ export const RESTART_HISTORY_FILE = 'restart-history.json';
11
+ const LOOP_WINDOW_MS = 5 * 60_000;
12
+ const LOOP_MAX_RESTARTS = 5;
13
+
14
+ // Modules read data files next to them (src/lib/mirror-contract.json), so every file under src/ is source. Dotfiles
15
+ // (.DS_Store, editor swap files) are not.
16
+ const sourceName = (name) => !String(name).split(path.sep).some((part) => part.startsWith('.'));
17
+
18
+ async function sourceFiles(root) {
19
+ const entries = await readdir(path.join(root, 'src'), { withFileTypes: true, recursive: true });
20
+ return entries
21
+ .filter((entry) => entry.isFile())
22
+ .map((entry) => path.relative(root, path.join(entry.parentPath ?? entry.path, entry.name)))
23
+ .filter(sourceName);
24
+ }
25
+
26
+ /** sha256 of each file under the plugin's src/ and of its package.json, by path relative to the plugin root. */
27
+ export async function sourceSnapshot(root) {
28
+ const snapshot = new Map();
29
+ for (const file of ['package.json', ...(await sourceFiles(root))].sort()) {
30
+ const bytes = await readFile(path.join(root, file)).catch(() => null);
31
+ if (bytes) snapshot.set(file, createHash('sha256').update(bytes).digest('hex'));
32
+ }
33
+ return snapshot;
34
+ }
35
+
36
+ /** Files added, removed or rewritten with different bytes; a touch or a no-op checkout changes nothing. */
37
+ export function changedFiles(before, after) {
38
+ const files = new Set([...before.keys(), ...after.keys()]);
39
+ return [...files].filter((file) => before.get(file) !== after.get(file)).sort();
40
+ }
41
+
42
+ const snapshotKey = (snapshot) => [...snapshot].join('\n');
43
+
44
+ /**
45
+ * Calls onChange(files) once, when the plugin's sources differ from their bytes at start and have been quiet for
46
+ * debounceMs. fs.watch events reset a trailing debounce, after which the files as enumerated then are hashed. If a watch
47
+ * fails (FSEvents can refuse it with EMFILE, git can swap a directory away), it polls every pollMs instead, firing only
48
+ * when two polls debounceMs apart agree, and tries to re-arm the watch every rearmMs.
49
+ */
50
+ export async function watchSource({
51
+ root, onChange, onError = () => {}, debounceMs = 2_000, pollMs = 5_000, rearmMs = 30_000, watch = fsWatch, now = Date.now,
52
+ }) {
53
+ const booted = await sourceSnapshot(root);
54
+ let closed = false;
55
+ let fired = false;
56
+ let comparing = false;
57
+ let timer = null;
58
+ let poller = null;
59
+ let rearm = null;
60
+ let watchers = [];
61
+ let seenKey = snapshotKey(booted);
62
+ let seenAt = now();
63
+
64
+ const fire = (current) => {
65
+ const files = changedFiles(booted, current);
66
+ if (!files.length || fired || closed) return;
67
+ fired = true;
68
+ onChange(files);
69
+ };
70
+ const afterQuiet = async () => {
71
+ if (comparing || closed) return;
72
+ comparing = true;
73
+ try {
74
+ fire(await sourceSnapshot(root));
75
+ } catch {
76
+ changed();
77
+ } finally {
78
+ comparing = false;
79
+ }
80
+ };
81
+ const changed = () => {
82
+ clearTimeout(timer);
83
+ timer = setTimeout(() => {
84
+ timer = null;
85
+ void afterQuiet();
86
+ }, debounceMs);
87
+ };
88
+ const poll = async () => {
89
+ if (comparing || closed) return;
90
+ comparing = true;
91
+ try {
92
+ const current = await sourceSnapshot(root);
93
+ const key = snapshotKey(current);
94
+ if (key !== seenKey) {
95
+ seenKey = key;
96
+ seenAt = now();
97
+ } else if (now() - seenAt >= debounceMs) {
98
+ fire(current);
99
+ }
100
+ } catch {
101
+ seenKey = null;
102
+ seenAt = now();
103
+ } finally {
104
+ comparing = false;
105
+ }
106
+ };
107
+ const closeWatchers = () => {
108
+ for (const watcher of watchers) watcher.close();
109
+ watchers = [];
110
+ };
111
+ const failed = (error) => {
112
+ closeWatchers();
113
+ if (closed) return;
114
+ if (!poller) {
115
+ onError(error);
116
+ poller = setInterval(() => void poll(), pollMs);
117
+ poller.unref?.();
118
+ }
119
+ clearTimeout(rearm);
120
+ rearm = setTimeout(arm, rearmMs);
121
+ rearm.unref?.();
122
+ };
123
+ function arm() {
124
+ if (closed) return;
125
+ try {
126
+ watchers = [
127
+ watch(path.join(root, 'src'), { recursive: true }, (_event, name) => {
128
+ if (name === null || sourceName(name)) changed();
129
+ }),
130
+ watch(root, (_event, name) => {
131
+ if (String(name) === 'package.json') changed();
132
+ }),
133
+ ];
134
+ for (const watcher of watchers) {
135
+ watcher.on('error', failed);
136
+ watcher.unref?.();
137
+ }
138
+ } catch (error) {
139
+ failed(error);
140
+ }
141
+ }
142
+ arm();
143
+ return {
144
+ get polling() {
145
+ return poller !== null;
146
+ },
147
+ close() {
148
+ closed = true;
149
+ clearTimeout(timer);
150
+ clearTimeout(rearm);
151
+ clearInterval(poller);
152
+ closeWatchers();
153
+ },
154
+ };
155
+ }
156
+
157
+ export async function restartRequested(directory) {
158
+ try {
159
+ await access(path.join(directory, RESTART_REQUEST_FILE));
160
+ return true;
161
+ } catch {
162
+ return false;
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Deletes restart.request and returns its mtime in ms, or null if there was none. Only the call that deleted it gets a
168
+ * time, so one request restarts the daemon once.
169
+ */
170
+ export async function consumeRestartRequest(directory) {
171
+ const file = path.join(directory, RESTART_REQUEST_FILE);
172
+ try {
173
+ const { mtimeMs } = await stat(file);
174
+ await unlink(file);
175
+ return mtimeMs;
176
+ } catch (error) {
177
+ if (error.code === 'ENOENT') return null;
178
+ throw error;
179
+ }
180
+ }
181
+
182
+ /** Counts daemon starts in a sliding window; past maxRestarts the daemon stops restarting itself until the window clears. */
183
+ export class RestartGuard {
184
+ constructor({ directory, now = Date.now, windowMs = LOOP_WINDOW_MS, maxRestarts = LOOP_MAX_RESTARTS }) {
185
+ this.file = path.join(directory, RESTART_HISTORY_FILE);
186
+ this.now = now;
187
+ this.windowMs = windowMs;
188
+ this.maxRestarts = maxRestarts;
189
+ this.starts = [];
190
+ }
191
+
192
+ async recordStart() {
193
+ const stored = await readOwnerOnlyJson(this.file).catch(() => null);
194
+ const at = this.now();
195
+ this.starts = [...(Array.isArray(stored?.starts) ? stored.starts.filter(Number.isFinite) : []), at]
196
+ .filter((start) => at - start < this.windowMs)
197
+ .slice(-(this.maxRestarts + 2));
198
+ await writeOwnerOnlyJson(this.file, { starts: this.starts });
199
+ return this.status();
200
+ }
201
+
202
+ status() {
203
+ const at = this.now();
204
+ const recent = this.starts.filter((start) => at - start < this.windowMs);
205
+ const restarts = Math.max(0, recent.length - 1);
206
+ const looping = restarts > this.maxRestarts;
207
+ return {
208
+ looping,
209
+ restarts,
210
+ windowMs: this.windowMs,
211
+ clearsAt: looping ? recent[recent.length - this.maxRestarts - 2] + this.windowMs : null,
212
+ };
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Tracks each long-running loop (the main cycle, a mirror pass) from begin to end. A loop that reports no progress for
218
+ * its limit is reported once to onStall with the step and call it was last awaiting.
219
+ */
220
+ export class Watchdog {
221
+ constructor({ onStall, now = Date.now }) {
222
+ this.onStall = onStall;
223
+ this.now = now;
224
+ this.loops = new Map();
225
+ }
226
+
227
+ begin(loop, step, limitMs) {
228
+ const entry = { step, call: null, limitMs, at: this.now(), stalled: false };
229
+ this.loops.set(loop, entry);
230
+ return entry;
231
+ }
232
+
233
+ progress(loop, { step, call } = {}) {
234
+ const entry = this.loops.get(loop);
235
+ if (!entry) return;
236
+ entry.at = this.now();
237
+ if (step !== undefined) {
238
+ entry.step = step;
239
+ entry.call = null;
240
+ }
241
+ if (call !== undefined) entry.call = call;
242
+ }
243
+
244
+ end(loop, entry = this.loops.get(loop)) {
245
+ if (this.loops.get(loop) === entry) this.loops.delete(loop);
246
+ }
247
+
248
+ check() {
249
+ for (const [loop, entry] of this.loops) {
250
+ const stalledMs = this.now() - entry.at;
251
+ if (entry.stalled || stalledMs <= entry.limitMs) continue;
252
+ entry.stalled = true;
253
+ this.onStall({
254
+ loop,
255
+ awaiting: entry.call ? `${entry.step}: ${entry.call}` : entry.step,
256
+ stalledMs,
257
+ limitMs: entry.limitMs,
258
+ });
259
+ }
260
+ }
261
+ }