@phnx-labs/agents-cli 1.20.34 → 1.20.36

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 (291) hide show
  1. package/CHANGELOG.md +113 -0
  2. package/LICENSE +185 -21
  3. package/README.md +13 -5
  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-actions.d.ts +15 -0
  11. package/dist/commands/computer-actions.js +120 -22
  12. package/dist/commands/computer.js +81 -0
  13. package/dist/commands/cost.js +17 -12
  14. package/dist/commands/daemon.js +4 -1
  15. package/dist/commands/doctor.d.ts +1 -0
  16. package/dist/commands/doctor.js +54 -97
  17. package/dist/commands/events.d.ts +16 -0
  18. package/dist/commands/events.js +173 -0
  19. package/dist/commands/exec.d.ts +19 -0
  20. package/dist/commands/exec.js +283 -12
  21. package/dist/commands/hosts.js +37 -29
  22. package/dist/commands/inspect.d.ts +2 -0
  23. package/dist/commands/inspect.js +62 -16
  24. package/dist/commands/lock.d.ts +12 -0
  25. package/dist/commands/lock.js +70 -0
  26. package/dist/commands/logs.d.ts +17 -0
  27. package/dist/commands/logs.js +139 -0
  28. package/dist/commands/message.d.ts +15 -0
  29. package/dist/commands/message.js +56 -0
  30. package/dist/commands/models.d.ts +3 -0
  31. package/dist/commands/models.js +27 -8
  32. package/dist/commands/permissions.js +9 -2
  33. package/dist/commands/repo.d.ts +34 -0
  34. package/dist/commands/repo.js +243 -65
  35. package/dist/commands/resource-view.d.ts +20 -0
  36. package/dist/commands/resource-view.js +90 -28
  37. package/dist/commands/routines.js +101 -5
  38. package/dist/commands/secrets-migrate.js +106 -57
  39. package/dist/commands/secrets.d.ts +31 -18
  40. package/dist/commands/secrets.js +196 -91
  41. package/dist/commands/serve.d.ts +10 -0
  42. package/dist/commands/serve.js +37 -0
  43. package/dist/commands/sessions-inject.d.ts +14 -0
  44. package/dist/commands/sessions-inject.js +111 -0
  45. package/dist/commands/sessions-picker.d.ts +2 -0
  46. package/dist/commands/sessions-picker.js +24 -3
  47. package/dist/commands/sessions-resume.d.ts +2 -0
  48. package/dist/commands/sessions-resume.js +221 -0
  49. package/dist/commands/sessions-tail.d.ts +10 -0
  50. package/dist/commands/sessions-tail.js +11 -0
  51. package/dist/commands/sessions.d.ts +143 -1
  52. package/dist/commands/sessions.js +628 -80
  53. package/dist/commands/setup.js +4 -2
  54. package/dist/commands/sync.d.ts +3 -1
  55. package/dist/commands/sync.js +156 -4
  56. package/dist/commands/teams.js +217 -0
  57. package/dist/commands/versions.js +2 -4
  58. package/dist/commands/view.d.ts +3 -0
  59. package/dist/commands/view.js +19 -8
  60. package/dist/commands/wallet.d.ts +6 -0
  61. package/dist/commands/wallet.js +22 -5
  62. package/dist/commands/watchdog.d.ts +18 -0
  63. package/dist/commands/watchdog.js +238 -0
  64. package/dist/index.js +82 -32
  65. package/dist/lib/agents.js +30 -9
  66. package/dist/lib/audit/log.d.ts +92 -0
  67. package/dist/lib/audit/log.js +177 -0
  68. package/dist/lib/auto-pull.js +2 -1
  69. package/dist/lib/browser/cdp.js +5 -1
  70. package/dist/lib/browser/chrome.d.ts +10 -0
  71. package/dist/lib/browser/chrome.js +18 -7
  72. package/dist/lib/browser/drivers/ssh.d.ts +8 -0
  73. package/dist/lib/browser/drivers/ssh.js +77 -16
  74. package/dist/lib/browser/har.d.ts +84 -0
  75. package/dist/lib/browser/har.js +77 -0
  76. package/dist/lib/browser/ipc.d.ts +3 -0
  77. package/dist/lib/browser/ipc.js +37 -12
  78. package/dist/lib/browser/profiles.d.ts +1 -1
  79. package/dist/lib/browser/profiles.js +8 -10
  80. package/dist/lib/browser/refs.d.ts +65 -0
  81. package/dist/lib/browser/refs.js +73 -1
  82. package/dist/lib/browser/runtime-state.js +1 -0
  83. package/dist/lib/browser/service.d.ts +38 -2
  84. package/dist/lib/browser/service.js +183 -23
  85. package/dist/lib/browser/types.d.ts +14 -1
  86. package/dist/lib/budget/live-cloud.d.ts +42 -0
  87. package/dist/lib/budget/live-cloud.js +79 -0
  88. package/dist/lib/budget/live-team.d.ts +31 -0
  89. package/dist/lib/budget/live-team.js +115 -0
  90. package/dist/lib/cloud/codex.js +4 -0
  91. package/dist/lib/cloud/rush.d.ts +12 -1
  92. package/dist/lib/cloud/rush.js +13 -3
  93. package/dist/lib/cloud/types.d.ts +9 -0
  94. package/dist/lib/computer/dispatch.d.ts +8 -0
  95. package/dist/lib/computer/dispatch.js +125 -0
  96. package/dist/lib/computer/loop.d.ts +62 -0
  97. package/dist/lib/computer/loop.js +98 -0
  98. package/dist/lib/computer/model.d.ts +44 -0
  99. package/dist/lib/computer/model.js +157 -0
  100. package/dist/lib/concurrency.d.ts +19 -0
  101. package/dist/lib/concurrency.js +33 -0
  102. package/dist/lib/daemon.d.ts +92 -0
  103. package/dist/lib/daemon.js +222 -18
  104. package/dist/lib/devices/connect.d.ts +3 -2
  105. package/dist/lib/devices/connect.js +5 -3
  106. package/dist/lib/devices/registry.d.ts +7 -0
  107. package/dist/lib/devices/registry.js +24 -0
  108. package/dist/lib/devices/tailscale.js +1 -1
  109. package/dist/lib/drift.d.ts +52 -0
  110. package/dist/lib/drift.js +112 -0
  111. package/dist/lib/events.d.ts +10 -3
  112. package/dist/lib/events.js +101 -24
  113. package/dist/lib/exec.d.ts +17 -0
  114. package/dist/lib/exec.js +100 -12
  115. package/dist/lib/git.d.ts +27 -0
  116. package/dist/lib/git.js +56 -1
  117. package/dist/lib/hooks/cache.d.ts +6 -0
  118. package/dist/lib/hooks/cache.js +54 -12
  119. package/dist/lib/hooks.d.ts +27 -0
  120. package/dist/lib/hooks.js +135 -10
  121. package/dist/lib/hosts/dispatch.d.ts +15 -0
  122. package/dist/lib/hosts/dispatch.js +45 -13
  123. package/dist/lib/hosts/logs.d.ts +16 -0
  124. package/dist/lib/hosts/logs.js +74 -0
  125. package/dist/lib/hosts/option.js +1 -1
  126. package/dist/lib/hosts/passthrough.js +3 -1
  127. package/dist/lib/hosts/progress.d.ts +66 -0
  128. package/dist/lib/hosts/progress.js +125 -17
  129. package/dist/lib/hosts/ready.d.ts +51 -7
  130. package/dist/lib/hosts/ready.js +98 -25
  131. package/dist/lib/hosts/reconcile.d.ts +53 -0
  132. package/dist/lib/hosts/reconcile.js +81 -0
  133. package/dist/lib/hosts/registry.d.ts +19 -2
  134. package/dist/lib/hosts/registry.js +58 -2
  135. package/dist/lib/hosts/remote-cmd.d.ts +62 -1
  136. package/dist/lib/hosts/remote-cmd.js +70 -1
  137. package/dist/lib/hosts/remote-os.d.ts +17 -0
  138. package/dist/lib/hosts/remote-os.js +30 -0
  139. package/dist/lib/hosts/session-index.d.ts +34 -0
  140. package/dist/lib/hosts/session-index.js +56 -0
  141. package/dist/lib/hosts/tasks.d.ts +22 -0
  142. package/dist/lib/hosts/tasks.js +29 -0
  143. package/dist/lib/lock.d.ts +93 -0
  144. package/dist/lib/lock.js +207 -0
  145. package/dist/lib/loop.js +16 -1
  146. package/dist/lib/machine-id.d.ts +21 -0
  147. package/dist/lib/machine-id.js +26 -0
  148. package/dist/lib/mailbox-target.d.ts +36 -0
  149. package/dist/lib/mailbox-target.js +45 -0
  150. package/dist/lib/mailbox.d.ts +47 -0
  151. package/dist/lib/mailbox.js +194 -0
  152. package/dist/lib/mcp.d.ts +5 -0
  153. package/dist/lib/mcp.js +24 -8
  154. package/dist/lib/migrate.d.ts +19 -0
  155. package/dist/lib/migrate.js +134 -26
  156. package/dist/lib/overdue.js +3 -0
  157. package/dist/lib/permissions.d.ts +4 -0
  158. package/dist/lib/permissions.js +35 -0
  159. package/dist/lib/picker.d.ts +26 -0
  160. package/dist/lib/picker.js +127 -0
  161. package/dist/lib/platform/exec.d.ts +46 -0
  162. package/dist/lib/platform/exec.js +74 -0
  163. package/dist/lib/platform/process.d.ts +31 -0
  164. package/dist/lib/platform/process.js +34 -1
  165. package/dist/lib/platform/winpath.js +2 -0
  166. package/dist/lib/plugin-marketplace.d.ts +30 -0
  167. package/dist/lib/plugin-marketplace.js +215 -2
  168. package/dist/lib/plugins.d.ts +5 -0
  169. package/dist/lib/plugins.js +61 -10
  170. package/dist/lib/profiles.d.ts +25 -0
  171. package/dist/lib/profiles.js +22 -6
  172. package/dist/lib/pty-client.js +2 -1
  173. package/dist/lib/rotate.d.ts +61 -0
  174. package/dist/lib/rotate.js +52 -0
  175. package/dist/lib/routines.d.ts +40 -2
  176. package/dist/lib/routines.js +66 -8
  177. package/dist/lib/runner.d.ts +11 -2
  178. package/dist/lib/runner.js +49 -7
  179. package/dist/lib/scheduler.js +6 -1
  180. package/dist/lib/secrets/bundles.d.ts +60 -4
  181. package/dist/lib/secrets/bundles.js +131 -12
  182. package/dist/lib/secrets/filestore.d.ts +3 -0
  183. package/dist/lib/secrets/filestore.js +42 -16
  184. package/dist/lib/secrets/index.d.ts +43 -2
  185. package/dist/lib/secrets/index.js +102 -3
  186. package/dist/lib/secrets/mcp.d.ts +93 -0
  187. package/dist/lib/secrets/mcp.js +205 -0
  188. package/dist/lib/secrets/remote.js +12 -5
  189. package/dist/lib/secrets/sync.js +83 -4
  190. package/dist/lib/secrets/windows.js +14 -3
  191. package/dist/lib/serve/data.d.ts +81 -0
  192. package/dist/lib/serve/data.js +91 -0
  193. package/dist/lib/serve/page.d.ts +7 -0
  194. package/dist/lib/serve/page.js +140 -0
  195. package/dist/lib/serve/server.d.ts +46 -0
  196. package/dist/lib/serve/server.js +115 -0
  197. package/dist/lib/session/active.d.ts +61 -0
  198. package/dist/lib/session/active.js +196 -17
  199. package/dist/lib/session/discover.d.ts +37 -0
  200. package/dist/lib/session/discover.js +204 -27
  201. package/dist/lib/session/inject.d.ts +18 -0
  202. package/dist/lib/session/inject.js +21 -0
  203. package/dist/lib/session/parse.d.ts +6 -0
  204. package/dist/lib/session/parse.js +286 -2
  205. package/dist/lib/session/pid-registry.d.ts +22 -0
  206. package/dist/lib/session/pid-registry.js +104 -0
  207. package/dist/lib/session/provenance.d.ts +14 -2
  208. package/dist/lib/session/provenance.js +39 -8
  209. package/dist/lib/session/remote-active.d.ts +26 -0
  210. package/dist/lib/session/remote-active.js +153 -0
  211. package/dist/lib/session/remote-list.d.ts +51 -0
  212. package/dist/lib/session/remote-list.js +213 -0
  213. package/dist/lib/session/remote.d.ts +7 -1
  214. package/dist/lib/session/remote.js +18 -8
  215. package/dist/lib/session/render.js +1 -1
  216. package/dist/lib/session/sync/config.d.ts +1 -7
  217. package/dist/lib/session/sync/config.js +4 -11
  218. package/dist/lib/session/types.d.ts +18 -1
  219. package/dist/lib/session/types.js +1 -1
  220. package/dist/lib/shims.d.ts +44 -6
  221. package/dist/lib/shims.js +143 -57
  222. package/dist/lib/ssh-exec.d.ts +26 -3
  223. package/dist/lib/ssh-exec.js +47 -3
  224. package/dist/lib/ssh-tunnel.d.ts +24 -5
  225. package/dist/lib/ssh-tunnel.js +62 -63
  226. package/dist/lib/staleness/writers/hooks.js +1 -1
  227. package/dist/lib/startup/command-registry.d.ts +8 -0
  228. package/dist/lib/startup/command-registry.js +17 -1
  229. package/dist/lib/state.d.ts +13 -0
  230. package/dist/lib/state.js +103 -9
  231. package/dist/lib/sync-umbrella.d.ts +14 -7
  232. package/dist/lib/sync-umbrella.js +17 -9
  233. package/dist/lib/teams/forEach.d.ts +110 -0
  234. package/dist/lib/teams/forEach.js +186 -0
  235. package/dist/lib/teams/index.d.ts +1 -0
  236. package/dist/lib/teams/index.js +1 -0
  237. package/dist/lib/teams/pr-watch.d.ts +226 -0
  238. package/dist/lib/teams/pr-watch.js +371 -0
  239. package/dist/lib/teams/registry.js +25 -9
  240. package/dist/lib/teams/supervisor.d.ts +14 -1
  241. package/dist/lib/teams/supervisor.js +19 -0
  242. package/dist/lib/teams/worktree.d.ts +9 -0
  243. package/dist/lib/teams/worktree.js +32 -0
  244. package/dist/lib/terminal/backends/ghostty.d.ts +6 -0
  245. package/dist/lib/terminal/backends/ghostty.js +69 -0
  246. package/dist/lib/terminal/backends/index.d.ts +19 -0
  247. package/dist/lib/terminal/backends/index.js +31 -0
  248. package/dist/lib/terminal/backends/iterm.d.ts +6 -0
  249. package/dist/lib/terminal/backends/iterm.js +62 -0
  250. package/dist/lib/terminal/backends/tmux.d.ts +14 -0
  251. package/dist/lib/terminal/backends/tmux.js +23 -0
  252. package/dist/lib/terminal/backends/vscodium-agent.d.ts +31 -0
  253. package/dist/lib/terminal/backends/vscodium-agent.js +72 -0
  254. package/dist/lib/terminal/engine.d.ts +39 -0
  255. package/dist/lib/terminal/engine.js +54 -0
  256. package/dist/lib/terminal/index.d.ts +17 -0
  257. package/dist/lib/terminal/index.js +8 -0
  258. package/dist/lib/terminal/inject.d.ts +204 -0
  259. package/dist/lib/terminal/inject.js +247 -0
  260. package/dist/lib/terminal/policy.d.ts +11 -0
  261. package/dist/lib/terminal/policy.js +11 -0
  262. package/dist/lib/terminal/quote.d.ts +11 -0
  263. package/dist/lib/terminal/quote.js +13 -0
  264. package/dist/lib/terminal/resolve.d.ts +64 -0
  265. package/dist/lib/terminal/resolve.js +90 -0
  266. package/dist/lib/terminal/shell.d.ts +6 -0
  267. package/dist/lib/terminal/shell.js +23 -0
  268. package/dist/lib/terminal/transport.d.ts +15 -0
  269. package/dist/lib/terminal/transport.js +40 -0
  270. package/dist/lib/terminal/types.d.ts +59 -0
  271. package/dist/lib/terminal/types.js +13 -0
  272. package/dist/lib/triggers/webhook.d.ts +85 -0
  273. package/dist/lib/triggers/webhook.js +141 -0
  274. package/dist/lib/types.d.ts +7 -0
  275. package/dist/lib/versions.d.ts +30 -3
  276. package/dist/lib/versions.js +129 -18
  277. package/dist/lib/watchdog/index.d.ts +3 -0
  278. package/dist/lib/watchdog/index.js +5 -0
  279. package/dist/lib/watchdog/read.d.ts +35 -0
  280. package/dist/lib/watchdog/read.js +149 -0
  281. package/dist/lib/watchdog/runner.d.ts +127 -0
  282. package/dist/lib/watchdog/runner.js +322 -0
  283. package/dist/lib/watchdog/watchdog.d.ts +40 -0
  284. package/dist/lib/watchdog/watchdog.js +166 -0
  285. package/dist/lib/watchdog/watchdogTail.d.ts +5 -0
  286. package/dist/lib/watchdog/watchdogTail.js +154 -0
  287. package/dist/lib/whats-new.d.ts +9 -0
  288. package/dist/lib/whats-new.js +35 -0
  289. package/dist/lib/workflows.d.ts +166 -0
  290. package/dist/lib/workflows.js +193 -0
  291. package/package.json +5 -4
@@ -14,6 +14,39 @@ export declare function writeDaemonPid(pid: number): void;
14
14
  export declare function removeDaemonPid(): void;
15
15
  /** Check if the daemon process is alive by sending signal 0 to the stored PID. */
16
16
  export declare function isDaemonRunning(): boolean;
17
+ /**
18
+ * Single-instance claim for the daemon `_run` entrypoint.
19
+ *
20
+ * `agents daemon _run` is reachable directly — a manual invocation, or a
21
+ * service-manager restart that races a still-alive predecessor — bypassing the
22
+ * start lock in startDaemon(). Without this guard runDaemon() would call
23
+ * writeDaemonPid() unconditionally, clobber a live daemon's recorded PID, and
24
+ * run a second JobScheduler concurrently, so every cron routine fires twice.
25
+ *
26
+ * Returns true and records our PID when no other live daemon owns the pid file;
27
+ * returns false when a live daemon already holds it (the caller must exit
28
+ * without touching any further state). The read-decide-write is serialized
29
+ * behind the same O_EXCL start lock startDaemon() uses, so two _run processes
30
+ * can't both claim in the window between the liveness check and the write.
31
+ */
32
+ export declare function claimDaemonInstance(): boolean;
33
+ /**
34
+ * Reap stray duplicate daemon processes — a `daemon _run` of THIS install that
35
+ * isn't this process and isn't the pid-file owner. Mirrors the browser orphan
36
+ * reaper (below): a predecessor that was SIGKILLed/OOM-ed without cleaning up,
37
+ * or a duplicate that lost the pid-file write race, would otherwise keep a
38
+ * second scheduler alive and double-fire jobs even after claimDaemonInstance()
39
+ * hands the pid file to the survivor.
40
+ *
41
+ * Scoped to our own launch entry (process.argv[1]) so it only ever targets
42
+ * daemons of the same installation — a daemon from a different install / home
43
+ * (e.g. a side-by-side dev build, or a test fixture) is a legitimately separate
44
+ * instance and is left untouched. POSIX-only (uses `ps`); a no-op on Windows.
45
+ */
46
+ export declare function reapStrayDaemons(keepPid?: number): {
47
+ reaped: number;
48
+ details: string[];
49
+ };
17
50
  /** Append a JSONL log entry to the daemon log file (owner-only permissions). */
18
51
  export declare function log(level: string, message: string): void;
19
52
  /** Main daemon loop: load jobs, schedule crons, monitor runs, and handle signals. */
@@ -28,6 +61,16 @@ export declare function runDaemon(): Promise<void>;
28
61
  * session). Never throws: a misconfigured token must not block daemon startup.
29
62
  */
30
63
  export declare function readDaemonClaudeOAuthToken(): string | null;
64
+ /**
65
+ * Write a launchd plist or systemd unit with owner-only permissions atomically.
66
+ *
67
+ * `writeFileSync`'s `mode` is honored only when the file is *created*, so we
68
+ * unlink any pre-existing manifest first. That guarantees every write is a
69
+ * fresh 0600 create — closing the TOCTOU window on new files AND re-locking a
70
+ * stale world-readable manifest left by an older install — since these files
71
+ * embed long-lived credentials.
72
+ */
73
+ export declare function writeOwnerOnlyServiceManifest(filePath: string, content: string): void;
31
74
  /** Generate a macOS launchd plist for auto-starting the daemon. */
32
75
  export declare function generateLaunchdPlist(): string;
33
76
  /** Generate a Linux systemd user unit for auto-starting the daemon. */
@@ -37,6 +80,20 @@ export declare function startDaemon(): {
37
80
  pid: number | null;
38
81
  method: string;
39
82
  };
83
+ /**
84
+ * Bring the always-on daemon up as a side effect of a background-adjacent
85
+ * command (secrets unlock, browser start, ...), not only from `routines add`.
86
+ *
87
+ * Delegates to the single `startDaemon` entrypoint, so it honors the
88
+ * single-instance start lock and is a no-op when a daemon is already running
89
+ * (returns `already-running`). Best-effort: any failure is swallowed and null
90
+ * returned, so ensuring the daemon can never break the foreground command that
91
+ * happened to bring it up. See issue #415.
92
+ */
93
+ export declare function ensureDaemonStarted(): {
94
+ pid: number | null;
95
+ method: string;
96
+ } | null;
40
97
  /**
41
98
  * Environment for the detached daemon fallback. The launchd/systemd paths
42
99
  * deliver the long-lived OAuth token via the service manifest's environment;
@@ -46,6 +103,40 @@ export declare function startDaemon(): {
46
103
  * value (e.g. inherited from launchd) is left untouched.
47
104
  */
48
105
  export declare function buildDetachedDaemonEnv(baseEnv?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
106
+ /**
107
+ * Resolve how to launch the daemon: `node <entry> daemon _run`, matching the
108
+ * exact form that works under a direct `daemon _run`.
109
+ *
110
+ * We spawn the Node runtime (`process.execPath`) with the CLI entry as an
111
+ * argument rather than executing the entry path directly. Executing the `.js`
112
+ * path relies on its shebang on POSIX, and on Windows CreateProcess can't run a
113
+ * `.js`/shim directly at all — it gets launched through a transient
114
+ * console-owning wrapper (cmd.exe / the npm shim). When that wrapper exits it
115
+ * closes its console, and the detached daemon sharing that console receives a
116
+ * console-close event that trips its shutdown handler — the daemon comes up,
117
+ * binds the browser IPC socket, then tears itself down ~36ms later (#556).
118
+ * Going through `process.execPath` means a real PE/binary is spawned with
119
+ * `detached: true` and no console, so nothing signals the daemon after launch.
120
+ *
121
+ * When the entry isn't a JS file (e.g. a native launcher resolved via
122
+ * `which agents`), run it directly — it owns its own runtime resolution.
123
+ */
124
+ export declare function getDaemonLaunch(agentsBin?: string): {
125
+ command: string;
126
+ args: string[];
127
+ };
128
+ interface StartDetachedOptions {
129
+ /** CLI entry to launch (defaults to the running binary). Injectable for tests. */
130
+ agentsBin?: string;
131
+ /** Log file the daemon's stdio is redirected to (defaults to the daemon log). */
132
+ logPath?: string;
133
+ /** Environment for the child (defaults to the OAuth-augmented detached env). */
134
+ env?: NodeJS.ProcessEnv;
135
+ }
136
+ export declare function startDetached(opts?: StartDetachedOptions): {
137
+ pid: number | null;
138
+ method: string;
139
+ };
49
140
  /** Stop the daemon, unloading it from launchd/systemd if applicable. */
50
141
  export declare function stopDaemon(): boolean;
51
142
  /** Get current daemon status including running state, PID, and enabled job count. */
@@ -59,3 +150,4 @@ export declare function getDaemonStatus(): {
59
150
  export declare function readDaemonLog(lines?: number): string;
60
151
  /** Send SIGHUP to the daemon to trigger a job reload. */
61
152
  export declare function signalDaemonReload(): boolean;
153
+ export {};
@@ -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 {
@@ -373,6 +475,23 @@ function xmlEscape(s) {
373
475
  .replace(/</g, '&lt;')
374
476
  .replace(/>/g, '&gt;');
375
477
  }
478
+ /**
479
+ * Write a launchd plist or systemd unit with owner-only permissions atomically.
480
+ *
481
+ * `writeFileSync`'s `mode` is honored only when the file is *created*, so we
482
+ * unlink any pre-existing manifest first. That guarantees every write is a
483
+ * fresh 0600 create — closing the TOCTOU window on new files AND re-locking a
484
+ * stale world-readable manifest left by an older install — since these files
485
+ * embed long-lived credentials.
486
+ */
487
+ export function writeOwnerOnlyServiceManifest(filePath, content) {
488
+ const dir = path.dirname(filePath);
489
+ if (!fs.existsSync(dir)) {
490
+ fs.mkdirSync(dir, { recursive: true });
491
+ }
492
+ fs.rmSync(filePath, { force: true });
493
+ fs.writeFileSync(filePath, content, { encoding: 'utf-8', mode: 0o600 });
494
+ }
376
495
  /** Generate a macOS launchd plist for auto-starting the daemon. */
377
496
  export function generateLaunchdPlist() {
378
497
  const agentsBin = getAgentsBinPath();
@@ -448,6 +567,31 @@ function getAgentsBinPath() {
448
567
  return 'agents';
449
568
  }
450
569
  }
570
+ /**
571
+ * Ask the service manager for the daemon's live PID. Used as a fallback when
572
+ * the daemon hasn't yet written its pid file but launchd/systemd already report
573
+ * it running — so a start never has to surface a null PID for a daemon that is
574
+ * in fact up. Returns null when the service isn't running or the query fails.
575
+ */
576
+ function readServiceManagerPid(platform = os.platform()) {
577
+ try {
578
+ if (platform === 'linux') {
579
+ const out = execFileSync('systemctl', ['--user', 'show', '-p', 'MainPID', '--value', SYSTEMD_UNIT], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
580
+ const pid = parseInt(out, 10);
581
+ return !isNaN(pid) && pid > 0 ? pid : null;
582
+ }
583
+ if (platform === 'darwin') {
584
+ const out = execFileSync('launchctl', ['list', PLIST_NAME], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
585
+ const m = out.match(/"PID"\s*=\s*(\d+)/);
586
+ if (m) {
587
+ const pid = parseInt(m[1], 10);
588
+ return pid > 0 ? pid : null;
589
+ }
590
+ }
591
+ }
592
+ catch { /* not running / manager unavailable */ }
593
+ return null;
594
+ }
451
595
  /** Start the daemon via launchd, systemd, or as a detached process. */
452
596
  export function startDaemon() {
453
597
  if (isDaemonRunning()) {
@@ -467,6 +611,24 @@ export function startDaemon() {
467
611
  releaseLock();
468
612
  }
469
613
  }
614
+ /**
615
+ * Bring the always-on daemon up as a side effect of a background-adjacent
616
+ * command (secrets unlock, browser start, ...), not only from `routines add`.
617
+ *
618
+ * Delegates to the single `startDaemon` entrypoint, so it honors the
619
+ * single-instance start lock and is a no-op when a daemon is already running
620
+ * (returns `already-running`). Best-effort: any failure is swallowed and null
621
+ * returned, so ensuring the daemon can never break the foreground command that
622
+ * happened to bring it up. See issue #415.
623
+ */
624
+ export function ensureDaemonStarted() {
625
+ try {
626
+ return startDaemon();
627
+ }
628
+ catch {
629
+ return null;
630
+ }
631
+ }
470
632
  function startDaemonLocked() {
471
633
  const platform = os.platform();
472
634
  if (platform === 'darwin') {
@@ -476,10 +638,9 @@ function startDaemonLocked() {
476
638
  if (!fs.existsSync(plistDir)) {
477
639
  fs.mkdirSync(plistDir, { recursive: true });
478
640
  }
479
- fs.writeFileSync(plistPath, generateLaunchdPlist(), 'utf-8');
480
641
  // 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);
642
+ // create owner-only atomically (no world-readable window before chmod).
643
+ writeOwnerOnlyServiceManifest(plistPath, generateLaunchdPlist());
483
644
  try {
484
645
  execFileSync('launchctl', ['unload', plistPath], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
485
646
  }
@@ -489,7 +650,7 @@ function startDaemonLocked() {
489
650
  // of success. If no pid materializes within the window, give up on
490
651
  // launchd and fall through to a plain detached spawn.
491
652
  execFileSync('launchctl', ['load', plistPath], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] });
492
- const pid = waitForPid(3000);
653
+ const pid = waitForPid(3000) ?? readServiceManagerPid();
493
654
  if (pid)
494
655
  return { pid, method: 'launchd' };
495
656
  // launchctl claimed success but nothing ran. Fall through.
@@ -506,18 +667,21 @@ function startDaemonLocked() {
506
667
  if (!fs.existsSync(unitDir)) {
507
668
  fs.mkdirSync(unitDir, { recursive: true });
508
669
  }
509
- fs.writeFileSync(unitPath, generateSystemdUnit(), 'utf-8');
510
670
  // May embed a long-lived OAuth token in an Environment= line; owner-only.
511
- fs.chmodSync(unitPath, 0o600);
671
+ writeOwnerOnlyServiceManifest(unitPath, generateSystemdUnit());
512
672
  execFileSync('systemctl', ['--user', 'daemon-reload'], { encoding: 'utf-8' });
513
673
  execFileSync('systemctl', ['--user', 'enable', SYSTEMD_UNIT], { encoding: 'utf-8' });
514
674
  execFileSync('systemctl', ['--user', 'start', SYSTEMD_UNIT], { encoding: 'utf-8' });
515
- const pid = waitForPid(3000);
516
- return { pid, method: 'systemd' };
675
+ const pid = waitForPid(3000) ?? readServiceManagerPid();
676
+ if (pid)
677
+ return { pid, method: 'systemd' };
678
+ // systemctl returned success but no PID surfaced — fall through to a
679
+ // plain detached spawn rather than reporting a null PID.
517
680
  }
518
681
  catch {
519
- return startDetached();
682
+ // start threw — fall through to detached spawn
520
683
  }
684
+ return startDetached();
521
685
  }
522
686
  return startDetached();
523
687
  }
@@ -538,18 +702,58 @@ export function buildDetachedDaemonEnv(baseEnv = process.env) {
538
702
  }
539
703
  return env;
540
704
  }
541
- function startDetached() {
542
- const agentsBin = getAgentsBinPath();
543
- const logPath = getLogPath();
705
+ /**
706
+ * Resolve how to launch the daemon: `node <entry> daemon _run`, matching the
707
+ * exact form that works under a direct `daemon _run`.
708
+ *
709
+ * We spawn the Node runtime (`process.execPath`) with the CLI entry as an
710
+ * argument rather than executing the entry path directly. Executing the `.js`
711
+ * path relies on its shebang on POSIX, and on Windows CreateProcess can't run a
712
+ * `.js`/shim directly at all — it gets launched through a transient
713
+ * console-owning wrapper (cmd.exe / the npm shim). When that wrapper exits it
714
+ * closes its console, and the detached daemon sharing that console receives a
715
+ * console-close event that trips its shutdown handler — the daemon comes up,
716
+ * binds the browser IPC socket, then tears itself down ~36ms later (#556).
717
+ * Going through `process.execPath` means a real PE/binary is spawned with
718
+ * `detached: true` and no console, so nothing signals the daemon after launch.
719
+ *
720
+ * When the entry isn't a JS file (e.g. a native launcher resolved via
721
+ * `which agents`), run it directly — it owns its own runtime resolution.
722
+ */
723
+ export function getDaemonLaunch(agentsBin = getAgentsBinPath()) {
724
+ if (/\.(c|m)?js$/.test(agentsBin)) {
725
+ return { command: process.execPath, args: [agentsBin, 'daemon', '_run'] };
726
+ }
727
+ return { command: agentsBin, args: ['daemon', '_run'] };
728
+ }
729
+ export function startDetached(opts = {}) {
730
+ const agentsBin = opts.agentsBin ?? getAgentsBinPath();
731
+ const logPath = opts.logPath ?? getLogPath();
544
732
  const logFd = fs.openSync(logPath, 'a');
545
- const child = spawn(agentsBin, ['daemon', '_run'], {
733
+ const { command, args } = getDaemonLaunch(agentsBin);
734
+ // fdStdio: the log-file fds make windowsHide inert (libuv skips
735
+ // CREATE_NO_WINDOW when a stdio fd is inherited), so on Windows the daemon
736
+ // must DETACH to own no console — otherwise it shares the launcher's console
737
+ // and a console-close event tears it down when the launcher exits (#556).
738
+ const child = spawn(command, args, {
546
739
  stdio: ['ignore', logFd, logFd],
547
- detached: true,
548
- env: buildDetachedDaemonEnv(),
740
+ ...backgroundSpawnOptions({ fdStdio: true }),
741
+ env: opts.env ?? buildDetachedDaemonEnv(),
549
742
  });
743
+ // A failed spawn (ENOENT/EACCES) emits 'error' asynchronously; without a
744
+ // listener that would crash the parent as an unhandled EventEmitter error.
745
+ // The synchronous `!child.pid` guard below is what reports the failure loudly.
746
+ child.on('error', () => { });
550
747
  child.unref();
551
748
  fs.closeSync(logFd);
552
- return { pid: child.pid || null, method: 'detached' };
749
+ // `spawn` leaves `pid` undefined only when the process could not be created.
750
+ // Returning null here (the old `child.pid || null`) let callers report
751
+ // "PID: null" as if the daemon had started — a start with no PID is a failed
752
+ // start, so fail loudly instead of manufacturing a phantom success.
753
+ if (!child.pid) {
754
+ throw new Error(`Failed to start daemon: spawning '${command}' produced no PID (binary missing or not executable?)`);
755
+ }
756
+ return { pid: child.pid, method: 'detached' };
553
757
  }
554
758
  function waitForPid(timeoutMs) {
555
759
  const start = Date.now();
@@ -11,8 +11,9 @@ export declare function sshTargetFor(device: DeviceProfile): string;
11
11
  /**
12
12
  * Wrap a remote command for the device's shell. Windows devices speak
13
13
  * PowerShell, so a bare command is run through `powershell -NoProfile
14
- * -Command`; POSIX devices get the command verbatim (the remote login shell
15
- * parses it). Returns undefined when no command was given (interactive login).
14
+ * -EncodedCommand`; POSIX devices get the command verbatim (the remote login
15
+ * shell parses it). Returns undefined when no command was given (interactive
16
+ * login).
16
17
  */
17
18
  export declare function wrapRemoteCommand(device: DeviceProfile, cmd: string[]): string | undefined;
18
19
  /**
@@ -16,6 +16,7 @@
16
16
  import * as fs from 'fs';
17
17
  import * as path from 'path';
18
18
  import { assertValidSshTarget, shellQuote } from '../ssh-exec.js';
19
+ import { encodePwshBase64 } from '../pwsh.js';
19
20
  import { getCacheDir } from '../state.js';
20
21
  import { hostNameFor } from './ssh-config.js';
21
22
  /** Env var the askpass shim reads to know which bundle holds the password. */
@@ -38,15 +39,16 @@ export function sshTargetFor(device) {
38
39
  /**
39
40
  * Wrap a remote command for the device's shell. Windows devices speak
40
41
  * PowerShell, so a bare command is run through `powershell -NoProfile
41
- * -Command`; POSIX devices get the command verbatim (the remote login shell
42
- * parses it). Returns undefined when no command was given (interactive login).
42
+ * -EncodedCommand`; POSIX devices get the command verbatim (the remote login
43
+ * shell parses it). Returns undefined when no command was given (interactive
44
+ * login).
43
45
  */
44
46
  export function wrapRemoteCommand(device, cmd) {
45
47
  if (cmd.length === 0)
46
48
  return undefined;
47
49
  const joined = cmd.join(' ');
48
50
  if (device.shell === 'powershell') {
49
- return `powershell -NoProfile -Command ${shellQuote(joined)}`;
51
+ return `powershell -NoProfile -EncodedCommand ${encodePwshBase64(joined)}`;
50
52
  }
51
53
  return joined;
52
54
  }
@@ -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
  }
@@ -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;