@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,358 @@
1
+ import { fullJitterDelay } from './backoff.js';
2
+
3
+ // Every hub graph read writes hub.db and so raises a `hub` change event of its own; only windows passes read the
4
+ // graph (edges and bus ride on their 168 h read), so targeted passes never feed that loop, and the echo of a windows
5
+ // pass is not taken for a change (see onChange).
6
+ export const CHANGE_SOURCES = Object.freeze({
7
+ channels: ['deliveries', 'channels', 'windows'],
8
+ hub: ['deliveries', 'channels', 'inbox', 'windows'],
9
+ agents: ['deliveries', 'windows'],
10
+ // A hub heartbeat: only who is online and when they were last seen moved, which only the windows carry.
11
+ presence: ['windows'],
12
+ });
13
+
14
+ const REFUSAL_CODES = new Set(['HUB_MIRROR_SCOPE_MISSING', 'HUB_SCOPE_DENIED']);
15
+
16
+ export function isMirrorRefusal(code) {
17
+ return REFUSAL_CODES.has(code) || String(code).startsWith('machine_');
18
+ }
19
+
20
+ export class MirrorService {
21
+ constructor({
22
+ sync,
23
+ reader,
24
+ debounceMs = 300,
25
+ windowsGapMs = 2_000,
26
+ slowWindowsGapMs = 30_000,
27
+ echoMs = 1_500,
28
+ echoGapMs = 10_000,
29
+ presenceGapMs = 10_000,
30
+ reconcileMs = 5 * 60_000,
31
+ maxFullDelayMs = 15 * 60_000,
32
+ reconnectBaseMs = 1_000,
33
+ reconnectCapMs = 30_000,
34
+ refusedBaseMs = 60_000,
35
+ refusedCapMs = 15 * 60_000,
36
+ random = Math.random,
37
+ now = Date.now,
38
+ log = () => {},
39
+ onPassStart = () => null,
40
+ onPassEnd = () => {},
41
+ }) {
42
+ this.onPassStart = onPassStart;
43
+ this.onPassEnd = onPassEnd;
44
+ this.sync = sync;
45
+ this.reader = reader;
46
+ this.debounceMs = debounceMs;
47
+ this.windowsGapMs = windowsGapMs;
48
+ this.slowWindowsGapMs = slowWindowsGapMs;
49
+ this.lastSlowWindowsAt = -Infinity;
50
+ this.echoMs = echoMs;
51
+ this.echoGapMs = echoGapMs;
52
+ this.presenceGapMs = presenceGapMs;
53
+ this.windowsTimer = null;
54
+ this.windowsTimerGapMs = null;
55
+ this.lastWindowsAt = -Infinity;
56
+ this.windowsRunning = false;
57
+ this.windowsDeferredGapMs = null;
58
+ this.windowsChanged = false;
59
+ this.reconcileMs = reconcileMs;
60
+ this.maxFullDelayMs = maxFullDelayMs;
61
+ this.lastFullCompletedAt = -Infinity;
62
+ this.fullController = null;
63
+ this.reconnectBaseMs = reconnectBaseMs;
64
+ this.reconnectCapMs = reconnectCapMs;
65
+ this.refusedBaseMs = refusedBaseMs;
66
+ this.refusedCapMs = refusedCapMs;
67
+ this.random = random;
68
+ this.now = now;
69
+ this.log = log;
70
+ this.blocked = null;
71
+ this.refusals = 0;
72
+ this.retryTimer = null;
73
+ this.controller = null;
74
+ this.pendingSources = new Set();
75
+ this.pendingFull = false;
76
+ this.debounceTimer = null;
77
+ this.reconcileTimer = null;
78
+ this.running = null;
79
+ this.windowsPass = null;
80
+ this.listening = null;
81
+ this.connected = false;
82
+ }
83
+
84
+ start() {
85
+ if (this.controller) return;
86
+ this.controller = new AbortController();
87
+ this.#requestFull();
88
+ this.listening = this.#listen(this.controller.signal);
89
+ }
90
+
91
+ async stop() {
92
+ if (!this.controller) return;
93
+ this.controller.abort();
94
+ this.fullController?.abort();
95
+ clearTimeout(this.reconcileTimer);
96
+ this.reconcileTimer = null;
97
+ clearTimeout(this.debounceTimer);
98
+ clearTimeout(this.retryTimer);
99
+ clearTimeout(this.windowsTimer);
100
+ this.debounceTimer = null;
101
+ this.retryTimer = null;
102
+ this.windowsTimer = null;
103
+ this.pendingFull = false;
104
+ this.pendingSources.clear();
105
+ await Promise.allSettled([this.listening, this.running, this.windowsPass]);
106
+ this.controller = null;
107
+ }
108
+
109
+ onChange(kinds) {
110
+ if (this.blocked) return;
111
+ // A `hub` event during one of our graph reads or within echoMs after it may be that read's echo. Once a windows
112
+ // pass found nothing new, such an event only refreshes the windows after echoGapMs, so an idle hub is not re-read
113
+ // in a loop. While windows passes keep finding changes the hub is busy, and it refreshes at the normal gap.
114
+ const ours = this.reader.graphReadRecently?.(this.echoMs) ?? false;
115
+ const echo = !this.windowsChanged && ours;
116
+ const news = kinds.filter((kind) => kind !== 'presence');
117
+ if (news.some((kind) => CHANGE_SOURCES[kind]) && !(ours && news.every((kind) => kind === 'hub'))) this.#preemptFull();
118
+ for (const kind of kinds) {
119
+ for (const source of CHANGE_SOURCES[kind] || []) {
120
+ if (source !== 'windows') this.pendingSources.add(source);
121
+ else if (kind === 'presence') this.#requestWindows(Math.max(this.windowsGapMs, this.presenceGapMs), { preempt: false });
122
+ else this.#requestWindows(kind === 'hub' && echo ? Math.max(this.windowsGapMs, this.echoGapMs) : this.windowsGapMs);
123
+ }
124
+ }
125
+ if (!this.pendingSources.size || this.debounceTimer) return;
126
+ this.debounceTimer = setTimeout(() => {
127
+ this.debounceTimer = null;
128
+ this.#drain();
129
+ }, this.debounceMs);
130
+ this.debounceTimer.unref?.();
131
+ }
132
+
133
+ #requestFull() {
134
+ this.pendingFull = true;
135
+ this.#drain();
136
+ }
137
+
138
+ /**
139
+ * A change never waits behind a full pass: one that is running stops at its next read or batch boundary and runs
140
+ * again after the change passes. Once the last complete full pass is maxFullDelayMs old, it runs to the end, so
141
+ * constant change cannot starve the reconcile.
142
+ */
143
+ #fullPreemptible() {
144
+ return this.now() - this.lastFullCompletedAt < this.maxFullDelayMs;
145
+ }
146
+
147
+ #preemptFull() {
148
+ if (this.fullController && !this.fullController.signal.aborted && this.#fullPreemptible()) this.fullController.abort();
149
+ }
150
+
151
+ /**
152
+ * The four graph windows are the hub's slowest read, so change events refresh them at most once per gapMs (normally
153
+ * windowsGapMs), counted from the end of the last windows pass so the hub is never asked for them back to back.
154
+ * A request with a shorter gap replaces a pending one with a longer gap.
155
+ */
156
+ #requestWindows(gapMs = this.windowsGapMs, { preempt = true } = {}) {
157
+ if (this.windowsTimer && this.windowsTimerGapMs <= gapMs) return;
158
+ clearTimeout(this.windowsTimer);
159
+ this.windowsTimerGapMs = gapMs;
160
+ const waitMs = Math.max(this.debounceMs, this.lastWindowsAt + gapMs - this.now());
161
+ this.windowsTimer = setTimeout(() => {
162
+ this.windowsTimer = null;
163
+ if (this.windowsRunning) {
164
+ this.windowsDeferredGapMs = Math.min(this.windowsDeferredGapMs ?? Infinity, gapMs);
165
+ if (preempt) this.#preemptFull();
166
+ return;
167
+ }
168
+ if (this.now() < this.lastWindowsAt + gapMs) {
169
+ this.#requestWindows(gapMs, { preempt });
170
+ return;
171
+ }
172
+ this.pendingSources.add('windows');
173
+ this.#drain();
174
+ }, waitMs);
175
+ this.windowsTimer.unref?.();
176
+ }
177
+
178
+ /**
179
+ * Two lanes. Windows refreshes (the hub's slowest read; edges and bus ride on their 168 h read) run one at a time in a
180
+ * lane of their own, so a channels, deliveries or inbox change never waits behind one: it runs at once in the change
181
+ * lane, beside it. The lanes write disjoint item families. Full passes run in the change lane and read the windows
182
+ * too, so a full pass and a windows pass never run at once.
183
+ */
184
+ #drain({ retry = false } = {}) {
185
+ if (!this.controller || this.controller.signal.aborted) return;
186
+ if (this.blocked && !retry) {
187
+ this.pendingFull = false;
188
+ this.pendingSources.clear();
189
+ return;
190
+ }
191
+ if (this.running) {
192
+ this.#drainWindows();
193
+ return;
194
+ }
195
+ const changes = new Set([...this.pendingSources].filter((source) => source !== 'windows'));
196
+ // Pending changes go ahead of a pending full pass, unless the reconcile is overdue (see #fullPreemptible).
197
+ if (this.pendingFull && !(changes.size && this.#fullPreemptible())) {
198
+ if (this.windowsPass) return;
199
+ this.pendingFull = false;
200
+ this.pendingSources.clear();
201
+ this.#startChanges({ full: true, sources: changes });
202
+ return;
203
+ }
204
+ this.#drainWindows();
205
+ if (!changes.size) return;
206
+ for (const source of changes) this.pendingSources.delete(source);
207
+ this.#startChanges({ full: false, sources: changes });
208
+ }
209
+
210
+ // Between slow refreshes only the recent windows are read; the 72 h and 168 h ones, edges and bus wait for the next.
211
+ #drainWindows() {
212
+ if (this.windowsPass || this.fullController || !this.pendingSources.has('windows')) return;
213
+ this.pendingSources.delete('windows');
214
+ const slow = this.now() >= this.lastSlowWindowsAt + this.slowWindowsGapMs;
215
+ const sources = new Set(slow ? ['graph', 'windows'] : ['recent']);
216
+ this.windowsRunning = true;
217
+ this.windowsPass = this.#pass({ full: false, sources, lane: 'windows' }).finally(() => {
218
+ this.windowsPass = null;
219
+ this.lastWindowsAt = this.now();
220
+ this.windowsRunning = false;
221
+ if (slow) {
222
+ this.lastSlowWindowsAt = this.lastWindowsAt;
223
+ } else if (this.controller && !this.controller.signal.aborted) {
224
+ // A change the long windows have not seen yet reaches them once their gap is up, change events or not.
225
+ this.#requestWindows(Math.max(this.windowsGapMs, this.lastSlowWindowsAt + this.slowWindowsGapMs - this.lastWindowsAt));
226
+ }
227
+ // The briefs of the tasks this pass saw move follow in a pass of their own, after the windows are up.
228
+ this.pendingSources.add('briefs');
229
+ this.#requestDeferredWindows();
230
+ if (this.debounceTimer === null) this.#drain();
231
+ });
232
+ }
233
+
234
+ #startChanges({ full, sources }) {
235
+ this.fullController = full ? new AbortController() : null;
236
+ if (full) this.windowsRunning = true;
237
+ this.running = this.#pass({ full, sources, lane: 'changes', signal: this.fullController?.signal }).then((preempted) => {
238
+ this.running = null;
239
+ this.fullController = null;
240
+ if (!full) {
241
+ if (this.debounceTimer === null) this.#drain();
242
+ return;
243
+ }
244
+ this.windowsRunning = false;
245
+ if (preempted) {
246
+ // The windows it read never reached the cloud: a windows refresh owed meanwhile runs at once.
247
+ if (this.windowsDeferredGapMs !== null || (this.windowsTimer && this.windowsTimerGapMs <= this.windowsGapMs)) {
248
+ clearTimeout(this.windowsTimer);
249
+ this.windowsTimer = null;
250
+ this.windowsDeferredGapMs = null;
251
+ this.pendingSources.add('windows');
252
+ }
253
+ this.#drain();
254
+ return;
255
+ }
256
+ // The next reconcile counts from the end of this one, so a slow hub never gets full passes back to back.
257
+ if (this.controller && !this.controller.signal.aborted) {
258
+ clearTimeout(this.reconcileTimer);
259
+ this.reconcileTimer = setTimeout(() => this.#requestFull(), this.reconcileMs);
260
+ this.reconcileTimer.unref?.();
261
+ }
262
+ this.lastWindowsAt = this.now();
263
+ this.lastSlowWindowsAt = this.lastWindowsAt;
264
+ this.#requestDeferredWindows();
265
+ if (this.debounceTimer === null) this.#drain();
266
+ });
267
+ }
268
+
269
+ #requestDeferredWindows() {
270
+ if (this.windowsDeferredGapMs === null) return;
271
+ const gapMs = this.windowsDeferredGapMs;
272
+ this.windowsDeferredGapMs = null;
273
+ this.#requestWindows(gapMs);
274
+ }
275
+
276
+ /** One mirror pass; resolves to whether a change preempted it, and never rejects. */
277
+ #pass({ full, sources, lane, signal }) {
278
+ const pass = this.onPassStart({ lane });
279
+ let preempted = false;
280
+ return this.sync.run(full ? { full, sources, signal } : { full, sources })
281
+ .then((result) => {
282
+ this.blocked = null;
283
+ this.refusals = 0;
284
+ if (full) this.lastFullCompletedAt = this.now();
285
+ if (result.briefsPending) this.pendingSources.add('briefs');
286
+ if (lane === 'windows') this.windowsChanged = result.pushed + result.tombstoned > 0;
287
+ this.log('mirror', { full, pushed: result.pushed, tombstoned: result.tombstoned, rejected: result.rejected.length });
288
+ })
289
+ .catch((error) => {
290
+ const code = error.code || 'HUB_MIRROR_FAILED';
291
+ if (code === 'HUB_MIRROR_PREEMPTED') {
292
+ preempted = true;
293
+ this.pendingFull = true;
294
+ this.log('mirror_preempted', { full });
295
+ return undefined;
296
+ }
297
+ if (isMirrorRefusal(code)) return this.#block(error, code);
298
+ this.blocked = null;
299
+ this.log('mirror_failed', { full, code });
300
+ })
301
+ .then(() => {
302
+ this.onPassEnd(pass);
303
+ return preempted;
304
+ });
305
+ }
306
+
307
+ // A refused push never succeeds by repeating it, so change events and
308
+ // reconciles are ignored until one full retry after a capped backoff.
309
+ async #block(error, code) {
310
+ const baseMs = Math.min(this.refusedCapMs, this.refusedBaseMs * 2 ** this.refusals);
311
+ const waitMs = Math.round(baseMs * (1 + this.random() * 0.1));
312
+ this.refusals += 1;
313
+ this.blocked = { code, retryAt: this.now() + waitMs };
314
+ this.log('mirror_blocked', { code, retryInMs: waitMs });
315
+ await this.sync.recordError?.(error, { retryAt: this.blocked.retryAt }).catch(() => {});
316
+ if (!this.controller || this.controller.signal.aborted) return;
317
+ clearTimeout(this.retryTimer);
318
+ this.retryTimer = setTimeout(() => {
319
+ this.retryTimer = null;
320
+ this.pendingFull = true;
321
+ this.#drain({ retry: true });
322
+ }, waitMs);
323
+ this.retryTimer.unref?.();
324
+ }
325
+
326
+ async #listen(signal) {
327
+ let attempt = 0;
328
+ let openedBefore = false;
329
+ while (!signal.aborted) {
330
+ try {
331
+ await this.reader.subscribe({
332
+ signal,
333
+ onOpen: () => {
334
+ attempt = 0;
335
+ this.connected = true;
336
+ if (openedBefore) this.#requestFull();
337
+ openedBefore = true;
338
+ },
339
+ onChange: (kinds) => this.onChange(kinds),
340
+ });
341
+ } catch (error) {
342
+ if (signal.aborted) break;
343
+ this.log('mirror_stream_failed', { code: error.code || error.name || 'HUB_LOCAL_HUB_STREAM_FAILED' });
344
+ }
345
+ this.connected = false;
346
+ if (signal.aborted) break;
347
+ const wait = fullJitterDelay(attempt, { baseMs: this.reconnectBaseMs, capMs: this.reconnectCapMs, random: this.random });
348
+ attempt += 1;
349
+ await new Promise((resolve) => {
350
+ const timer = setTimeout(resolve, wait);
351
+ signal.addEventListener('abort', () => {
352
+ clearTimeout(timer);
353
+ resolve();
354
+ }, { once: true });
355
+ });
356
+ }
357
+ }
358
+ }