@phnx-labs/agents-cli 1.20.35 → 1.20.37

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 (236) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/LICENSE +185 -21
  3. package/README.md +8 -4
  4. package/dist/commands/audit.d.ts +14 -0
  5. package/dist/commands/audit.js +68 -0
  6. package/dist/commands/browser.js +82 -8
  7. package/dist/commands/check.d.ts +15 -0
  8. package/dist/commands/check.js +84 -0
  9. package/dist/commands/cloud.js +143 -3
  10. package/dist/commands/computer.js +81 -0
  11. package/dist/commands/daemon.js +4 -1
  12. package/dist/commands/doctor.js +1 -89
  13. package/dist/commands/events.js +3 -3
  14. package/dist/commands/exec.d.ts +19 -0
  15. package/dist/commands/exec.js +277 -11
  16. package/dist/commands/hosts.js +10 -6
  17. package/dist/commands/inspect.js +8 -9
  18. package/dist/commands/lock.d.ts +12 -0
  19. package/dist/commands/lock.js +70 -0
  20. package/dist/commands/message.d.ts +15 -0
  21. package/dist/commands/message.js +56 -0
  22. package/dist/commands/routines.js +101 -5
  23. package/dist/commands/secrets-migrate.js +106 -57
  24. package/dist/commands/secrets.d.ts +31 -18
  25. package/dist/commands/secrets.js +156 -75
  26. package/dist/commands/serve.d.ts +10 -0
  27. package/dist/commands/serve.js +37 -0
  28. package/dist/commands/sessions-inject.d.ts +14 -0
  29. package/dist/commands/sessions-inject.js +111 -0
  30. package/dist/commands/sessions-picker.d.ts +2 -0
  31. package/dist/commands/sessions-picker.js +24 -3
  32. package/dist/commands/sessions-resume.js +20 -8
  33. package/dist/commands/sessions-sync.d.ts +3 -0
  34. package/dist/commands/sessions-sync.js +44 -4
  35. package/dist/commands/sessions.d.ts +78 -1
  36. package/dist/commands/sessions.js +506 -66
  37. package/dist/commands/setup.js +4 -2
  38. package/dist/commands/sync.d.ts +3 -1
  39. package/dist/commands/sync.js +156 -4
  40. package/dist/commands/teams.js +217 -0
  41. package/dist/commands/versions.js +2 -4
  42. package/dist/commands/watchdog.d.ts +18 -0
  43. package/dist/commands/watchdog.js +238 -0
  44. package/dist/index.js +25 -2
  45. package/dist/lib/audit/log.d.ts +92 -0
  46. package/dist/lib/audit/log.js +177 -0
  47. package/dist/lib/auto-pull.js +2 -1
  48. package/dist/lib/browser/chrome.d.ts +10 -0
  49. package/dist/lib/browser/chrome.js +18 -7
  50. package/dist/lib/browser/drivers/ssh.js +2 -1
  51. package/dist/lib/browser/har.d.ts +84 -0
  52. package/dist/lib/browser/har.js +77 -0
  53. package/dist/lib/browser/ipc.js +24 -3
  54. package/dist/lib/browser/profiles.d.ts +1 -1
  55. package/dist/lib/browser/profiles.js +8 -10
  56. package/dist/lib/browser/refs.d.ts +65 -0
  57. package/dist/lib/browser/refs.js +73 -1
  58. package/dist/lib/browser/runtime-state.js +1 -0
  59. package/dist/lib/browser/service.d.ts +38 -2
  60. package/dist/lib/browser/service.js +112 -8
  61. package/dist/lib/browser/types.d.ts +14 -1
  62. package/dist/lib/budget/live-cloud.d.ts +42 -0
  63. package/dist/lib/budget/live-cloud.js +79 -0
  64. package/dist/lib/budget/live-team.d.ts +31 -0
  65. package/dist/lib/budget/live-team.js +115 -0
  66. package/dist/lib/cloud/codex.js +4 -0
  67. package/dist/lib/cloud/rush.d.ts +12 -1
  68. package/dist/lib/cloud/rush.js +13 -3
  69. package/dist/lib/cloud/types.d.ts +9 -0
  70. package/dist/lib/computer/dispatch.d.ts +8 -0
  71. package/dist/lib/computer/dispatch.js +125 -0
  72. package/dist/lib/computer/loop.d.ts +62 -0
  73. package/dist/lib/computer/loop.js +98 -0
  74. package/dist/lib/computer/model.d.ts +44 -0
  75. package/dist/lib/computer/model.js +157 -0
  76. package/dist/lib/concurrency.d.ts +19 -0
  77. package/dist/lib/concurrency.js +33 -0
  78. package/dist/lib/daemon.d.ts +57 -0
  79. package/dist/lib/daemon.js +196 -18
  80. package/dist/lib/devices/registry.d.ts +7 -0
  81. package/dist/lib/devices/registry.js +24 -0
  82. package/dist/lib/devices/resolve-target.d.ts +24 -0
  83. package/dist/lib/devices/resolve-target.js +80 -0
  84. package/dist/lib/devices/tailscale.js +1 -1
  85. package/dist/lib/drift.d.ts +52 -0
  86. package/dist/lib/drift.js +112 -0
  87. package/dist/lib/events.d.ts +1 -1
  88. package/dist/lib/events.js +31 -13
  89. package/dist/lib/exec.d.ts +17 -0
  90. package/dist/lib/exec.js +79 -13
  91. package/dist/lib/git.d.ts +27 -0
  92. package/dist/lib/git.js +56 -1
  93. package/dist/lib/hooks/cache.d.ts +6 -0
  94. package/dist/lib/hooks/cache.js +54 -12
  95. package/dist/lib/hooks.d.ts +27 -0
  96. package/dist/lib/hooks.js +127 -8
  97. package/dist/lib/hosts/dispatch.d.ts +15 -0
  98. package/dist/lib/hosts/dispatch.js +39 -6
  99. package/dist/lib/hosts/logs.js +30 -1
  100. package/dist/lib/hosts/option.js +1 -1
  101. package/dist/lib/hosts/passthrough.js +3 -1
  102. package/dist/lib/hosts/ready.d.ts +29 -6
  103. package/dist/lib/hosts/ready.js +66 -15
  104. package/dist/lib/hosts/registry.d.ts +19 -2
  105. package/dist/lib/hosts/registry.js +58 -2
  106. package/dist/lib/hosts/remote-cmd.d.ts +62 -1
  107. package/dist/lib/hosts/remote-cmd.js +70 -1
  108. package/dist/lib/hosts/remote-os.d.ts +17 -0
  109. package/dist/lib/hosts/remote-os.js +30 -0
  110. package/dist/lib/hosts/session-index.d.ts +34 -0
  111. package/dist/lib/hosts/session-index.js +56 -0
  112. package/dist/lib/hosts/tasks.d.ts +14 -0
  113. package/dist/lib/hosts/tasks.js +15 -0
  114. package/dist/lib/lock.d.ts +93 -0
  115. package/dist/lib/lock.js +207 -0
  116. package/dist/lib/loop.js +16 -1
  117. package/dist/lib/machine-id.d.ts +21 -0
  118. package/dist/lib/machine-id.js +26 -0
  119. package/dist/lib/mailbox-target.d.ts +36 -0
  120. package/dist/lib/mailbox-target.js +45 -0
  121. package/dist/lib/mailbox.d.ts +47 -0
  122. package/dist/lib/mailbox.js +194 -0
  123. package/dist/lib/mcp.d.ts +5 -0
  124. package/dist/lib/mcp.js +24 -8
  125. package/dist/lib/migrate.d.ts +19 -0
  126. package/dist/lib/migrate.js +134 -26
  127. package/dist/lib/overdue.js +3 -0
  128. package/dist/lib/picker.d.ts +2 -0
  129. package/dist/lib/picker.js +4 -1
  130. package/dist/lib/platform/exec.d.ts +46 -0
  131. package/dist/lib/platform/exec.js +74 -0
  132. package/dist/lib/platform/process.d.ts +31 -0
  133. package/dist/lib/platform/process.js +34 -1
  134. package/dist/lib/platform/winpath.js +2 -0
  135. package/dist/lib/plugins.js +16 -6
  136. package/dist/lib/profiles.d.ts +25 -0
  137. package/dist/lib/profiles.js +22 -6
  138. package/dist/lib/pty-client.js +2 -1
  139. package/dist/lib/rotate.d.ts +61 -0
  140. package/dist/lib/rotate.js +52 -0
  141. package/dist/lib/routines.d.ts +40 -2
  142. package/dist/lib/routines.js +66 -8
  143. package/dist/lib/runner.d.ts +11 -2
  144. package/dist/lib/runner.js +49 -7
  145. package/dist/lib/scheduler.js +6 -1
  146. package/dist/lib/secrets/bundles.d.ts +60 -4
  147. package/dist/lib/secrets/bundles.js +131 -12
  148. package/dist/lib/secrets/filestore.d.ts +3 -0
  149. package/dist/lib/secrets/filestore.js +42 -16
  150. package/dist/lib/secrets/index.d.ts +43 -2
  151. package/dist/lib/secrets/index.js +102 -3
  152. package/dist/lib/secrets/mcp.d.ts +93 -0
  153. package/dist/lib/secrets/mcp.js +205 -0
  154. package/dist/lib/secrets/remote.js +12 -5
  155. package/dist/lib/secrets/sync.js +83 -4
  156. package/dist/lib/secrets/windows.js +14 -3
  157. package/dist/lib/serve/data.d.ts +81 -0
  158. package/dist/lib/serve/data.js +91 -0
  159. package/dist/lib/serve/page.d.ts +7 -0
  160. package/dist/lib/serve/page.js +140 -0
  161. package/dist/lib/serve/server.d.ts +46 -0
  162. package/dist/lib/serve/server.js +115 -0
  163. package/dist/lib/session/active.d.ts +73 -0
  164. package/dist/lib/session/active.js +199 -22
  165. package/dist/lib/session/db.d.ts +2 -1
  166. package/dist/lib/session/db.js +41 -5
  167. package/dist/lib/session/discover.d.ts +39 -0
  168. package/dist/lib/session/discover.js +127 -29
  169. package/dist/lib/session/ghostty-tabs.d.ts +33 -0
  170. package/dist/lib/session/ghostty-tabs.js +126 -0
  171. package/dist/lib/session/inject.d.ts +18 -0
  172. package/dist/lib/session/inject.js +21 -0
  173. package/dist/lib/session/parse.js +23 -20
  174. package/dist/lib/session/pid-registry.d.ts +1 -0
  175. package/dist/lib/session/pid-registry.js +24 -0
  176. package/dist/lib/session/provenance.d.ts +14 -2
  177. package/dist/lib/session/provenance.js +39 -8
  178. package/dist/lib/session/relative-time.js +6 -2
  179. package/dist/lib/session/remote-active.js +19 -17
  180. package/dist/lib/session/remote-list.d.ts +51 -0
  181. package/dist/lib/session/remote-list.js +205 -0
  182. package/dist/lib/session/remote.d.ts +7 -1
  183. package/dist/lib/session/remote.js +20 -4
  184. package/dist/lib/session/sync/config.d.ts +14 -15
  185. package/dist/lib/session/sync/config.js +60 -20
  186. package/dist/lib/session/types.d.ts +23 -0
  187. package/dist/lib/shims.d.ts +36 -6
  188. package/dist/lib/shims.js +91 -29
  189. package/dist/lib/ssh-exec.js +2 -0
  190. package/dist/lib/ssh-tunnel.js +2 -1
  191. package/dist/lib/startup/command-registry.d.ts +6 -0
  192. package/dist/lib/startup/command-registry.js +13 -1
  193. package/dist/lib/state.d.ts +13 -0
  194. package/dist/lib/state.js +103 -9
  195. package/dist/lib/sync-umbrella.d.ts +14 -7
  196. package/dist/lib/sync-umbrella.js +21 -13
  197. package/dist/lib/teams/forEach.d.ts +110 -0
  198. package/dist/lib/teams/forEach.js +186 -0
  199. package/dist/lib/teams/index.d.ts +1 -0
  200. package/dist/lib/teams/index.js +1 -0
  201. package/dist/lib/teams/pr-watch.d.ts +226 -0
  202. package/dist/lib/teams/pr-watch.js +371 -0
  203. package/dist/lib/teams/supervisor.d.ts +14 -1
  204. package/dist/lib/teams/supervisor.js +19 -0
  205. package/dist/lib/teams/worktree.d.ts +9 -0
  206. package/dist/lib/teams/worktree.js +32 -0
  207. package/dist/lib/terminal/backends/index.d.ts +2 -1
  208. package/dist/lib/terminal/backends/index.js +3 -1
  209. package/dist/lib/terminal/backends/vscodium-agent.d.ts +31 -0
  210. package/dist/lib/terminal/backends/vscodium-agent.js +72 -0
  211. package/dist/lib/terminal/index.d.ts +4 -1
  212. package/dist/lib/terminal/index.js +4 -1
  213. package/dist/lib/terminal/inject.d.ts +204 -0
  214. package/dist/lib/terminal/inject.js +247 -0
  215. package/dist/lib/terminal/resolve.d.ts +64 -0
  216. package/dist/lib/terminal/resolve.js +90 -0
  217. package/dist/lib/terminal/types.d.ts +1 -1
  218. package/dist/lib/tmux/session.d.ts +10 -0
  219. package/dist/lib/tmux/session.js +31 -0
  220. package/dist/lib/triggers/webhook.d.ts +85 -0
  221. package/dist/lib/triggers/webhook.js +141 -0
  222. package/dist/lib/versions.d.ts +23 -0
  223. package/dist/lib/versions.js +119 -13
  224. package/dist/lib/watchdog/index.d.ts +3 -0
  225. package/dist/lib/watchdog/index.js +5 -0
  226. package/dist/lib/watchdog/read.d.ts +35 -0
  227. package/dist/lib/watchdog/read.js +149 -0
  228. package/dist/lib/watchdog/runner.d.ts +127 -0
  229. package/dist/lib/watchdog/runner.js +322 -0
  230. package/dist/lib/watchdog/watchdog.d.ts +40 -0
  231. package/dist/lib/watchdog/watchdog.js +166 -0
  232. package/dist/lib/watchdog/watchdogTail.d.ts +5 -0
  233. package/dist/lib/watchdog/watchdogTail.js +154 -0
  234. package/dist/lib/workflows.d.ts +166 -0
  235. package/dist/lib/workflows.js +193 -0
  236. package/package.json +5 -4
@@ -11,7 +11,7 @@ import * as fs from 'fs';
11
11
  import * as path from 'path';
12
12
  import * as os from 'os';
13
13
  import { getDaemonDir as getDaemonDirRoot } from './state.js';
14
- import { isAlive, killTree } from './platform/index.js';
14
+ import { isAlive, killTree, backgroundSpawnOptions } from './platform/index.js';
15
15
  import { listJobs as listAllJobs } from './routines.js';
16
16
  import { JobScheduler } from './scheduler.js';
17
17
  import { executeJobDetached, monitorRunningJobs } from './runner.js';
@@ -127,6 +127,86 @@ export function isDaemonRunning() {
127
127
  removeDaemonPid();
128
128
  return false;
129
129
  }
130
+ /**
131
+ * Single-instance claim for the daemon `_run` entrypoint.
132
+ *
133
+ * `agents daemon _run` is reachable directly — a manual invocation, or a
134
+ * service-manager restart that races a still-alive predecessor — bypassing the
135
+ * start lock in startDaemon(). Without this guard runDaemon() would call
136
+ * writeDaemonPid() unconditionally, clobber a live daemon's recorded PID, and
137
+ * run a second JobScheduler concurrently, so every cron routine fires twice.
138
+ *
139
+ * Returns true and records our PID when no other live daemon owns the pid file;
140
+ * returns false when a live daemon already holds it (the caller must exit
141
+ * without touching any further state). The read-decide-write is serialized
142
+ * behind the same O_EXCL start lock startDaemon() uses, so two _run processes
143
+ * can't both claim in the window between the liveness check and the write.
144
+ */
145
+ export function claimDaemonInstance() {
146
+ const release = acquireStartLock();
147
+ try {
148
+ const existing = readDaemonPid();
149
+ if (existing !== null && existing !== process.pid && isAlive(existing)) {
150
+ return false; // another live daemon already owns the pid file
151
+ }
152
+ writeDaemonPid(process.pid);
153
+ return true;
154
+ }
155
+ finally {
156
+ release?.();
157
+ }
158
+ }
159
+ /**
160
+ * Reap stray duplicate daemon processes — a `daemon _run` of THIS install that
161
+ * isn't this process and isn't the pid-file owner. Mirrors the browser orphan
162
+ * reaper (below): a predecessor that was SIGKILLed/OOM-ed without cleaning up,
163
+ * or a duplicate that lost the pid-file write race, would otherwise keep a
164
+ * second scheduler alive and double-fire jobs even after claimDaemonInstance()
165
+ * hands the pid file to the survivor.
166
+ *
167
+ * Scoped to our own launch entry (process.argv[1]) so it only ever targets
168
+ * daemons of the same installation — a daemon from a different install / home
169
+ * (e.g. a side-by-side dev build, or a test fixture) is a legitimately separate
170
+ * instance and is left untouched. POSIX-only (uses `ps`); a no-op on Windows.
171
+ */
172
+ export function reapStrayDaemons(keepPid = process.pid) {
173
+ const details = [];
174
+ let reaped = 0;
175
+ if (process.platform === 'win32')
176
+ return { reaped, details };
177
+ const selfEntry = process.argv[1];
178
+ if (!selfEntry)
179
+ return { reaped, details };
180
+ let out;
181
+ try {
182
+ out = execFileSync('ps', ['-eo', 'pid=,args='], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
183
+ }
184
+ catch {
185
+ return { reaped, details }; // no `ps` — best effort
186
+ }
187
+ const ownerPid = readDaemonPid();
188
+ for (const line of out.split('\n')) {
189
+ const m = line.trim().match(/^(\d+)\s+(.*)$/);
190
+ if (!m)
191
+ continue;
192
+ const pid = parseInt(m[1], 10);
193
+ const args = m[2];
194
+ if (isNaN(pid) || pid === keepPid || pid === process.pid || pid === ownerPid)
195
+ continue;
196
+ // Same install (same launch entry) AND a `daemon _run` command line.
197
+ if (!args.includes(selfEntry))
198
+ continue;
199
+ if (!/\bdaemon\b.*\b_run\b/.test(args))
200
+ continue;
201
+ try {
202
+ process.kill(pid, 'SIGTERM');
203
+ reaped++;
204
+ details.push(`reaped stray daemon pid ${pid}`);
205
+ }
206
+ catch { /* already gone */ }
207
+ }
208
+ return { reaped, details };
209
+ }
130
210
  /** Redact values that look like tokens or credentials in a log message. */
131
211
  function redactSecrets(message) {
132
212
  let safe = message;
@@ -165,8 +245,30 @@ export function log(level, message) {
165
245
  }
166
246
  /** Main daemon loop: load jobs, schedule crons, monitor runs, and handle signals. */
167
247
  export async function runDaemon() {
168
- writeDaemonPid(process.pid);
248
+ // Single-instance guard: a direct `agents daemon _run` (manual, or a
249
+ // service-manager restart racing a live predecessor) must not clobber a
250
+ // running daemon's pid file and start a second scheduler.
251
+ if (!claimDaemonInstance()) {
252
+ const owner = readDaemonPid();
253
+ log('WARN', `Another daemon already owns the pid file (PID: ${owner}); this instance (PID ${process.pid}) is exiting`);
254
+ // Exit cleanly (0) so a service manager treats it as an orderly no-op
255
+ // rather than a failure to restart-flap on.
256
+ process.exit(0);
257
+ }
169
258
  log('INFO', `Daemon started (PID: ${process.pid})`);
259
+ // Reap any stray duplicate daemon of this install that slipped past the start
260
+ // lock or was orphaned by a hard-crash — before it can double-fire jobs.
261
+ try {
262
+ const strays = reapStrayDaemons();
263
+ if (strays.reaped > 0) {
264
+ log('WARN', `Reaped ${strays.reaped} stray daemon process(es)`);
265
+ for (const d of strays.details)
266
+ log('WARN', ` ${d}`);
267
+ }
268
+ }
269
+ catch (err) {
270
+ log('ERROR', `Stray daemon reaper failed: ${err.message}`);
271
+ }
170
272
  const scheduler = new JobScheduler(async (config) => {
171
273
  log('INFO', `Triggering job '${config.name}' (agent: ${config.agent})`);
172
274
  try {
@@ -241,8 +343,10 @@ export async function runDaemon() {
241
343
  return;
242
344
  syncing = true;
243
345
  try {
244
- const { isSyncConfigured } = await import('./session/sync/config.js');
245
- if (!isSyncConfigured())
346
+ const { isSyncConfigured, isSyncEnabled } = await import('./session/sync/config.js');
347
+ // isSyncEnabled() first: a machine the operator turned off must skip the
348
+ // keychain read entirely, not just the network cycle.
349
+ if (!isSyncEnabled() || !isSyncConfigured())
246
350
  return;
247
351
  const { syncSessions } = await import('./session/sync/sync.js');
248
352
  const r = await syncSessions();
@@ -373,6 +477,23 @@ function xmlEscape(s) {
373
477
  .replace(/</g, '&lt;')
374
478
  .replace(/>/g, '&gt;');
375
479
  }
480
+ /**
481
+ * Write a launchd plist or systemd unit with owner-only permissions atomically.
482
+ *
483
+ * `writeFileSync`'s `mode` is honored only when the file is *created*, so we
484
+ * unlink any pre-existing manifest first. That guarantees every write is a
485
+ * fresh 0600 create — closing the TOCTOU window on new files AND re-locking a
486
+ * stale world-readable manifest left by an older install — since these files
487
+ * embed long-lived credentials.
488
+ */
489
+ export function writeOwnerOnlyServiceManifest(filePath, content) {
490
+ const dir = path.dirname(filePath);
491
+ if (!fs.existsSync(dir)) {
492
+ fs.mkdirSync(dir, { recursive: true });
493
+ }
494
+ fs.rmSync(filePath, { force: true });
495
+ fs.writeFileSync(filePath, content, { encoding: 'utf-8', mode: 0o600 });
496
+ }
376
497
  /** Generate a macOS launchd plist for auto-starting the daemon. */
377
498
  export function generateLaunchdPlist() {
378
499
  const agentsBin = getAgentsBinPath();
@@ -448,6 +569,31 @@ function getAgentsBinPath() {
448
569
  return 'agents';
449
570
  }
450
571
  }
572
+ /**
573
+ * Ask the service manager for the daemon's live PID. Used as a fallback when
574
+ * the daemon hasn't yet written its pid file but launchd/systemd already report
575
+ * it running — so a start never has to surface a null PID for a daemon that is
576
+ * in fact up. Returns null when the service isn't running or the query fails.
577
+ */
578
+ function readServiceManagerPid(platform = os.platform()) {
579
+ try {
580
+ if (platform === 'linux') {
581
+ const out = execFileSync('systemctl', ['--user', 'show', '-p', 'MainPID', '--value', SYSTEMD_UNIT], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
582
+ const pid = parseInt(out, 10);
583
+ return !isNaN(pid) && pid > 0 ? pid : null;
584
+ }
585
+ if (platform === 'darwin') {
586
+ const out = execFileSync('launchctl', ['list', PLIST_NAME], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
587
+ const m = out.match(/"PID"\s*=\s*(\d+)/);
588
+ if (m) {
589
+ const pid = parseInt(m[1], 10);
590
+ return pid > 0 ? pid : null;
591
+ }
592
+ }
593
+ }
594
+ catch { /* not running / manager unavailable */ }
595
+ return null;
596
+ }
451
597
  /** Start the daemon via launchd, systemd, or as a detached process. */
452
598
  export function startDaemon() {
453
599
  if (isDaemonRunning()) {
@@ -467,6 +613,24 @@ export function startDaemon() {
467
613
  releaseLock();
468
614
  }
469
615
  }
616
+ /**
617
+ * Bring the always-on daemon up as a side effect of a background-adjacent
618
+ * command (secrets unlock, browser start, ...), not only from `routines add`.
619
+ *
620
+ * Delegates to the single `startDaemon` entrypoint, so it honors the
621
+ * single-instance start lock and is a no-op when a daemon is already running
622
+ * (returns `already-running`). Best-effort: any failure is swallowed and null
623
+ * returned, so ensuring the daemon can never break the foreground command that
624
+ * happened to bring it up. See issue #415.
625
+ */
626
+ export function ensureDaemonStarted() {
627
+ try {
628
+ return startDaemon();
629
+ }
630
+ catch {
631
+ return null;
632
+ }
633
+ }
470
634
  function startDaemonLocked() {
471
635
  const platform = os.platform();
472
636
  if (platform === 'darwin') {
@@ -476,10 +640,9 @@ function startDaemonLocked() {
476
640
  if (!fs.existsSync(plistDir)) {
477
641
  fs.mkdirSync(plistDir, { recursive: true });
478
642
  }
479
- fs.writeFileSync(plistPath, generateLaunchdPlist(), 'utf-8');
480
643
  // The plist may embed a long-lived OAuth token in EnvironmentVariables;
481
- // keep it owner-only so it isn't world/group readable on disk.
482
- fs.chmodSync(plistPath, 0o600);
644
+ // create owner-only atomically (no world-readable window before chmod).
645
+ writeOwnerOnlyServiceManifest(plistPath, generateLaunchdPlist());
483
646
  try {
484
647
  execFileSync('launchctl', ['unload', plistPath], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
485
648
  }
@@ -489,7 +652,7 @@ function startDaemonLocked() {
489
652
  // of success. If no pid materializes within the window, give up on
490
653
  // launchd and fall through to a plain detached spawn.
491
654
  execFileSync('launchctl', ['load', plistPath], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] });
492
- const pid = waitForPid(3000);
655
+ const pid = waitForPid(3000) ?? readServiceManagerPid();
493
656
  if (pid)
494
657
  return { pid, method: 'launchd' };
495
658
  // launchctl claimed success but nothing ran. Fall through.
@@ -506,18 +669,21 @@ function startDaemonLocked() {
506
669
  if (!fs.existsSync(unitDir)) {
507
670
  fs.mkdirSync(unitDir, { recursive: true });
508
671
  }
509
- fs.writeFileSync(unitPath, generateSystemdUnit(), 'utf-8');
510
672
  // May embed a long-lived OAuth token in an Environment= line; owner-only.
511
- fs.chmodSync(unitPath, 0o600);
673
+ writeOwnerOnlyServiceManifest(unitPath, generateSystemdUnit());
512
674
  execFileSync('systemctl', ['--user', 'daemon-reload'], { encoding: 'utf-8' });
513
675
  execFileSync('systemctl', ['--user', 'enable', SYSTEMD_UNIT], { encoding: 'utf-8' });
514
676
  execFileSync('systemctl', ['--user', 'start', SYSTEMD_UNIT], { encoding: 'utf-8' });
515
- const pid = waitForPid(3000);
516
- return { pid, method: 'systemd' };
677
+ const pid = waitForPid(3000) ?? readServiceManagerPid();
678
+ if (pid)
679
+ return { pid, method: 'systemd' };
680
+ // systemctl returned success but no PID surfaced — fall through to a
681
+ // plain detached spawn rather than reporting a null PID.
517
682
  }
518
683
  catch {
519
- return startDetached();
684
+ // start threw — fall through to detached spawn
520
685
  }
686
+ return startDetached();
521
687
  }
522
688
  return startDetached();
523
689
  }
@@ -567,17 +733,29 @@ export function startDetached(opts = {}) {
567
733
  const logPath = opts.logPath ?? getLogPath();
568
734
  const logFd = fs.openSync(logPath, 'a');
569
735
  const { command, args } = getDaemonLaunch(agentsBin);
736
+ // fdStdio: the log-file fds make windowsHide inert (libuv skips
737
+ // CREATE_NO_WINDOW when a stdio fd is inherited), so on Windows the daemon
738
+ // must DETACH to own no console — otherwise it shares the launcher's console
739
+ // and a console-close event tears it down when the launcher exits (#556).
570
740
  const child = spawn(command, args, {
571
741
  stdio: ['ignore', logFd, logFd],
572
- detached: true,
573
- // No console window on Windows — a hidden, consoleless process can't be
574
- // torn down by a console-close event when its launcher exits (#556).
575
- windowsHide: true,
742
+ ...backgroundSpawnOptions({ fdStdio: true }),
576
743
  env: opts.env ?? buildDetachedDaemonEnv(),
577
744
  });
745
+ // A failed spawn (ENOENT/EACCES) emits 'error' asynchronously; without a
746
+ // listener that would crash the parent as an unhandled EventEmitter error.
747
+ // The synchronous `!child.pid` guard below is what reports the failure loudly.
748
+ child.on('error', () => { });
578
749
  child.unref();
579
750
  fs.closeSync(logFd);
580
- return { pid: child.pid || null, method: 'detached' };
751
+ // `spawn` leaves `pid` undefined only when the process could not be created.
752
+ // Returning null here (the old `child.pid || null`) let callers report
753
+ // "PID: null" as if the daemon had started — a start with no PID is a failed
754
+ // start, so fail loudly instead of manufacturing a phantom success.
755
+ if (!child.pid) {
756
+ throw new Error(`Failed to start daemon: spawning '${command}' produced no PID (binary missing or not executable?)`);
757
+ }
758
+ return { pid: child.pid, method: 'detached' };
581
759
  }
582
760
  function waitForPid(timeoutMs) {
583
761
  const start = Date.now();
@@ -58,6 +58,13 @@ export declare function shellForPlatform(platform: DevicePlatform): DeviceShell;
58
58
  * returning {} would let the next write wipe the user's device list.
59
59
  */
60
60
  export declare function loadDevices(): Promise<DeviceRegistry>;
61
+ /**
62
+ * Synchronous {@link loadDevices} for callers already on a sync path (e.g. the
63
+ * `agents sessions --host` fan-out builds its ssh command strings synchronously
64
+ * and only needs the target's platform to pick POSIX vs PowerShell). Same
65
+ * missing-file/corruption contract as {@link loadDevices}.
66
+ */
67
+ export declare function loadDevicesSync(): DeviceRegistry;
61
68
  /** Get a single device profile, or null if it is not registered. */
62
69
  export declare function getDevice(name: string): Promise<DeviceProfile | null>;
63
70
  /** Fields a caller may supply when creating or updating a device. */
@@ -117,6 +117,30 @@ export async function loadDevices() {
117
117
  throw new Error(`Device registry corrupted at ${p}: ${err?.message ?? err}. Inspect and restore from backup.`);
118
118
  }
119
119
  }
120
+ /**
121
+ * Synchronous {@link loadDevices} for callers already on a sync path (e.g. the
122
+ * `agents sessions --host` fan-out builds its ssh command strings synchronously
123
+ * and only needs the target's platform to pick POSIX vs PowerShell). Same
124
+ * missing-file/corruption contract as {@link loadDevices}.
125
+ */
126
+ export function loadDevicesSync() {
127
+ const p = registryPath();
128
+ let raw;
129
+ try {
130
+ raw = fsSync.readFileSync(p, 'utf-8');
131
+ }
132
+ catch (err) {
133
+ if (err && err.code === 'ENOENT')
134
+ return {};
135
+ throw err;
136
+ }
137
+ try {
138
+ return JSON.parse(raw);
139
+ }
140
+ catch (err) {
141
+ throw new Error(`Device registry corrupted at ${p}: ${err?.message ?? err}. Inspect and restore from backup.`);
142
+ }
143
+ }
120
144
  async function saveDevices(reg) {
121
145
  await atomicWriteJson(registryPath(), reg);
122
146
  }
@@ -0,0 +1,24 @@
1
+ import { type DeviceRegistry } from './registry.js';
2
+ /** A dialable peer: the ssh target, the machine id used to tag its rows, a
3
+ * display name, and the OS family that picks the remote shell dialect. */
4
+ export interface ResolvedSshTarget {
5
+ target: string;
6
+ machine: string;
7
+ name: string;
8
+ os?: string;
9
+ }
10
+ /**
11
+ * Resolve one `--host`/`--device` token to a concrete ssh target through the
12
+ * registry. Registry hit → the device's real address + platform (so the machine
13
+ * id, route, and OS all match the auto-discovery sweep). Miss → a literal
14
+ * `user@host` fallback, its OS taken from the host overlay if enrolled. Returns
15
+ * undefined only when the token fails the shared ssh-target injection guard.
16
+ */
17
+ export declare function resolveSshTarget(token: string, reg: DeviceRegistry): ResolvedSshTarget | undefined;
18
+ /**
19
+ * Resolve an explicit `--host`/`--device` list to dialable targets, reading the
20
+ * registry once. A token that fails the injection guard is skipped with a
21
+ * stderr note (never fatal — one bad token must not blank the fan-out). Shared
22
+ * by every cross-machine fan-out so they can never diverge onto two routes.
23
+ */
24
+ export declare function resolveExplicitTargets(hosts: string[]): Promise<ResolvedSshTarget[]>;
@@ -0,0 +1,80 @@
1
+ /**
2
+ * The one place a `--host` / `--device` token becomes a real ssh target.
3
+ *
4
+ * A device and a host are the same thing addressed two ways, so resolution must
5
+ * go through the device registry — the single source of truth. A token that
6
+ * names a registered device dials that device's real address (its Tailscale
7
+ * dnsName/ip + user, via `sshTargetFor`), *identical* to the auto-discovery
8
+ * sweep and `agents ssh`. Before this module, the explicit `--host` fan-out
9
+ * instead passed the bare token straight to `ssh`, so `--host yosemite-s0`
10
+ * dialed whatever `~/.ssh/config`/LAN DNS resolved `yosemite-s0` to — a
11
+ * different route than the sweep's `yosemite-s0.<tailnet>.ts.net`. That
12
+ * divergence broke ControlMaster socket reuse (different target → different
13
+ * `%C` hash → a cold dial every time) and could read a perfectly reachable box
14
+ * as "unreachable" when only the non-Tailscale route was down.
15
+ *
16
+ * A raw `user@host` that matches no registered device falls back to a literal
17
+ * target so ad-hoc boxes still work.
18
+ */
19
+ import chalk from 'chalk';
20
+ import { assertValidSshTarget } from '../ssh-exec.js';
21
+ import { normalizeHost } from '../machine-id.js';
22
+ import { resolveRemoteOsSync } from '../hosts/remote-os.js';
23
+ import { sshTargetFor } from './connect.js';
24
+ import { loadDevices } from './registry.js';
25
+ /**
26
+ * Resolve one `--host`/`--device` token to a concrete ssh target through the
27
+ * registry. Registry hit → the device's real address + platform (so the machine
28
+ * id, route, and OS all match the auto-discovery sweep). Miss → a literal
29
+ * `user@host` fallback, its OS taken from the host overlay if enrolled. Returns
30
+ * undefined only when the token fails the shared ssh-target injection guard.
31
+ */
32
+ export function resolveSshTarget(token, reg) {
33
+ try {
34
+ assertValidSshTarget(token);
35
+ }
36
+ catch {
37
+ return undefined;
38
+ }
39
+ const bare = token.split('@').pop() || token;
40
+ // An explicit `user@host` names an exact account/target — honour it literally.
41
+ // A bare alias (`yosemite-s0`) resolves through the registry to the device's
42
+ // real address, so it never diverges from the auto-discovery sweep.
43
+ const device = token.includes('@')
44
+ ? undefined
45
+ : reg[token] ?? Object.values(reg).find((d) => normalizeHost(d.name) === normalizeHost(bare));
46
+ if (device) {
47
+ try {
48
+ return { target: sshTargetFor(device), machine: normalizeHost(device.name), name: device.name, os: device.platform };
49
+ }
50
+ catch {
51
+ // Registered but has no address to dial — fall through to the literal token.
52
+ }
53
+ }
54
+ return { target: token, machine: normalizeHost(bare), name: token, os: resolveRemoteOsSync(token) };
55
+ }
56
+ /**
57
+ * Resolve an explicit `--host`/`--device` list to dialable targets, reading the
58
+ * registry once. A token that fails the injection guard is skipped with a
59
+ * stderr note (never fatal — one bad token must not blank the fan-out). Shared
60
+ * by every cross-machine fan-out so they can never diverge onto two routes.
61
+ */
62
+ export async function resolveExplicitTargets(hosts) {
63
+ let reg;
64
+ try {
65
+ reg = await loadDevices();
66
+ }
67
+ catch {
68
+ reg = {};
69
+ }
70
+ const out = [];
71
+ for (const h of hosts) {
72
+ const resolved = resolveSshTarget(h, reg);
73
+ if (!resolved) {
74
+ process.stderr.write(chalk.gray(` ${h}: not a valid ssh target — skipped\n`));
75
+ continue;
76
+ }
77
+ out.push(resolved);
78
+ }
79
+ return out;
80
+ }
@@ -115,7 +115,7 @@ export function nodeToDeviceInput(node) {
115
115
  * error when the binary is missing or the daemon is not reachable.
116
116
  */
117
117
  export function tailscaleStatusJson() {
118
- const res = spawnSync('tailscale', ['status', '--json'], { encoding: 'utf-8' });
118
+ const res = spawnSync('tailscale', ['status', '--json'], { encoding: 'utf-8', windowsHide: true });
119
119
  if (res.error && res.error.code === 'ENOENT') {
120
120
  throw new Error('tailscale not found on PATH. Install Tailscale, or add devices manually with `agents devices add`.');
121
121
  }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Shared drift-detection internals for `agents doctor` (overview mode) and
3
+ * `agents check` (the scriptable, CI-friendly gate).
4
+ *
5
+ * This is the single source of truth for "is the install out of sync?": the
6
+ * per-version sync status (fresh / stale / never-synced) and the orphan census.
7
+ * `agents doctor` renders it as a human report; `agents check` reduces it to an
8
+ * exit code. Neither reimplements the diagnostic — both call `computeDrift`.
9
+ */
10
+ import type { AgentId } from './types.js';
11
+ import { type VersionResourceReport } from './doctor-diff.js';
12
+ export interface SyncStatusRow {
13
+ agent: AgentId;
14
+ version: string;
15
+ status: 'fresh' | 'stale' | 'never-synced';
16
+ isDefault: boolean;
17
+ /** For stale rows: prioritized lines naming exactly what diverged (plugins first). */
18
+ divergence?: string[];
19
+ }
20
+ export interface OrphanRow {
21
+ agent: AgentId;
22
+ version: string;
23
+ commands: number;
24
+ skills: number;
25
+ hooks: number;
26
+ }
27
+ export declare function divergenceLines(report: VersionResourceReport): string[];
28
+ export declare function checkSyncStatus(cwd: string): SyncStatusRow[];
29
+ export declare function countOrphans(): OrphanRow[];
30
+ export interface DriftSummary {
31
+ syncRows: SyncStatusRow[];
32
+ orphanRows: OrphanRow[];
33
+ /** Versions whose sources changed since last sync. */
34
+ staleCount: number;
35
+ /** Versions installed but never synced. */
36
+ neverSyncedCount: number;
37
+ /** Versions carrying orphan resources (informational — not a drift signal). */
38
+ orphanVersionCount: number;
39
+ /**
40
+ * True when any installed version is stale or never-synced — the exact signal
41
+ * `agents doctor` surfaces as "run `agents status` to review what has drifted".
42
+ * Orphans are a `prune` concern, not sync drift, so they do NOT set this flag
43
+ * (mirrors the sync-status engine: an orphan alone never flags needsSync).
44
+ */
45
+ hasDrift: boolean;
46
+ }
47
+ /**
48
+ * Compute the same drift/divergence diagnostic `agents doctor` prints, reduced
49
+ * to a summary with a single `hasDrift` boolean. The gate `agents check` maps
50
+ * to an exit code; the readout `agents doctor` renders in full.
51
+ */
52
+ export declare function computeDrift(cwd: string): DriftSummary;
@@ -0,0 +1,112 @@
1
+ import { ALL_AGENT_IDS } from './agents.js';
2
+ import { getGlobalDefault, listInstalledVersions } from './versions.js';
3
+ import { loadManifest, isStale } from './staleness/index.js';
4
+ import { diffVersionResources } from './doctor-diff.js';
5
+ import { diffVersionCommands, iterCommandsCapableVersions } from './commands.js';
6
+ import { diffVersionSkills, iterSkillsCapableVersions } from './skills.js';
7
+ import { iterHooksCapableVersions, listUnmanagedHooksInVersionHome } from './hooks.js';
8
+ // Lines naming exactly what's out of sync for a version, plugins prioritized:
9
+ // each divergent plugin gets its own line with specifics (stale mirror version,
10
+ // invalid manifest, or the bundled skills/commands missing from the mirror —
11
+ // the system-repo plugin content that matters most). Other kinds collapse to
12
+ // compact counts so the readout stays scannable.
13
+ export function divergenceLines(report) {
14
+ const lines = [];
15
+ for (const p of report.kinds.plugins) {
16
+ if (p.status === 'missing')
17
+ lines.push(`plugin ${p.name} — not installed`);
18
+ else if (p.status === 'diff')
19
+ lines.push(`plugin ${p.name} — ${p.detail ?? 'mirror drifted'}`);
20
+ }
21
+ for (const kind of ['commands', 'skills', 'hooks', 'rules', 'mcp', 'permissions', 'subagents']) {
22
+ const rows = report.kinds[kind];
23
+ const miss = rows.filter((r) => r.status === 'missing').length;
24
+ const dif = rows.filter((r) => r.status === 'diff').length;
25
+ const bits = [];
26
+ if (miss)
27
+ bits.push(`${miss} missing`);
28
+ if (dif)
29
+ bits.push(`${dif} drifted`);
30
+ if (bits.length)
31
+ lines.push(`${kind.padEnd(11)} ${bits.join(' · ')}`);
32
+ }
33
+ return lines;
34
+ }
35
+ export function checkSyncStatus(cwd) {
36
+ const rows = [];
37
+ // Every installed version, not just the default — a stale NON-default version
38
+ // (e.g. one you launched from yesterday) is exactly the rot that silently
39
+ // serves outdated/invalid resources and that `--fix` now heals. Hiding it here
40
+ // is why that class of bug went unnoticed.
41
+ for (const agent of ALL_AGENT_IDS) {
42
+ const def = getGlobalDefault(agent);
43
+ for (const version of listInstalledVersions(agent)) {
44
+ const manifest = loadManifest(agent, version);
45
+ const status = !manifest
46
+ ? 'never-synced'
47
+ : isStale(manifest, agent, version, cwd) ? 'stale' : 'fresh';
48
+ const row = { agent, version, status, isDefault: version === def };
49
+ if (status === 'stale') {
50
+ // Resolve the specifics against non-project layers (the global home is
51
+ // never reconciled against per-cwd project resources).
52
+ const report = diffVersionResources(agent, version, { cwd, excludeProject: true });
53
+ const lines = divergenceLines(report);
54
+ if (lines.length)
55
+ row.divergence = lines;
56
+ }
57
+ rows.push(row);
58
+ }
59
+ }
60
+ return rows;
61
+ }
62
+ export function countOrphans() {
63
+ const byKey = new Map();
64
+ const ensure = (agent, version) => {
65
+ const key = `${agent}@${version}`;
66
+ let row = byKey.get(key);
67
+ if (!row) {
68
+ row = { agent, version, commands: 0, skills: 0, hooks: 0 };
69
+ byKey.set(key, row);
70
+ }
71
+ return row;
72
+ };
73
+ for (const { agent, version } of iterCommandsCapableVersions()) {
74
+ const diff = diffVersionCommands(agent, version);
75
+ if (diff.orphans.length > 0)
76
+ ensure(agent, version).commands = diff.orphans.length;
77
+ }
78
+ for (const { agent, version } of iterSkillsCapableVersions()) {
79
+ const diff = diffVersionSkills(agent, version);
80
+ if (diff.orphans.length > 0)
81
+ ensure(agent, version).skills = diff.orphans.length;
82
+ }
83
+ // Orphan hooks are scripts in the version home that no agents.yaml/hooks.yaml
84
+ // entry registers — so the registrar never wires them to an event and they
85
+ // never fire. (Distinct from the source-diff `diffVersionHooks().orphans`,
86
+ // which false-flags valid system-sourced registered hooks.)
87
+ for (const { agent, version } of iterHooksCapableVersions()) {
88
+ const dead = listUnmanagedHooksInVersionHome(agent, version);
89
+ if (dead.length > 0)
90
+ ensure(agent, version).hooks = dead.length;
91
+ }
92
+ return Array.from(byKey.values()).filter((r) => r.commands + r.skills + r.hooks > 0);
93
+ }
94
+ /**
95
+ * Compute the same drift/divergence diagnostic `agents doctor` prints, reduced
96
+ * to a summary with a single `hasDrift` boolean. The gate `agents check` maps
97
+ * to an exit code; the readout `agents doctor` renders in full.
98
+ */
99
+ export function computeDrift(cwd) {
100
+ const syncRows = checkSyncStatus(cwd);
101
+ const orphanRows = countOrphans();
102
+ const staleCount = syncRows.filter((r) => r.status === 'stale').length;
103
+ const neverSyncedCount = syncRows.filter((r) => r.status === 'never-synced').length;
104
+ return {
105
+ syncRows,
106
+ orphanRows,
107
+ staleCount,
108
+ neverSyncedCount,
109
+ orphanVersionCount: orphanRows.length,
110
+ hasDrift: syncRows.some((r) => r.status !== 'fresh'),
111
+ };
112
+ }
@@ -177,4 +177,4 @@ export declare function getTimingStats(label: string, options?: {
177
177
  p50Ms: number;
178
178
  p95Ms: number;
179
179
  } | null;
180
- export declare const LOGS_PATH: string;
180
+ export declare function getLogsPath(): string;