@agentpulselabs/cli-plugin-pulse 0.2.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 (151) hide show
  1. package/README.md +115 -32
  2. package/package.json +14 -19
  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 +15 -0
  7. package/src/commands/pulse/hub-write.js +26 -0
  8. package/src/commands/pulse/import.js +62 -0
  9. package/src/commands/pulse/install.js +14 -0
  10. package/src/commands/pulse/log-write.js +26 -0
  11. package/src/commands/pulse/login.js +20 -0
  12. package/src/commands/pulse/mirror.js +27 -0
  13. package/src/commands/pulse/pair.js +38 -0
  14. package/src/commands/pulse/parity.js +20 -0
  15. package/src/commands/pulse/restart.js +14 -0
  16. package/src/commands/pulse/shadow.js +19 -0
  17. package/src/commands/pulse/start.js +6 -18
  18. package/src/commands/pulse/status.js +6 -6
  19. package/src/commands/pulse/stop.js +24 -0
  20. package/src/daemon/main.js +43 -0
  21. package/src/hub/core/auth.mjs +80 -0
  22. package/src/hub/core/changes.mjs +38 -0
  23. package/src/hub/core/conversations.mjs +121 -0
  24. package/src/hub/core/db.mjs +263 -0
  25. package/src/hub/core/edgeIds.mjs +9 -0
  26. package/src/hub/core/hub.mjs +20 -0
  27. package/src/hub/core/ids.mjs +39 -0
  28. package/src/hub/core/leases.mjs +55 -0
  29. package/src/hub/core/messaging.mjs +322 -0
  30. package/src/hub/core/registry.mjs +281 -0
  31. package/src/hub/core/ticks.mjs +58 -0
  32. package/src/hub/core/wake.mjs +125 -0
  33. package/src/hub/home.mjs +16 -0
  34. package/src/hub/hooks/hub-bridge.mjs +167 -0
  35. package/src/hub/keepawake/agent.mjs +96 -0
  36. package/src/hub/lib/brief.mjs +133 -0
  37. package/src/hub/lib/channels.mjs +268 -0
  38. package/src/hub/lib/chat.mjs +163 -0
  39. package/src/hub/lib/closure.mjs +92 -0
  40. package/src/hub/lib/collect-worker.mjs +20 -0
  41. package/src/hub/lib/collect.mjs +342 -0
  42. package/src/hub/lib/deliveries.mjs +222 -0
  43. package/src/hub/lib/git.mjs +93 -0
  44. package/src/hub/lib/me.mjs +123 -0
  45. package/src/hub/lib/personas.mjs +143 -0
  46. package/src/hub/lib/status.mjs +147 -0
  47. package/src/hub/lib/store.mjs +106 -0
  48. package/src/hub/lib/titles.mjs +29 -0
  49. package/src/hub/lib/transcript-feed.mjs +72 -0
  50. package/src/hub/lib/transcripts.mjs +364 -0
  51. package/src/hub/lib/writing.mjs +18 -0
  52. package/src/hub/projections/deliveries.mjs +31 -0
  53. package/src/hub/projections/graph.mjs +149 -0
  54. package/src/hub/runners/drivers.mjs +162 -0
  55. package/src/hub/workers/collector.mjs +128 -0
  56. package/src/hub/workers/manager.mjs +366 -0
  57. package/src/hub/workers/runner.mjs +145 -0
  58. package/src/lib/BaseCommand.js +23 -23
  59. package/src/lib/agentSessions.js +279 -0
  60. package/src/lib/application.js +704 -0
  61. package/src/lib/backoff.js +8 -0
  62. package/src/lib/budgets.js +85 -0
  63. package/src/lib/channelScanner.js +99 -0
  64. package/src/lib/cliDefaults.js +20 -0
  65. package/src/lib/clientInstaller.js +111 -0
  66. package/src/lib/cloudAuth.js +189 -0
  67. package/src/lib/cloudFirstCommands.js +293 -0
  68. package/src/lib/contract.sha256 +2 -0
  69. package/src/lib/cutover.js +376 -0
  70. package/src/lib/cutoverWiring.js +137 -0
  71. package/src/lib/daemonComposition.js +338 -0
  72. package/src/lib/daemonLock.js +57 -0
  73. package/src/lib/daemonLoop.js +72 -0
  74. package/src/lib/daemonProcess.js +380 -0
  75. package/src/lib/daemonRuntime.js +828 -0
  76. package/src/lib/doctorWarnings.js +53 -0
  77. package/src/lib/drivers.js +280 -0
  78. package/src/lib/errors.js +33 -0
  79. package/src/lib/graphWindows.js +149 -0
  80. package/src/lib/http.js +84 -0
  81. package/src/lib/hubActions.js +74 -0
  82. package/src/lib/hubDbReader.js +132 -0
  83. package/src/lib/hubMapping.js +108 -0
  84. package/src/lib/hubScan.js +611 -0
  85. package/src/lib/hubSnapshot.js +64 -0
  86. package/src/lib/identity.js +102 -0
  87. package/src/lib/intents.js +590 -0
  88. package/src/lib/ledger.js +535 -0
  89. package/src/lib/lifecycle.js +123 -0
  90. package/src/lib/localHub.js +217 -0
  91. package/src/lib/localHubWriter.js +69 -0
  92. package/src/lib/mirror-contract.json +4299 -0
  93. package/src/lib/mirrorContract.js +513 -0
  94. package/src/lib/mirrorDaemon.js +127 -0
  95. package/src/lib/mirrorService.js +365 -0
  96. package/src/lib/mirrorSource.js +430 -0
  97. package/src/lib/mirrorSync.js +353 -0
  98. package/src/lib/nodeFloor.js +19 -0
  99. package/src/lib/oauthLogin.js +132 -0
  100. package/src/lib/outboxFlusher.js +75 -0
  101. package/src/lib/ownerLock.js +99 -0
  102. package/src/lib/pairedConfig.js +40 -0
  103. package/src/lib/pairingLifecycle.js +179 -0
  104. package/src/lib/pathRedaction.js +89 -0
  105. package/src/lib/planContract.js +141 -0
  106. package/src/lib/priorityLane.js +80 -0
  107. package/src/lib/processOwnership.js +252 -0
  108. package/src/lib/processSupervisor.js +376 -0
  109. package/src/lib/reconcile.js +316 -0
  110. package/src/lib/reconcileView.js +129 -0
  111. package/src/lib/redaction.js +128 -0
  112. package/src/lib/rootPolicy.js +372 -0
  113. package/src/lib/rulePacks.js +162 -0
  114. package/src/lib/runnerStore.js +418 -0
  115. package/src/lib/secretStore.js +169 -0
  116. package/src/lib/selfRestart.js +261 -0
  117. package/src/lib/serviceManager.js +419 -0
  118. package/src/lib/shadowService.js +435 -0
  119. package/src/lib/sqlite.js +28 -0
  120. package/src/lib/storage.js +131 -0
  121. package/src/lib/subprocess.js +45 -0
  122. package/src/lib/tokenStore.js +136 -0
  123. package/src/lib/transport.js +334 -0
  124. package/src/lib/transportContract.js +40 -0
  125. package/src/lib/uploadRedactor.js +102 -0
  126. package/src/runner/applier.js +297 -0
  127. package/src/runner/bridge.js +289 -0
  128. package/src/runner/busIdentity.js +499 -0
  129. package/src/runner/capture.js +103 -0
  130. package/src/runner/captureMapping.js +21 -0
  131. package/src/runner/channelDiscipline.js +425 -0
  132. package/src/runner/deliveryStates.js +220 -0
  133. package/src/runner/facade.js +876 -0
  134. package/src/runner/hubCapture.js +160 -0
  135. package/src/runner/lease.js +46 -0
  136. package/src/runner/main.js +35 -0
  137. package/src/runner/render.js +91 -0
  138. package/src/runner/runnerChild.js +57 -0
  139. package/src/runner/runnerHub.js +104 -0
  140. package/src/runner/runnerProcess.js +124 -0
  141. package/src/runner/runnerSchema.js +51 -0
  142. package/src/runner/runnerService.js +140 -0
  143. package/src/runner/spool.js +86 -0
  144. package/src/runner/spoolInto.js +22 -0
  145. package/src/runner/statusPage.js +30 -0
  146. package/LICENSE +0 -21
  147. package/src/commands/pulse/gate.js +0 -25
  148. package/src/commands/pulse/next.js +0 -14
  149. package/src/commands/pulse/submit.js +0 -21
  150. package/src/commands/pulse/verify.js +0 -21
  151. package/src/runPulse.js +0 -58
@@ -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
+ }
@@ -0,0 +1,419 @@
1
+ import { access, readFile } from 'node:fs/promises';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { setTimeout as delay } from 'node:timers/promises';
5
+ import { HubError } from './errors.js';
6
+ import {
7
+ ensureOwnerOnlyDirectory,
8
+ readOwnerOnlyJson,
9
+ writeOwnerOnlyText,
10
+ } from './storage.js';
11
+ import { execFileBounded as execFile } from './subprocess.js';
12
+
13
+ const LABEL = 'com.agentpulse.hub';
14
+ const SYSTEMD_UNIT = 'agentpulse-hub.service';
15
+ const START_ATTEMPTS = 6;
16
+ const START_RETRY_MS = 250;
17
+ const ABSENT_SERVICE = /not found|could not find|no such process|not loaded|inactive|not running/i;
18
+
19
+ function xml(value) {
20
+ return String(value)
21
+ .replaceAll('&', '&amp;')
22
+ .replaceAll('<', '&lt;')
23
+ .replaceAll('>', '&gt;')
24
+ .replaceAll('"', '&quot;')
25
+ .replaceAll("'", '&apos;');
26
+ }
27
+
28
+ function launchdPlist({ nodePath, daemonPath, configDir, logDir }) {
29
+ return `<?xml version="1.0" encoding="UTF-8"?>
30
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
31
+ <plist version="1.0">
32
+ <dict>
33
+ <key>Label</key><string>${LABEL}</string>
34
+ <key>ProgramArguments</key>
35
+ <array>
36
+ <string>${xml(nodePath)}</string>
37
+ <string>${xml(daemonPath)}</string>
38
+ <string>--config-dir</string>
39
+ <string>${xml(configDir)}</string>
40
+ </array>
41
+ <key>RunAtLoad</key><true/>
42
+ <key>KeepAlive</key><dict><key>SuccessfulExit</key><false/></dict>
43
+ <key>ProcessType</key><string>Standard</string>
44
+ <key>StandardOutPath</key><string>${xml(path.join(logDir, 'daemon.log'))}</string>
45
+ <key>StandardErrorPath</key><string>${xml(path.join(logDir, 'daemon-error.log'))}</string>
46
+ </dict>
47
+ </plist>
48
+ `;
49
+ }
50
+
51
+ function unxml(value) {
52
+ return value
53
+ .replaceAll('&apos;', "'")
54
+ .replaceAll('&quot;', '"')
55
+ .replaceAll('&gt;', '>')
56
+ .replaceAll('&lt;', '<')
57
+ .replaceAll('&amp;', '&');
58
+ }
59
+
60
+ function launchdArgv(contents) {
61
+ const programArguments = contents.match(/<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/)?.[1] || '';
62
+ return [...programArguments.matchAll(/<string>([^<]*)<\/string>/g)].map((match) => unxml(match[1]));
63
+ }
64
+
65
+ function systemdArgv(contents) {
66
+ const execStart = contents.match(/^ExecStart=(.*)$/m)?.[1] || '';
67
+ return [...execStart.matchAll(/"((?:\\.|[^"\\])*)"|(\S+)/g)].map(([, quoted, bare]) =>
68
+ quoted === undefined ? bare : quoted.replace(/\\(["\\])/g, '$1')
69
+ );
70
+ }
71
+
72
+ async function exists(file) {
73
+ try {
74
+ await access(file);
75
+ return true;
76
+ } catch {
77
+ return false;
78
+ }
79
+ }
80
+
81
+ function systemdArgument(value) {
82
+ if (/^[A-Za-z0-9_./:@%+=,-]+$/.test(value)) return value;
83
+ return `"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
84
+ }
85
+
86
+ function systemdUnit({ nodePath, daemonPath, configDir }) {
87
+ for (const value of [nodePath, daemonPath, configDir]) {
88
+ if (/[\n\r]/.test(value)) {
89
+ throw new HubError('HUB_SERVICE_PATH_DENIED', 'Service paths cannot contain newlines');
90
+ }
91
+ }
92
+ return `[Unit]
93
+ Description=AgentPulse Pulse local connector
94
+ After=network-online.target
95
+ Wants=network-online.target
96
+
97
+ [Service]
98
+ Type=simple
99
+ ExecStart=${systemdArgument(nodePath)} ${systemdArgument(daemonPath)} --config-dir ${systemdArgument(configDir)}
100
+ Restart=on-failure
101
+ RestartSec=5
102
+ NoNewPrivileges=true
103
+ PrivateTmp=true
104
+
105
+ [Install]
106
+ WantedBy=default.target
107
+ `;
108
+ }
109
+
110
+ export class ServiceManager {
111
+ constructor({
112
+ platform = process.platform,
113
+ home = os.homedir(),
114
+ uid = typeof process.getuid === 'function' ? process.getuid() : null,
115
+ nodePath = process.execPath,
116
+ daemonPath,
117
+ configDir = path.join(
118
+ process.env.XDG_CONFIG_HOME || path.join(home, '.config'),
119
+ 'agentpulse',
120
+ 'hub'
121
+ ),
122
+ logDir = path.join(
123
+ process.env.XDG_STATE_HOME || path.join(home, '.local', 'state'),
124
+ 'agentpulse',
125
+ 'hub',
126
+ 'logs'
127
+ ),
128
+ execute = execFile,
129
+ wait = delay,
130
+ launchAgentsDir = path.join(home, 'Library', 'LaunchAgents'),
131
+ }) {
132
+ this.platform = platform;
133
+ this.home = home;
134
+ this.uid = uid;
135
+ this.nodePath = nodePath;
136
+ this.daemonPath = daemonPath;
137
+ this.configDir = configDir;
138
+ this.logDir = logDir;
139
+ this.execute = execute;
140
+ this.wait = wait;
141
+ this.launchAgentsDir = launchAgentsDir;
142
+ }
143
+
144
+ serviceFile() {
145
+ switch (this.platform) {
146
+ case 'darwin':
147
+ return path.join(this.home, 'Library', 'LaunchAgents', `${LABEL}.plist`);
148
+ case 'linux':
149
+ return path.join(this.home, '.config', 'systemd', 'user', SYSTEMD_UNIT);
150
+ default:
151
+ throw new HubError('HUB_UNSUPPORTED_PLATFORM', `Unsupported service platform: ${this.platform}`);
152
+ }
153
+ }
154
+
155
+ async install() {
156
+ if (!this.daemonPath) {
157
+ throw new HubError('HUB_DAEMON_PATH_MISSING', 'Daemon entry point is unavailable');
158
+ }
159
+ const serviceFile = this.serviceFile();
160
+ switch (this.platform) {
161
+ case 'darwin':
162
+ await ensureOwnerOnlyDirectory(this.logDir);
163
+ await writeOwnerOnlyText(
164
+ serviceFile,
165
+ launchdPlist({
166
+ nodePath: this.nodePath,
167
+ daemonPath: this.daemonPath,
168
+ configDir: this.configDir,
169
+ logDir: this.logDir,
170
+ })
171
+ );
172
+ break;
173
+ case 'linux':
174
+ await writeOwnerOnlyText(
175
+ serviceFile,
176
+ systemdUnit({
177
+ nodePath: this.nodePath,
178
+ daemonPath: this.daemonPath,
179
+ configDir: this.configDir,
180
+ })
181
+ );
182
+ await this.execute('systemctl', ['--user', 'daemon-reload']);
183
+ break;
184
+ default:
185
+ throw new HubError('HUB_UNSUPPORTED_PLATFORM', `Unsupported service platform: ${this.platform}`);
186
+ }
187
+ return { serviceFile, platform: this.platform, label: LABEL };
188
+ }
189
+
190
+ async daemonPathCheck() {
191
+ let contents;
192
+ try {
193
+ contents = await readFile(this.serviceFile(), 'utf8');
194
+ } catch (error) {
195
+ if (error.code === 'ENOENT') return { ok: true, installed: null, expected: this.daemonPath };
196
+ throw error;
197
+ }
198
+ let argv;
199
+ switch (this.platform) {
200
+ case 'darwin':
201
+ argv = launchdArgv(contents);
202
+ break;
203
+ case 'linux':
204
+ argv = systemdArgv(contents);
205
+ break;
206
+ default:
207
+ throw new HubError('HUB_UNSUPPORTED_PLATFORM', `Unsupported service platform: ${this.platform}`);
208
+ }
209
+ const [nodePath = null, installed = null] = argv;
210
+ let reason = null;
211
+ if (installed !== this.daemonPath) reason = 'daemon_path_changed';
212
+ else if (!nodePath || !(await exists(nodePath))) reason = 'node_missing';
213
+ if (!reason) return { ok: true, installed, expected: this.daemonPath, nodePath };
214
+ return {
215
+ ok: false,
216
+ code: 'HUB_SERVICE_PATH_STALE',
217
+ reason,
218
+ installed,
219
+ expected: this.daemonPath,
220
+ nodePath,
221
+ hint: 'Run `agentpulse pulse install` once, then `agentpulse pulse start`.',
222
+ };
223
+ }
224
+
225
+ async start() {
226
+ switch (this.platform) {
227
+ case 'darwin':
228
+ await this.#startLaunchd();
229
+ break;
230
+ case 'linux':
231
+ await this.execute('systemctl', ['--user', 'start', SYSTEMD_UNIT]);
232
+ break;
233
+ default:
234
+ throw new HubError('HUB_UNSUPPORTED_PLATFORM', `Unsupported service platform: ${this.platform}`);
235
+ }
236
+ return this.status();
237
+ }
238
+
239
+ async #launchdLoaded() {
240
+ try {
241
+ await this.execute('/bin/launchctl', ['print', `gui/${this.uid}/${LABEL}`]);
242
+ return true;
243
+ } catch {
244
+ return false;
245
+ }
246
+ }
247
+
248
+ // launchd tears a booted-out service down asynchronously, so bootstrap and
249
+ // kickstart can fail briefly (codes 5 and 37) right after `pulse stop`.
250
+ async #startLaunchd() {
251
+ const target = `gui/${this.uid}/${LABEL}`;
252
+ let lastError = null;
253
+ for (let attempt = 0; attempt < START_ATTEMPTS; attempt += 1) {
254
+ if (attempt > 0) await this.wait(START_RETRY_MS * attempt);
255
+ try {
256
+ if (await this.#launchdLoaded()) {
257
+ await this.execute('/bin/launchctl', ['kickstart', '-k', target]);
258
+ } else {
259
+ await this.execute('/bin/launchctl', ['bootstrap', `gui/${this.uid}`, this.serviceFile()]);
260
+ await this.execute('/bin/launchctl', ['kickstart', target]);
261
+ }
262
+ return;
263
+ } catch (error) {
264
+ lastError = error;
265
+ }
266
+ }
267
+ throw new HubError('HUB_SERVICE_START_FAILED', 'launchd could not start Pulse; run `agentpulse pulse install`, then `agentpulse pulse start`', {
268
+ cause: lastError,
269
+ details: { stderr: String(lastError?.stderr || lastError?.message || '').trim().slice(0, 300) },
270
+ });
271
+ }
272
+
273
+ async status() {
274
+ switch (this.platform) {
275
+ case 'darwin': {
276
+ try {
277
+ const result = await this.execute('/bin/launchctl', [
278
+ 'print',
279
+ `gui/${this.uid}/${LABEL}`,
280
+ ]);
281
+ const pid = Number(result.stdout.match(/\bpid\s*=\s*(\d+)/)?.[1]) || null;
282
+ const state = result.stdout.match(/\bstate\s*=\s*(\w+)/)?.[1] || (pid ? 'running' : 'loaded');
283
+ return { state, pid, serviceFile: this.serviceFile() };
284
+ } catch {
285
+ return { state: 'stopped', pid: null, serviceFile: this.serviceFile() };
286
+ }
287
+ }
288
+ case 'linux': {
289
+ try {
290
+ const result = await this.execute('systemctl', [
291
+ '--user',
292
+ 'show',
293
+ SYSTEMD_UNIT,
294
+ '--property',
295
+ 'ActiveState',
296
+ '--property',
297
+ 'MainPID',
298
+ ]);
299
+ const active = result.stdout.match(/^ActiveState=(.+)$/m)?.[1] || 'unknown';
300
+ const pid = Number(result.stdout.match(/^MainPID=(\d+)$/m)?.[1]) || null;
301
+ return {
302
+ state: active === 'active' ? 'running' : active,
303
+ pid,
304
+ serviceFile: this.serviceFile(),
305
+ };
306
+ } catch {
307
+ return { state: 'stopped', pid: null, serviceFile: this.serviceFile() };
308
+ }
309
+ }
310
+ default:
311
+ throw new HubError('HUB_UNSUPPORTED_PLATFORM', `Unsupported service platform: ${this.platform}`);
312
+ }
313
+ }
314
+
315
+ async stop() {
316
+ switch (this.platform) {
317
+ case 'darwin':
318
+ try {
319
+ await this.execute('/bin/launchctl', ['bootout', `gui/${this.uid}/${LABEL}`]);
320
+ } catch (error) {
321
+ const detail = `${error.message || ''}\n${error.stderr || ''}`;
322
+ if (!ABSENT_SERVICE.test(detail)) {
323
+ throw new HubError('HUB_SERVICE_STOP_FAILED', 'launchd could not stop Pulse', {
324
+ cause: error,
325
+ });
326
+ }
327
+ }
328
+ break;
329
+ case 'linux':
330
+ try {
331
+ await this.execute('systemctl', ['--user', 'stop', SYSTEMD_UNIT]);
332
+ } catch (error) {
333
+ const detail = `${error.message || ''}\n${error.stderr || ''}`;
334
+ if (!ABSENT_SERVICE.test(detail)) {
335
+ throw new HubError('HUB_SERVICE_STOP_FAILED', 'systemd could not stop Pulse', {
336
+ cause: error,
337
+ });
338
+ }
339
+ }
340
+ break;
341
+ default:
342
+ throw new HubError('HUB_UNSUPPORTED_PLATFORM', `Unsupported service platform: ${this.platform}`);
343
+ }
344
+ return { state: 'stopped', pid: null, serviceFile: this.serviceFile() };
345
+ }
346
+
347
+ /**
348
+ * Another LaunchAgent of this machine, by label (the old local hub, its keep-awake helper), for cutover and
349
+ * rollback. macOS only; every call goes through the injected executor.
350
+ */
351
+ #requireLaunchd(label) {
352
+ if (this.platform !== 'darwin') throw new HubError('HUB_UNSUPPORTED_PLATFORM', `${label} is a macOS LaunchAgent`);
353
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,120}$/.test(label)) throw new HubError('HUB_SERVICE_LABEL_INVALID', `Bad LaunchAgent label: ${label}`);
354
+ return `gui/${this.uid}/${label}`;
355
+ }
356
+
357
+ plistFor(label) {
358
+ this.#requireLaunchd(label);
359
+ return path.join(this.launchAgentsDir, `${label}.plist`);
360
+ }
361
+
362
+ /** `{ loaded, pid, plist }` for a LaunchAgent label. */
363
+ async labelState(label) {
364
+ const target = this.#requireLaunchd(label);
365
+ const plist = this.plistFor(label);
366
+ try {
367
+ const result = await this.execute('/bin/launchctl', ['print', target]);
368
+ return { loaded: true, pid: Number(result.stdout.match(/\bpid\s*=\s*(\d+)/)?.[1]) || null, plist, installed: await exists(plist) };
369
+ } catch {
370
+ return { loaded: false, pid: null, plist, installed: await exists(plist) };
371
+ }
372
+ }
373
+
374
+ /** Unload a LaunchAgent and keep it from loading again at login (`disable`), until `restoreLabel`. */
375
+ async retireLabel(label) {
376
+ const target = this.#requireLaunchd(label);
377
+ try {
378
+ await this.execute('/bin/launchctl', ['bootout', target]);
379
+ } catch (error) {
380
+ if (!ABSENT_SERVICE.test(`${error.message || ''}\n${error.stderr || ''}`)) {
381
+ throw new HubError('HUB_SERVICE_STOP_FAILED', `launchd could not unload ${label}`, { cause: error });
382
+ }
383
+ }
384
+ await this.execute('/bin/launchctl', ['disable', target]);
385
+ return this.labelState(label);
386
+ }
387
+
388
+ /** Undo `retireLabel`: enable the label again and load its plist. */
389
+ async restoreLabel(label) {
390
+ const target = this.#requireLaunchd(label);
391
+ await this.execute('/bin/launchctl', ['enable', target]);
392
+ let lastError = null;
393
+ for (let attempt = 0; attempt < START_ATTEMPTS; attempt += 1) {
394
+ if (attempt > 0) await this.wait(START_RETRY_MS * attempt);
395
+ if ((await this.labelState(label)).loaded) return this.labelState(label);
396
+ try {
397
+ await this.execute('/bin/launchctl', ['bootstrap', `gui/${this.uid}`, this.plistFor(label)]);
398
+ } catch (error) {
399
+ lastError = error;
400
+ }
401
+ }
402
+ if ((await this.labelState(label)).loaded) return this.labelState(label);
403
+ throw new HubError('HUB_SERVICE_START_FAILED', `launchd could not load ${label}`, { cause: lastError });
404
+ }
405
+
406
+ async health({ freshMs = 30_000, after = 0 } = {}) {
407
+ const health = await readOwnerOnlyJson(path.join(this.configDir, 'daemon-health.json'));
408
+ const heartbeat = Date.parse(health?.heartbeatAt);
409
+ return {
410
+ ok: Boolean(
411
+ health &&
412
+ Number.isFinite(heartbeat) &&
413
+ heartbeat >= after &&
414
+ Date.now() - heartbeat <= freshMs
415
+ ),
416
+ health,
417
+ };
418
+ }
419
+ }