@agentpulselabs/cli-plugin-pulse 0.2.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 (69) hide show
  1. package/README.md +69 -32
  2. package/package.json +10 -18
  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 +6 -18
  11. package/src/commands/pulse/status.js +6 -6
  12. package/src/commands/pulse/stop.js +24 -0
  13. package/src/daemon/main.js +36 -0
  14. package/src/lib/BaseCommand.js +23 -23
  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/LICENSE +0 -21
  65. package/src/commands/pulse/gate.js +0 -25
  66. package/src/commands/pulse/next.js +0 -14
  67. package/src/commands/pulse/submit.js +0 -21
  68. package/src/commands/pulse/verify.js +0 -21
  69. package/src/runPulse.js +0 -58
@@ -0,0 +1,656 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { lstat } from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { setTimeout as delay } from 'node:timers/promises';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { readCliDefaults } from './cliDefaults.js';
8
+ import { CloudAuth } from './cloudAuth.js';
9
+ import { createClaudeDriver, createCursorDriver } from './drivers.js';
10
+ import { HubError } from './errors.js';
11
+ import { fetchJson } from './http.js';
12
+ import { loadIdentity, loadOrCreateIdentity } from './identity.js';
13
+ import { MIRROR_SCOPE } from './mirrorContract.js';
14
+ import { localScopeCheckApplies, MIRROR_STATE_FILE, mirrorEnabled } from './mirrorDaemon.js';
15
+ import { MirrorSync, requestResend } from './mirrorSync.js';
16
+ import { loginWithBrowser } from './oauthLogin.js';
17
+ import { assertPairedConfig, pairedStatus } from './pairedConfig.js';
18
+ import { pairConnector } from './pairingLifecycle.js';
19
+ import { ProcessSupervisor } from './processSupervisor.js';
20
+ import { defaultSecretStore } from './secretStore.js';
21
+ import { consumeRestartRequest, RESTART_REQUEST_FILE } from './selfRestart.js';
22
+ import { ServiceManager } from './serviceManager.js';
23
+ import {
24
+ ensureOwnerOnlyDirectory,
25
+ readOwnerOnlyJson,
26
+ writeOwnerOnlyJson,
27
+ } from './storage.js';
28
+ import { TokenStore } from './tokenStore.js';
29
+ import { USER_API_BASE } from './transportContract.js';
30
+
31
+ const CONFIG_FILE = 'config.json';
32
+ const HUB_WRITE = 'hub.write';
33
+ // The daemon publishes its first heartbeat after its first cloud cycle, which includes a reconnect and a claim poll.
34
+ const START_HEALTH_POLL_MS = 250;
35
+ const RESTART_POLL_MS = 500;
36
+ const DAEMON_PATH = fileURLToPath(new URL('../daemon/main.js', import.meta.url));
37
+ const SIGN_IN_HINT = 'Run `agentpulse pulse login`, then `agentpulse pulse start`.';
38
+ const STALE_SERVICE_MESSAGE = 'The Pulse connector service points at an old daemon path.';
39
+ const RECONCILE_STALE_MS = 5 * 60_000;
40
+ const DEFAULT_PAIRING_POLICY = Object.freeze({
41
+ capabilities: Object.freeze(['workspace.read', 'git.read']),
42
+ providerPolicy: Object.freeze({
43
+ providers: Object.freeze({}),
44
+ maxConcurrency: 1,
45
+ maxTokensPerRun: 1,
46
+ maxCostUsd: 0,
47
+ }),
48
+ });
49
+
50
+ export function defaultConfigDirectory(home = os.homedir()) {
51
+ return path.join(
52
+ process.env.XDG_CONFIG_HOME || path.join(home, '.config'),
53
+ 'agentpulse',
54
+ 'hub'
55
+ );
56
+ }
57
+
58
+ function baseConfig(identity) {
59
+ return {
60
+ schemaVersion: 1,
61
+ protocolVersion: 1,
62
+ installedAt: new Date().toISOString(),
63
+ identityFingerprint: identity.fingerprint,
64
+ paired: null,
65
+ };
66
+ }
67
+
68
+ async function openInBrowser(url) {
69
+ const opener = process.platform === 'darwin' ? 'open' : 'xdg-open';
70
+ spawn(opener, [url], { stdio: 'ignore', detached: true }).on('error', () => {}).unref();
71
+ }
72
+
73
+ const BUSY_STATES = new Set(['running', 'idle_unpaired', 'starting']);
74
+
75
+ /** What the daemon's last heartbeat says beyond "running": not taking work, jobs being recovered, settlements given up. */
76
+ function healthSummary(health) {
77
+ if (!health) return '';
78
+ let summary = '';
79
+ if (health.state && !BUSY_STATES.has(health.state)) {
80
+ summary += ` It is not taking work: ${health.state}${health.code ? ` (${health.code})` : ''}.`;
81
+ if (health.message) summary += ` ${health.message}`;
82
+ } else if (health.state === 'starting') {
83
+ summary += ' It is still connecting to the cloud.';
84
+ }
85
+ const loop = health.degraded?.code === 'restart_loop' ? health.degraded : null;
86
+ if (loop) {
87
+ summary += ` It restarted ${loop.restarts} times in 5 minutes, so it holds further restarts until ${loop.clearsAt} (restart_loop)`;
88
+ summary += loop.pending ? `; a ${loop.pending} restart is waiting.` : '.';
89
+ }
90
+ for (const job of health.recovering || []) {
91
+ summary += ` Recovering job ${job.jobId} (${job.code}, ${job.failures} failed ${job.failures === 1 ? 'try' : 'tries'}).`;
92
+ }
93
+ for (const job of health.settleFailed || []) {
94
+ summary += ` Gave up settling job ${job.jobId} (${job.code}); the cloud will expire it as connector_no_result.`;
95
+ }
96
+ return summary;
97
+ }
98
+
99
+ export class ConnectorApplication {
100
+ constructor({
101
+ configDir = defaultConfigDirectory(),
102
+ secretStore = defaultSecretStore(),
103
+ serviceManager = new ServiceManager({ daemonPath: DAEMON_PATH, configDir }),
104
+ pairingPolicy = DEFAULT_PAIRING_POLICY,
105
+ providerProbes = null,
106
+ fetch = globalThis.fetch,
107
+ login = null,
108
+ cliDefaults = readCliDefaults,
109
+ now = Date.now,
110
+ log = (line) => process.stderr.write(`${line}\n`),
111
+ startHealthTimeoutMs = 10_000,
112
+ restartTimeoutMs = 60_000,
113
+ } = {}) {
114
+ this.startHealthTimeoutMs = startHealthTimeoutMs;
115
+ this.restartTimeoutMs = restartTimeoutMs;
116
+ this.configDir = configDir;
117
+ this.secretStore = secretStore;
118
+ this.serviceManager = serviceManager;
119
+ this.pairingPolicy = structuredClone(pairingPolicy);
120
+ this.providerProbes = providerProbes || this.#defaultProviderProbes.bind(this);
121
+ this.fetch = fetch;
122
+ this.browserLogin = login || ((options) => loginWithBrowser({
123
+ ...options,
124
+ fetch,
125
+ now,
126
+ openBrowser: openInBrowser,
127
+ onAuthUrl: (url) => log(`Opening your browser to sign in. If it does not open, visit:\n ${url}`),
128
+ }));
129
+ this.cliDefaults = cliDefaults;
130
+ this.now = now;
131
+ this.tokenStore = new TokenStore({ secretStore, configDir });
132
+ this.configFile = path.join(configDir, CONFIG_FILE);
133
+ }
134
+
135
+ async #defaultProviderProbes() {
136
+ const supervisor = new ProcessSupervisor();
137
+ const cursor = createCursorDriver({ supervisor });
138
+ const claude = createClaudeDriver({ supervisor });
139
+ const [cursorStatus, claudeStatus] = await Promise.all([cursor.probe(), claude.probe()]);
140
+ return { cursor: cursorStatus, claude: claudeStatus };
141
+ }
142
+
143
+ async #config(required = true) {
144
+ const config = await readOwnerOnlyJson(this.configFile);
145
+ if (!config && required) {
146
+ throw new HubError('HUB_NOT_INSTALLED', 'Pulse connector is not installed. Run agentpulse pulse install.');
147
+ }
148
+ return config;
149
+ }
150
+
151
+ async #authStatus() {
152
+ try {
153
+ const session = await this.tokenStore.load();
154
+ if (!session) return { ok: false, code: 'HUB_LOGIN_REQUIRED', hint: SIGN_IN_HINT };
155
+ const expired = Date.parse(session.expiresAt || '') <= this.now();
156
+ return {
157
+ ok: !expired || Boolean(session.refreshToken),
158
+ userId: session.userId,
159
+ expiresAt: session.expiresAt,
160
+ ...(expired ? { code: 'HUB_ACCESS_TOKEN_EXPIRED', hint: SIGN_IN_HINT } : {}),
161
+ };
162
+ } catch (error) {
163
+ return { ok: false, code: error.code || 'HUB_TOKEN_STORE_FAILED', hint: SIGN_IN_HINT };
164
+ }
165
+ }
166
+
167
+ async #servicePath() {
168
+ if (typeof this.serviceManager.daemonPathCheck !== 'function') return { ok: true };
169
+ return this.serviceManager.daemonPathCheck();
170
+ }
171
+
172
+ async install() {
173
+ await ensureOwnerOnlyDirectory(this.configDir);
174
+ const identity = await loadOrCreateIdentity({
175
+ secretStore: this.secretStore,
176
+ configDir: this.configDir,
177
+ });
178
+ const existing = await this.#config(false);
179
+ if (existing && existing.identityFingerprint !== identity.fingerprint) {
180
+ throw new HubError(
181
+ 'HUB_IDENTITY_MISMATCH',
182
+ 'Stored connector configuration does not match the local device identity'
183
+ );
184
+ }
185
+ await writeOwnerOnlyJson(this.configFile, existing || baseConfig(identity));
186
+ const service = await this.serviceManager.install();
187
+ return {
188
+ command: 'install',
189
+ state: 'installed',
190
+ serviceFile: service.serviceFile,
191
+ identityFingerprint: identity.fingerprint,
192
+ message: existing && pairedStatus(existing).ok
193
+ ? 'Pulse connector installed. This machine is already paired; run `agentpulse pulse start`.'
194
+ : 'Pulse connector installed. Pair this machine before starting the daemon.',
195
+ };
196
+ }
197
+
198
+ async pair({ org, platformUrl, displayName = os.hostname(), roots = [] } = {}) {
199
+ const config = await this.#config();
200
+ return pairConnector({
201
+ config,
202
+ configDir: this.configDir,
203
+ configFile: this.configFile,
204
+ tokenStore: this.tokenStore,
205
+ fetch: this.fetch,
206
+ login: this.browserLogin,
207
+ cliDefaults: this.cliDefaults,
208
+ now: this.now,
209
+ pairingPolicy: this.pairingPolicy,
210
+ platformUrl,
211
+ org,
212
+ displayName,
213
+ roots,
214
+ });
215
+ }
216
+
217
+ async login({ platformUrl } = {}) {
218
+ const config = await this.#config();
219
+ const paired = config.paired ? assertPairedConfig(config) : null;
220
+ const target = platformUrl || paired?.target.platformUrl || (await this.cliDefaults()).platformUrl;
221
+ if (!target) throw new HubError('HUB_PLATFORM_REQUIRED', 'Pass --platform <url> or run `agentpulse login` first');
222
+ const session = await this.browserLogin({ platformUrl: target });
223
+ if (paired && (session.userId !== paired.userId || session.teamId !== paired.teamId)) {
224
+ throw new HubError(
225
+ 'HUB_LOGIN_IDENTITY_MISMATCH',
226
+ 'This machine was paired by a different user or team; sign in as that user or revoke and pair again',
227
+ );
228
+ }
229
+ await this.tokenStore.save(session);
230
+ return {
231
+ command: 'login',
232
+ state: 'signed_in',
233
+ expiresAt: session.expiresAt,
234
+ message: paired ? 'Signed in. Run `agentpulse pulse start` to resume the connector.' : 'Signed in.',
235
+ };
236
+ }
237
+
238
+ async start() {
239
+ const config = await this.#config();
240
+ const servicePath = await this.#servicePath();
241
+ if (!servicePath.ok) throw new HubError(servicePath.code, `${STALE_SERVICE_MESSAGE} ${servicePath.hint}`);
242
+ assertPairedConfig(config);
243
+ const auth = await this.#authStatus();
244
+ if (!auth.ok) throw new HubError('HUB_LOGIN_REQUIRED', `Pulse connector is not signed in. ${SIGN_IN_HINT}`);
245
+ const requestedAt = Date.now();
246
+ const service = await this.serviceManager.start();
247
+ let daemonState = null;
248
+ if (typeof this.serviceManager.health === 'function') {
249
+ let health = await this.serviceManager.health({ after: requestedAt });
250
+ while (!health.ok && Date.now() - requestedAt < this.startHealthTimeoutMs) {
251
+ await delay(START_HEALTH_POLL_MS);
252
+ health = await this.serviceManager.health({ after: requestedAt });
253
+ }
254
+ const reported = health?.health;
255
+ if (health?.ok && ['error', 'failed'].includes(reported?.state)) {
256
+ throw new HubError('HUB_DAEMON_UNHEALTHY', `Pulse daemon reported ${reported.code || reported.state}; see \`agentpulse pulse doctor\``);
257
+ }
258
+ if (health?.ok) {
259
+ daemonState = reported?.state || null;
260
+ } else if ((await this.serviceManager.status()).state === 'running') {
261
+ // No heartbeat since the start request, so the daemon is still booting into its first sync.
262
+ daemonState = 'first_sync';
263
+ } else {
264
+ throw new HubError('HUB_DAEMON_UNHEALTHY', 'Pulse daemon did not start within 10 seconds; see `agentpulse pulse doctor`');
265
+ }
266
+ }
267
+ let message = service.state === 'running' ? 'Pulse connector started.' : 'Pulse connector start requested.';
268
+ if (daemonState === 'starting') {
269
+ message = 'Pulse connector started and is connecting to the cloud; `agentpulse pulse status` shows when it is online.';
270
+ }
271
+ if (daemonState === 'first_sync') {
272
+ message = 'Pulse connector is starting; its first sync is still in progress. `agentpulse pulse status` shows when it is online.';
273
+ }
274
+ return {
275
+ command: 'start',
276
+ ...service,
277
+ ...(daemonState ? { daemonState } : {}),
278
+ message,
279
+ };
280
+ }
281
+
282
+ /**
283
+ * Asks the running daemon to restart itself by writing restart.request, then waits for a fresh heartbeat from a new
284
+ * pid. It never calls launchctl or systemctl, so a sandboxed caller can restart the daemon without approval.
285
+ */
286
+ async restart() {
287
+ await this.#config();
288
+ const healthFile = path.join(this.configDir, 'daemon-health.json');
289
+ const current = await this.serviceManager.health();
290
+ const before = current.health;
291
+ if (!current.ok) {
292
+ throw new HubError(
293
+ 'HUB_DAEMON_NOT_RUNNING',
294
+ before?.heartbeatAt
295
+ ? `Pulse daemon has not sent a heartbeat since ${before.heartbeatAt}, so it is not running or is stuck; nothing can restart it from here. Run \`agentpulse pulse start\`.`
296
+ : 'Pulse daemon has never sent a heartbeat, so it is not running. Run `agentpulse pulse start`.',
297
+ );
298
+ }
299
+ const requestedAt = Date.now();
300
+ await writeOwnerOnlyJson(path.join(this.configDir, RESTART_REQUEST_FILE), {
301
+ requestedAt: new Date(requestedAt).toISOString(),
302
+ pid: process.pid,
303
+ });
304
+ const deferred = (loop) => new HubError(
305
+ 'HUB_RESTART_DEFERRED',
306
+ `Pulse restart deferred until ${loop.clearsAt}: the daemon restarted ${loop.restarts} times in 5 minutes. The request stays queued until then.`,
307
+ );
308
+ if (before?.degraded?.code === 'restart_loop') throw deferred(before.degraded);
309
+ for (;;) {
310
+ const { ok, health } = await this.serviceManager.health({ after: requestedAt });
311
+ if (ok && health.pid !== before?.pid) {
312
+ return {
313
+ command: 'restart',
314
+ state: 'restarted',
315
+ pid: health.pid,
316
+ previousPid: before?.pid ?? null,
317
+ daemonState: health.state,
318
+ message: `Pulse connector restarted (pid ${before?.pid ?? 'none'} → ${health.pid}).`,
319
+ };
320
+ }
321
+ if (Date.now() - requestedAt >= this.restartTimeoutMs) break;
322
+ await delay(RESTART_POLL_MS);
323
+ }
324
+ const latest = await readOwnerOnlyJson(healthFile).catch(() => null);
325
+ if (latest?.degraded?.code === 'restart_loop') throw deferred(latest.degraded);
326
+ // The daemon took the request and is letting running jobs finish; the restart is under way, not lost.
327
+ if (latest?.restarting && latest.pid === before?.pid) {
328
+ const jobs = latest.restarting.draining || [];
329
+ return {
330
+ command: 'restart',
331
+ state: 'draining',
332
+ pid: latest.pid,
333
+ draining: jobs,
334
+ message: `Pulse restarts once ${jobs.length ? `job${jobs.length === 1 ? '' : 's'} ${jobs.join(', ')} finish${jobs.length === 1 ? 'es' : ''}` : 'its current cycle ends'}; \`agentpulse pulse status\` shows the new process when it is up.`,
335
+ };
336
+ }
337
+ await consumeRestartRequest(this.configDir).catch(() => {});
338
+ throw new HubError(
339
+ 'HUB_RESTART_TIMEOUT',
340
+ `Pulse daemon did not restart within ${Math.round(this.restartTimeoutMs / 1000)} seconds; it may not be running. See \`agentpulse pulse status\`.`,
341
+ );
342
+ }
343
+
344
+ async status() {
345
+ const config = await this.#config();
346
+ const service = await this.serviceManager.status();
347
+ const health = await readOwnerOnlyJson(path.join(this.configDir, 'daemon-health.json'));
348
+ const pairing = pairedStatus(config);
349
+ const auth = await this.#authStatus();
350
+ const servicePath = await this.#servicePath();
351
+ const needsSignIn = !auth.ok || health?.state === 'auth_required';
352
+ let message = `Pulse connector is ${service.state}${pairing.ok ? '' : ' and unpaired'}.`;
353
+ if (needsSignIn) message = `Pulse connector is ${service.state} and needs you to sign in again. ${SIGN_IN_HINT}`;
354
+ if (!servicePath.ok) message = `${STALE_SERVICE_MESSAGE} ${servicePath.hint}`;
355
+ if (!needsSignIn && servicePath.ok && service.state === 'running') message += healthSummary(health);
356
+ const jobError = health?.lastJobError;
357
+ if (jobError?.code) {
358
+ message += ` Last job error: ${jobError.code} on ${jobError.type || 'a job'}${jobError.at ? ` at ${jobError.at}` : ''} (job ${jobError.jobId}).`;
359
+ }
360
+ return {
361
+ command: 'status',
362
+ state: service.state,
363
+ pid: service.pid,
364
+ paired: pairing.ok,
365
+ machineId: pairing.ok ? pairing.machineId : null,
366
+ pairing,
367
+ auth,
368
+ servicePath,
369
+ health,
370
+ message,
371
+ };
372
+ }
373
+
374
+ async doctor() {
375
+ const checks = {};
376
+ const config = await this.#config(false);
377
+ try {
378
+ const directory = await lstat(this.configDir);
379
+ const configInfo = await lstat(this.configFile);
380
+ checks.storage = {
381
+ ok:
382
+ directory.isDirectory() &&
383
+ (directory.mode & 0o077) === 0 &&
384
+ configInfo.isFile() &&
385
+ (configInfo.mode & 0o077) === 0,
386
+ };
387
+ } catch (error) {
388
+ checks.storage = { ok: false, code: error.code };
389
+ }
390
+ try {
391
+ const identity = await loadIdentity({ secretStore: this.secretStore, configDir: this.configDir });
392
+ checks.identity = { ok: true, fingerprint: identity.fingerprint };
393
+ } catch (error) {
394
+ checks.identity = { ok: false, code: error.code || 'HUB_IDENTITY_FAILED' };
395
+ }
396
+ const service = await this.serviceManager.status();
397
+ checks.service = { ok: ['running', 'loaded', 'stopped', 'inactive'].includes(service.state), ...service };
398
+ checks.servicePath = await this.#servicePath();
399
+ checks.providers = await this.providerProbes();
400
+ checks.pairing = pairedStatus(config);
401
+ checks.auth = config ? await this.#authStatus() : { ok: false, code: 'HUB_NOT_INSTALLED' };
402
+ const ok =
403
+ checks.storage.ok &&
404
+ checks.identity.ok &&
405
+ checks.service.ok &&
406
+ checks.servicePath.ok &&
407
+ checks.pairing.ok &&
408
+ checks.auth.ok;
409
+ return {
410
+ command: 'doctor',
411
+ state: ok ? 'healthy' : 'unhealthy',
412
+ ok,
413
+ checks,
414
+ message: ok
415
+ ? 'Pulse local installation, pairing and sign-in are healthy.'
416
+ : 'Pulse local installation has failed checks.',
417
+ };
418
+ }
419
+
420
+ async mirror({ action }) {
421
+ const config = await this.#config();
422
+ const scopeGranted = localScopeCheckApplies(config)
423
+ ? (await this.tokenStore.scopes()).includes(MIRROR_SCOPE)
424
+ : null;
425
+ const loginHint = scopeGranted === false ? ' Your sign-in predates the mirror scope; run `agentpulse pulse login`.' : '';
426
+ switch (action) {
427
+ case 'on':
428
+ case 'off': {
429
+ const localHub = action === 'on';
430
+ await writeOwnerOnlyJson(this.configFile, { ...config, mirror: { ...config.mirror, localHub } });
431
+ return {
432
+ command: 'mirror',
433
+ enabled: localHub,
434
+ scopeGranted,
435
+ message: localHub
436
+ ? `Local Agent Hub mirroring is on; the daemon picks it up within a heartbeat.${loginHint}`
437
+ : 'Local Agent Hub mirroring is off. Items already mirrored stay in Pulse until they change or you remove them there.',
438
+ };
439
+ }
440
+ case 'status': {
441
+ const state = await new MirrorSync({ stateFile: path.join(this.configDir, MIRROR_STATE_FILE) }).status();
442
+ const enabled = mirrorEnabled(config);
443
+ const mirroredTotal = Object.values(state.mirrored).reduce((sum, count) => sum + count, 0);
444
+ let message = `Local Agent Hub mirroring is ${enabled ? 'on' : 'off'}.`;
445
+ if (enabled && !pairedStatus(config).ok) message += ' This machine is not paired.';
446
+ if (enabled) message += loginHint;
447
+ if (state.lastSyncAt) message += ` Last sync ${new Date(state.lastSyncAt).toISOString()}; ${mirroredTotal} items mirrored.`;
448
+ if (state.lastPass) {
449
+ const seconds = (ms) => `${(ms / 1000).toFixed(1)} s`;
450
+ const kilobytes = (bytes) => `${Math.round(bytes / 1024)} KB`;
451
+ const short = (value) => (value < 1000 ? `${Math.round(value)} ms` : seconds(value));
452
+ const { ms, collectMs, uploadMs, batches, bytes, sentBytes, slowestBatch } = state.lastPass;
453
+ const sent = Number.isFinite(sentBytes) && batches > 0 ? `, ${kilobytes(sentBytes)} sent for ${kilobytes(bytes)} of JSON` : '';
454
+ message += ` The last pass took ${seconds(ms)}`;
455
+ message += Number.isFinite(collectMs) ? ` (reading the hub ${seconds(collectMs)}, uploading ${batches} batch${batches === 1 ? '' : 'es'} ${seconds(uploadMs)}${sent}).` : '.';
456
+ if (slowestBatch) {
457
+ const parts = [];
458
+ if (slowestBatch.backoffMs > 0) parts.push(`${seconds(slowestBatch.backoffMs)} backing off (responses ${slowestBatch.statuses.join(', ')})`);
459
+ if (slowestBatch.laneWaitMs > 0) {
460
+ const held = (slowestBatch.heldBy || []).map((call) =>
461
+ `${call.path}, ${call.attempts} attempt${call.attempts === 1 ? '' : 's'}, ${call.done ? `${short(call.ms)}` : 'still running'}`);
462
+ const detail = [...held, ...(slowestBatch.laneCapped ? ['then sent alongside'] : [])].join('; ');
463
+ parts.push(`${seconds(slowestBatch.laneWaitMs)} behind priority calls${detail ? ` (${detail})` : ''}`);
464
+ }
465
+ if (slowestBatch.credentialsMs > 0) parts.push(`${seconds(slowestBatch.credentialsMs)} reading credentials`);
466
+ if (slowestBatch.serverMs !== null) {
467
+ parts.push(`${short(slowestBatch.serverMs)} on the server${slowestBatch.dbMs !== null ? ` (${short(slowestBatch.dbMs)} in the database)` : ''}`);
468
+ }
469
+ parts.push(`${seconds(slowestBatch.networkMs)} on the network${slowestBatch.serverMs === null ? ' and the server' : ''}`);
470
+ message += ` Its slowest batch, ${slowestBatch.items} items in ${seconds(slowestBatch.ms)}: ${parts.join(', ')}.`;
471
+ }
472
+ }
473
+ const dropped = Object.entries(state.dropped).flatMap(([kind, reasons]) =>
474
+ Object.entries(reasons).map(([reason, count]) => `${count} ${kind} (${reason})`));
475
+ if (dropped.length) message += ` Not mirrored: ${dropped.join(', ')}.`;
476
+ if (state.redactedTotal > 0) {
477
+ const last = state.redactedLastAt ? ` (the last at ${new Date(state.redactedLastAt).toISOString()})` : '';
478
+ message += ` Pulse redacted ${state.redactedTotal} secret${state.redactedTotal === 1 ? '' : 's'} from this machine's uploads${last}; check what this connector sends.`;
479
+ }
480
+ if (state.rejected) message += ` Pulse refused ${state.rejected} item${state.rejected === 1 ? '' : 's'}; \`agentpulse pulse mirror resend-rejected\` sends refused items again.`;
481
+ const graphRows = Object.entries(state.graphDropped || {}).flatMap(([window, sections]) => Object.entries(sections).flatMap(([section, reasons]) =>
482
+ Object.entries(reasons).map(([reason, count]) => `${count} ${section} in the ${window} window (${reason})`)));
483
+ if (graphRows.length) message += ` Graph rows not mirrored: ${graphRows.join(', ')}.`;
484
+ const absent = Object.entries(state.lastAbsent || {}).map(([read, count]) => `${count} ${read}`);
485
+ if (absent.length) message += ` Absent on the local hub at the last reconcile (not an error): ${absent.join(', ')}.`;
486
+ if (state.lastError) message += ` Last blocking error: ${state.lastError.code}.`;
487
+ const reconcileStale = enabled && Boolean(state.lastSyncAt) && (!state.lastCompleteAt || Date.now() - state.lastCompleteAt > RECONCILE_STALE_MS);
488
+ if (state.lastCompleteAt) message += ` Last complete reconcile ${new Date(state.lastCompleteAt).toISOString()}.`;
489
+ if (reconcileStale) {
490
+ message += state.lastCompleteAt
491
+ ? ' Warning: no complete reconcile for over 5 minutes, so local deletions are not reaching Pulse.'
492
+ : ' Warning: no complete reconcile yet, so local deletions are not reaching Pulse.';
493
+ }
494
+ if (state.lastError?.code === 'HUB_MIRROR_SCOPE_MISSING' && scopeGranted !== false) {
495
+ message += ' Pulse refused mirroring: your seat or plan does not include the mirror scope.';
496
+ }
497
+ const blocked = enabled && Boolean(state.lastError?.retryAt);
498
+ if (blocked) message += ` Mirroring is paused; the daemon retries at ${new Date(state.lastError.retryAt).toISOString()}.`;
499
+ return {
500
+ command: 'mirror',
501
+ enabled,
502
+ state: enabled ? (blocked ? 'blocked' : 'on') : 'off',
503
+ code: blocked ? state.lastError.code : null,
504
+ scopeGranted,
505
+ reconcileStale,
506
+ ...state,
507
+ message,
508
+ };
509
+ }
510
+ case 'resend-rejected': {
511
+ const { rejected } = await requestResend(this.configDir, { stateFile: path.join(this.configDir, MIRROR_STATE_FILE) });
512
+ return {
513
+ command: 'mirror',
514
+ rejected,
515
+ message: `${rejected} refused item${rejected === 1 ? ' is' : 's are'} sent again on the daemon's next full pass; each is sent once, and one Pulse refuses again stays listed in \`agentpulse pulse mirror status\`.`,
516
+ };
517
+ }
518
+ default:
519
+ throw new HubError('HUB_MIRROR_ACTION_INVALID', `Unknown mirror action: ${action}`);
520
+ }
521
+ }
522
+
523
+ async hubWrite({ action }) {
524
+ const config = await this.#config();
525
+ if (!config.paired) {
526
+ throw new HubError('HUB_NOT_PAIRED', 'This machine is not paired; run `agentpulse pulse pair` first.');
527
+ }
528
+ const paired = assertPairedConfig(config);
529
+ const auth = new CloudAuth({
530
+ tokenStore: this.tokenStore,
531
+ fetch: this.fetch,
532
+ now: this.now,
533
+ target: {
534
+ platformUrl: paired.target.platformUrl,
535
+ devOrg: paired.target.devOrg,
536
+ instanceUrl: paired.baseUrl,
537
+ userId: paired.userId,
538
+ teamId: paired.teamId,
539
+ },
540
+ });
541
+ const machineUrl = `${paired.baseUrl}${USER_API_BASE}/machines/${encodeURIComponent(paired.machineId)}`;
542
+ const read = await fetchJson({ fetch: this.fetch, method: 'GET', url: machineUrl, headers: await auth.credentials() });
543
+ if (!read.ok) throw new HubError(read.body.error || `HTTP_${read.status}`, read.body.message || 'Could not read this machine from Pulse');
544
+ const grant = read.body.machine?.active_grant;
545
+ if (!grant || grant.state !== 'active' || !Array.isArray(grant.capabilities)) {
546
+ throw new HubError('HUB_GRANT_MISSING', 'This machine has no active grant in Pulse; pair it again.');
547
+ }
548
+ const granted = grant.capabilities.includes(HUB_WRITE);
549
+ if (action === 'status') {
550
+ return {
551
+ command: 'hub-write',
552
+ enabled: granted,
553
+ policyVersion: grant.version,
554
+ message: granted
555
+ ? 'Pulse may post to the local Agent Hub as you (hub.write is granted).'
556
+ : 'Pulse cannot post to the local Agent Hub (hub.write is off). Turn it on with `agentpulse pulse hub-write on`.',
557
+ };
558
+ }
559
+ if (action !== 'on' && action !== 'off') {
560
+ throw new HubError('HUB_WRITE_ACTION_INVALID', `Unknown hub-write action: ${action}`);
561
+ }
562
+ const enable = action === 'on';
563
+ if (enable === granted) {
564
+ return {
565
+ command: 'hub-write',
566
+ enabled: granted,
567
+ policyVersion: grant.version,
568
+ changed: false,
569
+ message: `hub.write is already ${granted ? 'on' : 'off'}.`,
570
+ };
571
+ }
572
+ const capabilities = enable
573
+ ? [...grant.capabilities, HUB_WRITE]
574
+ : grant.capabilities.filter((capability) => capability !== HUB_WRITE);
575
+ const replaced = await fetchJson({
576
+ fetch: this.fetch,
577
+ method: 'PUT',
578
+ url: `${machineUrl}/grant`,
579
+ headers: await auth.credentials(),
580
+ body: {
581
+ roots: grant.roots.map(({ rootId, label, canonicalPathHash, displayPath, repoWrite }) =>
582
+ ({ rootId, label, canonicalPathHash, displayPath, repoWrite })),
583
+ capabilities,
584
+ providerPolicy: grant.providerPolicy,
585
+ },
586
+ });
587
+ if (!replaced.ok) {
588
+ throw new HubError(replaced.body.error || `HTTP_${replaced.status}`, replaced.body.message || 'Pulse refused the grant change');
589
+ }
590
+ const version = Number(replaced.body.grant?.version);
591
+ if (!Number.isInteger(version)) throw new HubError('HUB_GRANT_INVALID', 'Pulse returned a grant without a version');
592
+ await writeOwnerOnlyJson(this.configFile, {
593
+ ...config,
594
+ capabilities,
595
+ paired: { ...config.paired, policyVersion: version },
596
+ });
597
+ return {
598
+ command: 'hub-write',
599
+ enabled: enable,
600
+ policyVersion: version,
601
+ changed: true,
602
+ message: enable
603
+ ? 'hub.write is on: Pulse can post, nudge, decide and accept on the local Agent Hub as you. The daemon picks up the new grant within a heartbeat.'
604
+ : 'hub.write is off: Pulse can no longer post to the local Agent Hub. Actions still queued for this machine expire.',
605
+ };
606
+ }
607
+
608
+ async stop({ revoke = false } = {}) {
609
+ const service = await this.serviceManager.stop();
610
+ let revoked = false;
611
+ if (revoke) {
612
+ const config = await this.#config();
613
+ if (config.paired) {
614
+ const paired = assertPairedConfig(config);
615
+ await writeOwnerOnlyJson(this.configFile, { ...config, paired: { ...config.paired, state: 'revoking' } });
616
+ const auth = new CloudAuth({
617
+ tokenStore: this.tokenStore,
618
+ fetch: this.fetch,
619
+ now: this.now,
620
+ target: {
621
+ platformUrl: paired.target.platformUrl,
622
+ devOrg: paired.target.devOrg,
623
+ instanceUrl: paired.baseUrl,
624
+ userId: paired.userId,
625
+ teamId: paired.teamId,
626
+ },
627
+ });
628
+ const response = await fetchJson({
629
+ fetch: this.fetch,
630
+ url: `${paired.baseUrl}${USER_API_BASE}/machines/${encodeURIComponent(paired.machineId)}/revoke`,
631
+ headers: await auth.credentials(),
632
+ body: { reason: 'Revoked from the connector' },
633
+ });
634
+ if (!response.ok && response.status !== 404) {
635
+ await writeOwnerOnlyJson(this.configFile, config);
636
+ throw new HubError(response.body.error || `HTTP_${response.status}`, response.body.message || 'Machine revocation failed');
637
+ }
638
+ await writeOwnerOnlyJson(this.configFile, { ...config, paired: null });
639
+ revoked = true;
640
+ }
641
+ }
642
+ return {
643
+ command: 'stop',
644
+ ...service,
645
+ revoked,
646
+ message: revoked ? 'Pulse connector stopped and machine revoked.' : 'Pulse connector stopped.',
647
+ };
648
+ }
649
+ }
650
+
651
+ let defaultApplication;
652
+
653
+ export function getDefaultApplication() {
654
+ defaultApplication ||= new ConnectorApplication();
655
+ return defaultApplication;
656
+ }
@@ -0,0 +1,8 @@
1
+ export function fullJitterDelay(
2
+ attempt,
3
+ { baseMs = 1_000, capMs = 60_000, random = Math.random } = {}
4
+ ) {
5
+ const exponent = Math.max(0, Math.min(30, Number(attempt) || 0));
6
+ const ceiling = Math.min(capMs, baseMs * 2 ** exponent);
7
+ return Math.floor(Math.max(0, Math.min(1, random())) * ceiling);
8
+ }