@phnx-labs/agents-cli 1.20.74 → 1.20.77

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 (231) hide show
  1. package/CHANGELOG.md +1055 -0
  2. package/README.md +36 -10
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/apply.js +21 -3
  5. package/dist/commands/attach.d.ts +10 -0
  6. package/dist/commands/attach.js +41 -0
  7. package/dist/commands/computer.js +3 -3
  8. package/dist/commands/detach-core.d.ts +46 -0
  9. package/dist/commands/detach-core.js +61 -0
  10. package/dist/commands/detach.d.ts +5 -0
  11. package/dist/commands/detach.js +171 -0
  12. package/dist/commands/doctor.js +18 -0
  13. package/dist/commands/exec.js +83 -13
  14. package/dist/commands/feed.d.ts +7 -1
  15. package/dist/commands/feed.js +47 -4
  16. package/dist/commands/go.d.ts +9 -2
  17. package/dist/commands/go.js +16 -6
  18. package/dist/commands/harness.d.ts +14 -0
  19. package/dist/commands/harness.js +145 -0
  20. package/dist/commands/import.js +38 -8
  21. package/dist/commands/inspect.js +5 -2
  22. package/dist/commands/profiles.d.ts +20 -0
  23. package/dist/commands/profiles.js +68 -21
  24. package/dist/commands/repo.js +14 -0
  25. package/dist/commands/routines.js +286 -15
  26. package/dist/commands/secrets.js +102 -37
  27. package/dist/commands/sessions-browser.js +12 -2
  28. package/dist/commands/sessions-export.js +19 -5
  29. package/dist/commands/sessions-migrate.d.ts +29 -0
  30. package/dist/commands/sessions-migrate.js +596 -0
  31. package/dist/commands/sessions-picker.d.ts +14 -1
  32. package/dist/commands/sessions-picker.js +184 -16
  33. package/dist/commands/sessions.d.ts +56 -14
  34. package/dist/commands/sessions.js +322 -62
  35. package/dist/commands/setup-computer.js +2 -2
  36. package/dist/commands/ssh.d.ts +13 -0
  37. package/dist/commands/ssh.js +110 -37
  38. package/dist/commands/status.js +10 -2
  39. package/dist/commands/versions.js +15 -6
  40. package/dist/commands/view.d.ts +5 -0
  41. package/dist/commands/view.js +24 -4
  42. package/dist/commands/watchdog.d.ts +7 -2
  43. package/dist/commands/watchdog.js +73 -57
  44. package/dist/index.js +59 -7
  45. package/dist/lib/activity.d.ts +17 -6
  46. package/dist/lib/activity.js +466 -40
  47. package/dist/lib/actor.d.ts +50 -0
  48. package/dist/lib/actor.js +166 -0
  49. package/dist/lib/agent-spec/provider.js +2 -1
  50. package/dist/lib/agent-spec/resolve.js +19 -5
  51. package/dist/lib/agent-spec/types.d.ts +9 -1
  52. package/dist/lib/agents.d.ts +21 -0
  53. package/dist/lib/agents.js +70 -6
  54. package/dist/lib/cloud/codex.d.ts +2 -0
  55. package/dist/lib/cloud/codex.js +14 -3
  56. package/dist/lib/cloud/session-index.d.ts +32 -0
  57. package/dist/lib/cloud/session-index.js +58 -0
  58. package/dist/lib/cloud/store.d.ts +7 -0
  59. package/dist/lib/cloud/store.js +25 -0
  60. package/dist/lib/config-transfer.js +4 -0
  61. package/dist/lib/daemon.d.ts +10 -1
  62. package/dist/lib/daemon.js +114 -11
  63. package/dist/lib/devices/connect.d.ts +15 -1
  64. package/dist/lib/devices/connect.js +15 -1
  65. package/dist/lib/devices/fleet.d.ts +37 -1
  66. package/dist/lib/devices/fleet.js +36 -2
  67. package/dist/lib/devices/health-report.d.ts +38 -0
  68. package/dist/lib/devices/health-report.js +214 -0
  69. package/dist/lib/devices/health.js +4 -1
  70. package/dist/lib/devices/reachability.d.ts +31 -0
  71. package/dist/lib/devices/reachability.js +40 -0
  72. package/dist/lib/devices/registry.d.ts +33 -0
  73. package/dist/lib/devices/registry.js +37 -0
  74. package/dist/lib/devices/resolve-target.d.ts +15 -27
  75. package/dist/lib/devices/resolve-target.js +63 -102
  76. package/dist/lib/devices/sync.d.ts +18 -0
  77. package/dist/lib/devices/sync.js +23 -1
  78. package/dist/lib/devices/tailscale.d.ts +3 -0
  79. package/dist/lib/devices/tailscale.js +1 -0
  80. package/dist/lib/events.d.ts +1 -1
  81. package/dist/lib/exec.d.ts +82 -0
  82. package/dist/lib/exec.js +218 -6
  83. package/dist/lib/feed-post.d.ts +63 -0
  84. package/dist/lib/feed-post.js +204 -0
  85. package/dist/lib/feed.d.ts +7 -1
  86. package/dist/lib/feed.js +96 -6
  87. package/dist/lib/fleet/apply.d.ts +32 -2
  88. package/dist/lib/fleet/apply.js +97 -10
  89. package/dist/lib/fleet/types.d.ts +11 -0
  90. package/dist/lib/fs-walk.d.ts +13 -0
  91. package/dist/lib/fs-walk.js +16 -7
  92. package/dist/lib/heal.d.ts +7 -4
  93. package/dist/lib/heal.js +10 -22
  94. package/dist/lib/hooks.d.ts +1 -0
  95. package/dist/lib/hooks.js +156 -0
  96. package/dist/lib/hosts/dispatch.d.ts +16 -0
  97. package/dist/lib/hosts/dispatch.js +26 -4
  98. package/dist/lib/hosts/passthrough.js +9 -2
  99. package/dist/lib/hosts/reconnect.d.ts +78 -0
  100. package/dist/lib/hosts/reconnect.js +127 -0
  101. package/dist/lib/hosts/registry.d.ts +75 -14
  102. package/dist/lib/hosts/registry.js +205 -30
  103. package/dist/lib/hosts/remote-cmd.d.ts +11 -0
  104. package/dist/lib/hosts/remote-cmd.js +32 -8
  105. package/dist/lib/hosts/remote-session-id.d.ts +45 -0
  106. package/dist/lib/hosts/remote-session-id.js +84 -0
  107. package/dist/lib/hosts/run-target.d.ts +17 -5
  108. package/dist/lib/hosts/run-target.js +30 -10
  109. package/dist/lib/hosts/session-index.d.ts +18 -1
  110. package/dist/lib/hosts/session-index.js +40 -5
  111. package/dist/lib/hosts/session-marker.d.ts +33 -0
  112. package/dist/lib/hosts/session-marker.js +51 -0
  113. package/dist/lib/hosts/tasks.d.ts +8 -4
  114. package/dist/lib/import.d.ts +2 -0
  115. package/dist/lib/import.js +35 -2
  116. package/dist/lib/menubar/install-menubar.d.ts +29 -0
  117. package/dist/lib/menubar/install-menubar.js +59 -1
  118. package/dist/lib/menubar/notify-desktop.d.ts +44 -0
  119. package/dist/lib/menubar/notify-desktop.js +78 -0
  120. package/dist/lib/migrate.d.ts +16 -0
  121. package/dist/lib/migrate.js +36 -0
  122. package/dist/lib/models.d.ts +27 -0
  123. package/dist/lib/models.js +54 -1
  124. package/dist/lib/overdue.d.ts +6 -2
  125. package/dist/lib/overdue.js +10 -36
  126. package/dist/lib/picker.js +4 -1
  127. package/dist/lib/platform/process.d.ts +17 -0
  128. package/dist/lib/platform/process.js +70 -0
  129. package/dist/lib/profiles-presets.js +9 -7
  130. package/dist/lib/profiles.d.ts +31 -0
  131. package/dist/lib/profiles.js +70 -0
  132. package/dist/lib/pty-server.d.ts +2 -10
  133. package/dist/lib/pty-server.js +4 -38
  134. package/dist/lib/registry.d.ts +1 -1
  135. package/dist/lib/registry.js +48 -8
  136. package/dist/lib/rotate.d.ts +18 -0
  137. package/dist/lib/rotate.js +28 -0
  138. package/dist/lib/routine-notify.d.ts +76 -0
  139. package/dist/lib/routine-notify.js +190 -0
  140. package/dist/lib/routines-placement.d.ts +38 -0
  141. package/dist/lib/routines-placement.js +79 -0
  142. package/dist/lib/routines-project.d.ts +97 -0
  143. package/dist/lib/routines-project.js +349 -0
  144. package/dist/lib/routines.d.ts +94 -0
  145. package/dist/lib/routines.js +93 -9
  146. package/dist/lib/runner.d.ts +12 -2
  147. package/dist/lib/runner.js +242 -20
  148. package/dist/lib/sandbox.js +10 -0
  149. package/dist/lib/secrets/account-token.d.ts +20 -0
  150. package/dist/lib/secrets/account-token.js +64 -0
  151. package/dist/lib/secrets/agent.d.ts +74 -4
  152. package/dist/lib/secrets/agent.js +181 -107
  153. package/dist/lib/secrets/bundles.d.ts +54 -6
  154. package/dist/lib/secrets/bundles.js +230 -34
  155. package/dist/lib/secrets/index.d.ts +26 -3
  156. package/dist/lib/secrets/index.js +42 -12
  157. package/dist/lib/secrets/rc-hygiene.d.ts +55 -0
  158. package/dist/lib/secrets/rc-hygiene.js +154 -0
  159. package/dist/lib/secrets/remote.d.ts +7 -4
  160. package/dist/lib/secrets/remote.js +7 -4
  161. package/dist/lib/secrets/session-store.d.ts +6 -2
  162. package/dist/lib/secrets/session-store.js +65 -15
  163. package/dist/lib/secrets/sync-commands.d.ts +21 -0
  164. package/dist/lib/secrets/sync-commands.js +21 -0
  165. package/dist/lib/secrets/unlock-hints.d.ts +27 -0
  166. package/dist/lib/secrets/unlock-hints.js +36 -0
  167. package/dist/lib/self-update.d.ts +23 -2
  168. package/dist/lib/self-update.js +86 -5
  169. package/dist/lib/session/active.d.ts +124 -7
  170. package/dist/lib/session/active.js +390 -56
  171. package/dist/lib/session/cloud.js +2 -1
  172. package/dist/lib/session/db.d.ts +54 -0
  173. package/dist/lib/session/db.js +320 -19
  174. package/dist/lib/session/detached.d.ts +30 -0
  175. package/dist/lib/session/detached.js +92 -0
  176. package/dist/lib/session/discover.d.ts +478 -3
  177. package/dist/lib/session/discover.js +1740 -472
  178. package/dist/lib/session/fork.js +2 -1
  179. package/dist/lib/session/hook-sessions.d.ts +43 -0
  180. package/dist/lib/session/hook-sessions.js +135 -0
  181. package/dist/lib/session/linear.d.ts +6 -0
  182. package/dist/lib/session/linear.js +55 -0
  183. package/dist/lib/session/migrate-targets.d.ts +65 -0
  184. package/dist/lib/session/migrate-targets.js +94 -0
  185. package/dist/lib/session/migrations.d.ts +37 -0
  186. package/dist/lib/session/migrations.js +60 -0
  187. package/dist/lib/session/parse.d.ts +18 -0
  188. package/dist/lib/session/parse.js +141 -32
  189. package/dist/lib/session/pid-registry.d.ts +34 -2
  190. package/dist/lib/session/pid-registry.js +49 -2
  191. package/dist/lib/session/prompt.d.ts +7 -0
  192. package/dist/lib/session/prompt.js +37 -0
  193. package/dist/lib/session/provenance.d.ts +7 -0
  194. package/dist/lib/session/render.d.ts +7 -2
  195. package/dist/lib/session/render.js +40 -26
  196. package/dist/lib/session/short-id.d.ts +17 -0
  197. package/dist/lib/session/short-id.js +20 -0
  198. package/dist/lib/session/state.d.ts +4 -0
  199. package/dist/lib/session/state.js +99 -13
  200. package/dist/lib/session/types.d.ts +25 -1
  201. package/dist/lib/session/types.js +8 -0
  202. package/dist/lib/shims.d.ts +1 -1
  203. package/dist/lib/shims.js +20 -6
  204. package/dist/lib/sqlite.d.ts +3 -2
  205. package/dist/lib/sqlite.js +27 -4
  206. package/dist/lib/staleness/detectors/commands.js +1 -1
  207. package/dist/lib/staleness/detectors/workflows.js +13 -2
  208. package/dist/lib/staleness/writers/commands.js +7 -7
  209. package/dist/lib/staleness/writers/workflows.d.ts +4 -2
  210. package/dist/lib/startup/command-registry.d.ts +1 -0
  211. package/dist/lib/startup/command-registry.js +3 -0
  212. package/dist/lib/state.d.ts +8 -4
  213. package/dist/lib/state.js +21 -37
  214. package/dist/lib/teams/agents.d.ts +19 -10
  215. package/dist/lib/teams/agents.js +40 -39
  216. package/dist/lib/types.d.ts +47 -2
  217. package/dist/lib/usage.d.ts +33 -1
  218. package/dist/lib/usage.js +172 -12
  219. package/dist/lib/versions.d.ts +9 -2
  220. package/dist/lib/versions.js +37 -8
  221. package/dist/lib/watchdog/log.d.ts +43 -0
  222. package/dist/lib/watchdog/log.js +69 -0
  223. package/dist/lib/watchdog/routine.d.ts +44 -0
  224. package/dist/lib/watchdog/routine.js +69 -0
  225. package/dist/lib/watchdog/runner.d.ts +51 -7
  226. package/dist/lib/watchdog/runner.js +239 -64
  227. package/dist/lib/watchdog/watchdog.d.ts +1 -1
  228. package/dist/lib/watchdog/watchdog.js +31 -16
  229. package/dist/lib/workflows.d.ts +16 -0
  230. package/dist/lib/workflows.js +110 -1
  231. package/package.json +1 -1
@@ -223,11 +223,75 @@ export function profileFromPreset(profileName, preset, version) {
223
223
  envVar: preset.authEnvVar,
224
224
  keychainItem: keychainItemName(preset.provider),
225
225
  },
226
+ authOptional: preset.authOptional,
226
227
  description: preset.description,
227
228
  preset: preset.name,
228
229
  provider: preset.provider,
229
230
  };
230
231
  }
232
+ /**
233
+ * Env var each host CLI reads to override its model. Mirror of the read-side
234
+ * `MODEL_ENV_KEYS` above, keyed by host so a one-shot `--host <agent> --model
235
+ * <id>` writes the model onto the var that host actually honors.
236
+ */
237
+ const MODEL_ENV_KEY_BY_HOST = {
238
+ claude: 'ANTHROPIC_MODEL',
239
+ opencode: 'OPENCODE_MODEL',
240
+ grok: 'GROK_MODEL',
241
+ gemini: 'GEMINI_MODEL',
242
+ codex: 'OPENAI_MODEL',
243
+ };
244
+ /** Base-URL env var per host, for the OpenAI/Anthropic-compatible hosts where it applies. */
245
+ const BASE_URL_ENV_KEY_BY_HOST = {
246
+ claude: 'ANTHROPIC_BASE_URL',
247
+ codex: 'OPENAI_BASE_URL',
248
+ };
249
+ /** Return the model-override env var for a host (known hosts mapped; else `<HOST>_MODEL`). */
250
+ export function modelEnvKeyForHost(host) {
251
+ return MODEL_ENV_KEY_BY_HOST[host] ?? `${host.toUpperCase()}_MODEL`;
252
+ }
253
+ /** Return the base-URL env var for a host, or null when the host has no known override var. */
254
+ export function baseUrlEnvKeyForHost(host) {
255
+ return BASE_URL_ENV_KEY_BY_HOST[host] ?? null;
256
+ }
257
+ /** Env var each host reads its auth token from, for custom-endpoint (`--auth-provider`) harnesses. */
258
+ const AUTH_ENV_KEY_BY_HOST = {
259
+ claude: 'ANTHROPIC_AUTH_TOKEN',
260
+ codex: 'OPENAI_API_KEY',
261
+ gemini: 'GEMINI_API_KEY',
262
+ grok: 'XAI_API_KEY',
263
+ opencode: 'OPENCODE_API_KEY',
264
+ };
265
+ /** Return the auth-token env var for a host, or null when unknown. */
266
+ export function authEnvKeyForHost(host) {
267
+ return AUTH_ENV_KEY_BY_HOST[host] ?? null;
268
+ }
269
+ /**
270
+ * Build a custom-harness profile from a host CLI + model in one shot, without a
271
+ * preset. The model lands on the host's model env var ({@link modelEnvKeyForHost});
272
+ * auth is attached only when both `provider` and `authEnvVar` are supplied
273
+ * (hosts that manage their own login — e.g. opencode — need neither).
274
+ */
275
+ export function profileFromHostModel(name, host, model, opts = {}) {
276
+ const env = { [modelEnvKeyForHost(host)]: model };
277
+ if (opts.baseUrl) {
278
+ const key = baseUrlEnvKeyForHost(host);
279
+ if (key)
280
+ env[key] = opts.baseUrl;
281
+ }
282
+ const profile = {
283
+ name,
284
+ host: { agent: host, version: opts.version },
285
+ env,
286
+ description: opts.description ?? `Custom harness: ${host} + ${model}`,
287
+ provider: opts.provider ?? host,
288
+ };
289
+ if (opts.provider && opts.authEnvVar) {
290
+ profile.auth = { envVar: opts.authEnvVar, keychainItem: keychainItemName(opts.provider) };
291
+ profile.authOptional = false;
292
+ }
293
+ return profile;
294
+ }
231
295
  /**
232
296
  * Resolve a profile into the env block that should be injected into the
233
297
  * spawned agent process. Reads the token from keychain at exec time so the
@@ -236,6 +300,12 @@ export function profileFromPreset(profileName, preset, version) {
236
300
  export function resolveProfileEnv(profile) {
237
301
  const env = { ...profile.env };
238
302
  if (profile.auth) {
303
+ // Optional auth (host manages its own login) with no stored token: inject
304
+ // nothing and let the host use its own credentials. Only required auth
305
+ // hard-fails on a missing keychain item.
306
+ if (profile.authOptional && !hasKeychainToken(profile.auth.keychainItem)) {
307
+ return env;
308
+ }
239
309
  const token = getKeychainToken(profile.auth.keychainItem);
240
310
  env[profile.auth.envVar] = token;
241
311
  }
@@ -8,16 +8,8 @@
8
8
  *
9
9
  * Protocol: newline-delimited JSON over ~/.agents/.system/pty.sock
10
10
  */
11
- /**
12
- * Capture a stable identifier for a process at the moment it was started.
13
- * Used to defeat PID reuse: a kill(pid, ...) is only safe when the process
14
- * still occupies the PID we observed at spawn time.
15
- *
16
- * Linux: field 22 of /proc/<pid>/stat (starttime in clock ticks since boot).
17
- * macOS: output of `ps -o lstart= -p <pid>` (start time in human format).
18
- * Returns null on any error so callers can skip the guard rather than crash.
19
- */
20
- export declare function captureProcessStartTime(pid: number): string | null;
11
+ import { captureProcessStartTime } from './platform/index.js';
12
+ export { captureProcessStartTime };
21
13
  /**
22
14
  * Wrap a user command so a `__SENTINEL__:<exit>` line is printed after it
23
15
  * finishes — that line drives completion detection in the exec/read flow.
@@ -12,46 +12,12 @@ import * as net from 'net';
12
12
  import * as fs from 'fs';
13
13
  import * as path from 'path';
14
14
  import * as crypto from 'crypto';
15
- import { execFileSync } from 'child_process';
16
15
  import { fileURLToPath } from 'url';
17
16
  import { getPtyDir as getPtyDirRoot } from './state.js';
18
- import { isAlive } from './platform/index.js';
19
- /**
20
- * Capture a stable identifier for a process at the moment it was started.
21
- * Used to defeat PID reuse: a kill(pid, ...) is only safe when the process
22
- * still occupies the PID we observed at spawn time.
23
- *
24
- * Linux: field 22 of /proc/<pid>/stat (starttime in clock ticks since boot).
25
- * macOS: output of `ps -o lstart= -p <pid>` (start time in human format).
26
- * Returns null on any error so callers can skip the guard rather than crash.
27
- */
28
- export function captureProcessStartTime(pid) {
29
- if (!pid || pid <= 0)
30
- return null;
31
- try {
32
- if (process.platform === 'linux') {
33
- const stat = fs.readFileSync(`/proc/${pid}/stat`, 'utf-8');
34
- // The comm field (#2) is wrapped in parens and may contain spaces, so
35
- // split off everything after the last `)` to get a clean field list.
36
- const lastParen = stat.lastIndexOf(')');
37
- if (lastParen < 0)
38
- return null;
39
- const tail = stat.slice(lastParen + 2);
40
- const fields = tail.split(' ');
41
- // After comm we are at field 3; starttime is field 22, so index 19 here.
42
- return fields[19] || null;
43
- }
44
- const out = execFileSync('ps', ['-o', 'lstart=', '-p', String(pid)], {
45
- encoding: 'utf-8',
46
- stdio: ['ignore', 'pipe', 'ignore'],
47
- });
48
- const trimmed = out.trim();
49
- return trimmed.length > 0 ? trimmed : null;
50
- }
51
- catch {
52
- return null;
53
- }
54
- }
17
+ import { isAlive, captureProcessStartTime } from './platform/index.js';
18
+ // Re-exported for the existing importers of this module; the implementation is
19
+ // in platform/process.ts, shared with teams/agents.ts so the two cannot drift.
20
+ export { captureProcessStartTime };
55
21
  // --- Constants ---
56
22
  const IS_WINDOWS = process.platform === 'win32';
57
23
  const SENTINEL = '__AGENTS_PTY_DONE__';
@@ -17,7 +17,7 @@ export declare function getEnabledRegistries(type: RegistryType): Array<{
17
17
  }>;
18
18
  /** Add or update a registry configuration in agents.yaml. */
19
19
  export declare function setRegistry(type: RegistryType, name: string, config: Partial<RegistryConfig>): void;
20
- /** Remove a user-configured registry. Returns false if it did not exist. */
20
+ /** Remove a user-configured or seeded registry. Returns false if it did not exist. */
21
21
  export declare function removeRegistry(type: RegistryType, name: string): boolean;
22
22
  /** Search MCP registries for servers matching a query string. */
23
23
  export declare function searchMcpRegistries(query: string, options?: {
@@ -8,7 +8,7 @@
8
8
  import * as fs from 'fs';
9
9
  import * as path from 'path';
10
10
  import { createHash } from 'crypto';
11
- import { DEFAULT_REGISTRIES } from './types.js';
11
+ import { DEFAULT_REGISTRIES, SEEDED_REGISTRIES } from './types.js';
12
12
  import { readMeta, writeMeta } from './state.js';
13
13
  import { discoverSkillsFromRepo } from './skills.js';
14
14
  const UNSAFE_PACKAGE_SPEC_CHARS = /[;&|`$\s\x00-\x1f\x7f]/;
@@ -26,13 +26,38 @@ export function validatedPyPISpec(spec) {
26
26
  }
27
27
  return spec;
28
28
  }
29
+ /**
30
+ * Seeded presets offered for `type`, minus any the user has removed.
31
+ *
32
+ * These are resolved here rather than written into agents.yaml. Seeding used to
33
+ * happen on the state READ path and persisted the entry — but agents.yaml is
34
+ * git-tracked in the user's DotAgents repo, so that write left the working tree
35
+ * dirty and every `agents repo pull` aborted with "Working tree has uncommitted
36
+ * changes", naming neither the file nor the cause. Since every `agents`
37
+ * invocation reads state, the dirt returned the instant it was cleared, and the
38
+ * loop could not be escaped through the CLI at all (RUSH-1925).
39
+ *
40
+ * `seededPresets` is the removal tombstone: a key listed there means the user
41
+ * removed that preset, so it is not offered again. Users who were seeded under
42
+ * the old behaviour have the key listed *and* the entry in their own
43
+ * `registries:` block, which still wins below — so nothing disappears for them.
44
+ */
45
+ function offeredSeeds(type, meta) {
46
+ const removed = new Set(meta.seededPresets || []);
47
+ const offered = {};
48
+ for (const [name, config] of Object.entries(SEEDED_REGISTRIES[type] || {})) {
49
+ if (!removed.has(`${type}.${name}`))
50
+ offered[name] = { ...config };
51
+ }
52
+ return offered;
53
+ }
29
54
  /** Get all registries of a given type, merging defaults with user overrides. */
30
55
  export function getRegistries(type) {
31
56
  const meta = readMeta();
32
57
  const defaultRegs = DEFAULT_REGISTRIES[type] || {};
33
58
  const userRegs = meta.registries?.[type] || {};
34
59
  // Merge defaults with user config (user overrides defaults)
35
- return { ...defaultRegs, ...userRegs };
60
+ return { ...defaultRegs, ...offeredSeeds(type, meta), ...userRegs };
36
61
  }
37
62
  /** Get only the enabled registries of a given type. */
38
63
  export function getEnabledRegistries(type) {
@@ -50,19 +75,34 @@ export function setRegistry(type, name, config) {
50
75
  if (!meta.registries[type]) {
51
76
  meta.registries[type] = {};
52
77
  }
53
- const existing = meta.registries[type][name] || DEFAULT_REGISTRIES[type]?.[name];
78
+ // Seeded presets are resolved in memory and never materialized in agents.yaml
79
+ // (see offeredSeeds), so a partial update — `registry disable`, `enable`, or
80
+ // `config --api-key` — has nothing stored to merge with. Without the
81
+ // SEEDED_REGISTRIES fallback it would persist only the changed fields and drop
82
+ // `url`, and because userRegs wins over the in-memory seed in getRegistries the
83
+ // preset would stay broken even after re-enabling.
84
+ const existing = meta.registries[type][name]
85
+ || DEFAULT_REGISTRIES[type]?.[name]
86
+ || SEEDED_REGISTRIES[type]?.[name];
54
87
  meta.registries[type][name] = { ...existing, ...config };
55
88
  writeMeta(meta);
56
89
  }
57
- /** Remove a user-configured registry. Returns false if it did not exist. */
90
+ /** Remove a user-configured or seeded registry. Returns false if it did not exist. */
58
91
  export function removeRegistry(type, name) {
59
92
  const meta = readMeta();
60
- if (meta.registries?.[type]?.[name]) {
93
+ const inUserConfig = !!meta.registries?.[type]?.[name];
94
+ // A seeded preset has no entry in agents.yaml until the user edits it, so
95
+ // removal is recorded as a tombstone in seededPresets instead of a deletion.
96
+ const isOfferedSeed = !!offeredSeeds(type, meta)[name];
97
+ if (!inUserConfig && !isOfferedSeed)
98
+ return false;
99
+ if (inUserConfig)
61
100
  delete meta.registries[type][name];
62
- writeMeta(meta);
63
- return true;
101
+ if (SEEDED_REGISTRIES[type]?.[name]) {
102
+ meta.seededPresets = [...new Set([...(meta.seededPresets || []), `${type}.${name}`])];
64
103
  }
65
- return false;
104
+ writeMeta(meta);
105
+ return true;
66
106
  }
67
107
  /**
68
108
  * Cap every registry network call. Without this a slow or unreachable registry
@@ -126,6 +126,24 @@ export declare function pickBalancedCandidate(candidates: RotateCandidate[]): Ro
126
126
  */
127
127
  export declare function pickAvailableCandidate(candidates: RotateCandidate[], preferredVersion?: string | null): RotateResult | null;
128
128
  export declare function collectRunCandidates(agent: AgentId): Promise<RotateCandidate[]>;
129
+ /**
130
+ * Resolve an account identity to the installed version slot that holds it, over
131
+ * an already-collected candidate list. Pure — no I/O — so it is unit-tested
132
+ * directly. Matches, case-insensitively, against a candidate's login `email`
133
+ * (the usual form) or its `accountKey`, and only ever returns a signed-in slot.
134
+ * Returns null when nothing matches, so the caller can fall back and warn.
135
+ */
136
+ export declare function matchAccountVersion(candidates: RotateCandidate[], account: string): string | null;
137
+ /**
138
+ * Resolve a routine's `account:` pin (login email or account key) to the
139
+ * installed version currently holding that account. Thin I/O wrapper over
140
+ * {@link collectRunCandidates} + {@link matchAccountVersion}; returns null when
141
+ * no signed-in version matches. Pinning a routine to a distinct account is the
142
+ * durable cure for the shared-single-use-refresh-token revocation storm
143
+ * (RUSH-1957): the pinned run never rotates and never lands on another
144
+ * routine's credential.
145
+ */
146
+ export declare function resolveAccountVersion(agent: AgentId, account: string): Promise<string | null>;
129
147
  /**
130
148
  * Pick a healthy version for `agent` using weighted random by remaining
131
149
  * capacity. See `pickBalancedCandidate` for algorithm details.
@@ -316,6 +316,34 @@ export async function collectRunCandidates(agent) {
316
316
  };
317
317
  });
318
318
  }
319
+ /**
320
+ * Resolve an account identity to the installed version slot that holds it, over
321
+ * an already-collected candidate list. Pure — no I/O — so it is unit-tested
322
+ * directly. Matches, case-insensitively, against a candidate's login `email`
323
+ * (the usual form) or its `accountKey`, and only ever returns a signed-in slot.
324
+ * Returns null when nothing matches, so the caller can fall back and warn.
325
+ */
326
+ export function matchAccountVersion(candidates, account) {
327
+ const needle = account.trim().toLowerCase();
328
+ if (!needle)
329
+ return null;
330
+ const match = candidates.find((c) => c.signedIn &&
331
+ (c.email?.toLowerCase() === needle || c.accountKey?.toLowerCase() === needle));
332
+ return match?.version ?? null;
333
+ }
334
+ /**
335
+ * Resolve a routine's `account:` pin (login email or account key) to the
336
+ * installed version currently holding that account. Thin I/O wrapper over
337
+ * {@link collectRunCandidates} + {@link matchAccountVersion}; returns null when
338
+ * no signed-in version matches. Pinning a routine to a distinct account is the
339
+ * durable cure for the shared-single-use-refresh-token revocation storm
340
+ * (RUSH-1957): the pinned run never rotates and never lands on another
341
+ * routine's credential.
342
+ */
343
+ export async function resolveAccountVersion(agent, account) {
344
+ const candidates = await collectRunCandidates(agent);
345
+ return matchAccountVersion(candidates, account);
346
+ }
319
347
  /**
320
348
  * Pick a healthy version for `agent` using weighted random by remaining
321
349
  * capacity. See `pickBalancedCandidate` for algorithm details.
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Routine lifecycle desktop notifications (RUSH-2030).
3
+ *
4
+ * The daemon fires a branded notification when a scheduled routine starts and
5
+ * when it finishes (success or failure), routed through the MenubarHelper
6
+ * companion (notify-desktop.ts) so it carries the agents-cli mark.
7
+ *
8
+ * Anti-spam threshold (Acceptance Criteria #4 — "define a sensible threshold so
9
+ * users are not spammed"):
10
+ * - Agent / workflow routines: notify on BOTH start and finish. These are the
11
+ * runs whose output a user actually wants surfaced.
12
+ * - Command routines (deterministic housekeeping — version checks, `git pull`,
13
+ * notify shims that can fire every minute): notify only on FAILURE. A green
14
+ * housekeeping run is noise; a broken one is worth a ping. No start ping.
15
+ * - "Notable output" is folded into the single finish notification rather than
16
+ * sent as a third message: on failure the error reason, on success the first
17
+ * line of the run's report (the user-facing result) when the routine produced
18
+ * one. One start + one finish per run — never a stream.
19
+ *
20
+ * The pure builders (`routineStartNotification` / `routineFinishNotification`)
21
+ * return `null` when the threshold says "don't notify", and are unit-tested; the
22
+ * `notifyRoutine*` wrappers do the filesystem read + dispatch for the daemon.
23
+ */
24
+ import type { JobConfig, RunMeta } from './routines.js';
25
+ import { type DesktopNotification } from './menubar/notify-desktop.js';
26
+ type RoutineKind = 'agent' | 'workflow' | 'command';
27
+ /** Which flavor of routine a config/meta describes — drives the notify threshold. */
28
+ export declare function routineKind(r: Pick<JobConfig, 'agent' | 'workflow' | 'command'>): RoutineKind;
29
+ /** "1m 20s" / "45s" / "2h 3m" from a millisecond duration, or null when unknown. */
30
+ export declare function formatDuration(ms: number | undefined): string | null;
31
+ /**
32
+ * First non-empty line of a run report, trimmed to a notification-sized snippet.
33
+ * This is the "notable output" surfaced on a successful finish — the routine's
34
+ * own user-facing result. Returns null for an empty/whitespace report so the
35
+ * caller falls back to a plain "Completed" body.
36
+ */
37
+ export declare function notableSnippet(report: string | null | undefined, maxLen?: number): string | null;
38
+ /**
39
+ * Notification for a routine START, or null when the threshold suppresses it
40
+ * (command-mode housekeeping). Clicking opens the runs folder
41
+ * (~/.agents/.history/runs).
42
+ */
43
+ export declare function routineStartNotification(config: Pick<JobConfig, 'name' | 'agent' | 'workflow' | 'command'>): DesktopNotification | null;
44
+ /**
45
+ * Notification for a routine that failed to even START — `executeJobDetached`
46
+ * threw before the child was spawned, so no run record and no finish will ever
47
+ * exist. The daemon fires the START notification unconditionally, so this closes
48
+ * the "exactly one start + one finish" invariant: the orphaned start gets its
49
+ * matching failure banner (RUSH-2030). Unlike a green finish this is never
50
+ * suppressed — a broken start is worth a ping for every routine kind, including
51
+ * command housekeeping. Clicking opens the runs folder (~/.agents/.history/runs)
52
+ * since there is no run report to open.
53
+ */
54
+ export declare function routineStartFailedNotification(config: Pick<JobConfig, 'name' | 'agent' | 'workflow' | 'command'>, error: string): DesktopNotification;
55
+ /**
56
+ * Notification for a routine FINISH, or null when the threshold suppresses it
57
+ * (a successful command-mode housekeeping run). Success carries the report's
58
+ * first line when present (the notable output); failure carries the reason.
59
+ * Clicking opens the run report/log when one is available, else the runs folder
60
+ * (~/.agents/.history/runs).
61
+ */
62
+ export declare function routineFinishNotification(meta: Pick<RunMeta, 'jobName' | 'status' | 'exitCode' | 'errorMessage' | 'duration' | 'agent' | 'workflow' | 'command'>, opts?: {
63
+ report?: string | null;
64
+ artifactPath?: string | null;
65
+ }): DesktopNotification | null;
66
+ /** Daemon glue: fire the START notification for a triggered routine. Best-effort. */
67
+ export declare function notifyRoutineStart(config: JobConfig): void;
68
+ /**
69
+ * Daemon glue: fire the "failed to start" notification when a routine trigger
70
+ * threw before spawning a child. Pairs with the unconditional START ping so a
71
+ * pre-spawn failure never leaves an orphaned "Routine started". Best-effort.
72
+ */
73
+ export declare function notifyRoutineStartFailed(config: JobConfig, error: string): void;
74
+ /** Daemon glue: fire the FINISH notification for a completed run. Best-effort. */
75
+ export declare function notifyRoutineFinish(meta: RunMeta): void;
76
+ export {};
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Routine lifecycle desktop notifications (RUSH-2030).
3
+ *
4
+ * The daemon fires a branded notification when a scheduled routine starts and
5
+ * when it finishes (success or failure), routed through the MenubarHelper
6
+ * companion (notify-desktop.ts) so it carries the agents-cli mark.
7
+ *
8
+ * Anti-spam threshold (Acceptance Criteria #4 — "define a sensible threshold so
9
+ * users are not spammed"):
10
+ * - Agent / workflow routines: notify on BOTH start and finish. These are the
11
+ * runs whose output a user actually wants surfaced.
12
+ * - Command routines (deterministic housekeeping — version checks, `git pull`,
13
+ * notify shims that can fire every minute): notify only on FAILURE. A green
14
+ * housekeeping run is noise; a broken one is worth a ping. No start ping.
15
+ * - "Notable output" is folded into the single finish notification rather than
16
+ * sent as a third message: on failure the error reason, on success the first
17
+ * line of the run's report (the user-facing result) when the routine produced
18
+ * one. One start + one finish per run — never a stream.
19
+ *
20
+ * The pure builders (`routineStartNotification` / `routineFinishNotification`)
21
+ * return `null` when the threshold says "don't notify", and are unit-tested; the
22
+ * `notifyRoutine*` wrappers do the filesystem read + dispatch for the daemon.
23
+ */
24
+ import * as fs from 'fs';
25
+ import * as path from 'path';
26
+ import { getRunDir } from './routines.js';
27
+ import { notifyDesktop } from './menubar/notify-desktop.js';
28
+ /** Which flavor of routine a config/meta describes — drives the notify threshold. */
29
+ export function routineKind(r) {
30
+ if (r.command)
31
+ return 'command';
32
+ if (r.workflow)
33
+ return 'workflow';
34
+ return 'agent';
35
+ }
36
+ /** Human label for the routine body ("agent claude", "workflow deploy", "command"). */
37
+ function routineLabel(r) {
38
+ if (r.command)
39
+ return 'command';
40
+ if (r.workflow)
41
+ return `workflow ${r.workflow}`;
42
+ return `agent ${r.agent ?? 'unknown'}`;
43
+ }
44
+ /** "1m 20s" / "45s" / "2h 3m" from a millisecond duration, or null when unknown. */
45
+ export function formatDuration(ms) {
46
+ if (ms === undefined || !Number.isFinite(ms) || ms < 0)
47
+ return null;
48
+ const totalSec = Math.round(ms / 1000);
49
+ if (totalSec < 60)
50
+ return `${totalSec}s`;
51
+ const min = Math.floor(totalSec / 60);
52
+ const sec = totalSec % 60;
53
+ if (min < 60)
54
+ return sec ? `${min}m ${sec}s` : `${min}m`;
55
+ const hr = Math.floor(min / 60);
56
+ const remMin = min % 60;
57
+ return remMin ? `${hr}h ${remMin}m` : `${hr}h`;
58
+ }
59
+ /**
60
+ * First non-empty line of a run report, trimmed to a notification-sized snippet.
61
+ * This is the "notable output" surfaced on a successful finish — the routine's
62
+ * own user-facing result. Returns null for an empty/whitespace report so the
63
+ * caller falls back to a plain "Completed" body.
64
+ */
65
+ export function notableSnippet(report, maxLen = 140) {
66
+ if (!report)
67
+ return null;
68
+ const firstLine = report
69
+ .split('\n')
70
+ .map((l) => l.trim())
71
+ .find((l) => l.length > 0);
72
+ if (!firstLine)
73
+ return null;
74
+ return firstLine.length > maxLen ? `${firstLine.slice(0, maxLen - 1).trimEnd()}…` : firstLine;
75
+ }
76
+ /** Encode a click action that opens a file (report/log) in the default app. */
77
+ function openAction(filePath) {
78
+ return filePath ? `open:${filePath}` : undefined;
79
+ }
80
+ /**
81
+ * Notification for a routine START, or null when the threshold suppresses it
82
+ * (command-mode housekeeping). Clicking opens the runs folder
83
+ * (~/.agents/.history/runs).
84
+ */
85
+ export function routineStartNotification(config) {
86
+ if (routineKind(config) === 'command')
87
+ return null;
88
+ return {
89
+ title: 'Routine started',
90
+ subtitle: config.name,
91
+ body: `Running ${routineLabel(config)}`,
92
+ action: 'routines:list',
93
+ };
94
+ }
95
+ /**
96
+ * Notification for a routine that failed to even START — `executeJobDetached`
97
+ * threw before the child was spawned, so no run record and no finish will ever
98
+ * exist. The daemon fires the START notification unconditionally, so this closes
99
+ * the "exactly one start + one finish" invariant: the orphaned start gets its
100
+ * matching failure banner (RUSH-2030). Unlike a green finish this is never
101
+ * suppressed — a broken start is worth a ping for every routine kind, including
102
+ * command housekeeping. Clicking opens the runs folder (~/.agents/.history/runs)
103
+ * since there is no run report to open.
104
+ */
105
+ export function routineStartFailedNotification(config, error) {
106
+ return {
107
+ title: 'Routine failed',
108
+ subtitle: config.name,
109
+ body: `Failed to start: ${error}`,
110
+ action: 'routines:list',
111
+ };
112
+ }
113
+ /**
114
+ * Notification for a routine FINISH, or null when the threshold suppresses it
115
+ * (a successful command-mode housekeeping run). Success carries the report's
116
+ * first line when present (the notable output); failure carries the reason.
117
+ * Clicking opens the run report/log when one is available, else the runs folder
118
+ * (~/.agents/.history/runs).
119
+ */
120
+ export function routineFinishNotification(meta, opts = {}) {
121
+ const kind = routineKind(meta);
122
+ const ok = meta.status === 'completed';
123
+ if (kind === 'command' && ok)
124
+ return null; // green housekeeping is noise
125
+ const action = openAction(opts.artifactPath) ?? 'routines:list';
126
+ if (ok) {
127
+ const snippet = notableSnippet(opts.report);
128
+ const dur = formatDuration(meta.duration);
129
+ return {
130
+ title: 'Routine finished',
131
+ subtitle: meta.jobName,
132
+ body: snippet ?? (dur ? `Completed in ${dur}` : 'Completed'),
133
+ action,
134
+ };
135
+ }
136
+ // failed | timeout
137
+ const reason = meta.status === 'timeout'
138
+ ? 'Timed out'
139
+ : meta.errorMessage
140
+ ? meta.errorMessage
141
+ : `Exited with code ${meta.exitCode ?? '?'}`;
142
+ return {
143
+ title: 'Routine failed',
144
+ subtitle: meta.jobName,
145
+ body: reason,
146
+ action,
147
+ };
148
+ }
149
+ /** Read a finished run's report text + the best artifact to open on click. */
150
+ function loadRunArtifacts(meta) {
151
+ try {
152
+ const runDir = getRunDir(meta.jobName, meta.runId);
153
+ const reportPath = path.join(runDir, 'report.md');
154
+ const stdoutPath = path.join(runDir, 'stdout.log');
155
+ let report = null;
156
+ let artifactPath = null;
157
+ if (fs.existsSync(reportPath)) {
158
+ report = fs.readFileSync(reportPath, 'utf-8');
159
+ artifactPath = reportPath;
160
+ }
161
+ else if (fs.existsSync(stdoutPath)) {
162
+ artifactPath = stdoutPath;
163
+ }
164
+ return { report, artifactPath };
165
+ }
166
+ catch {
167
+ return { report: null, artifactPath: null };
168
+ }
169
+ }
170
+ /** Daemon glue: fire the START notification for a triggered routine. Best-effort. */
171
+ export function notifyRoutineStart(config) {
172
+ const n = routineStartNotification(config);
173
+ if (n)
174
+ notifyDesktop(n);
175
+ }
176
+ /**
177
+ * Daemon glue: fire the "failed to start" notification when a routine trigger
178
+ * threw before spawning a child. Pairs with the unconditional START ping so a
179
+ * pre-spawn failure never leaves an orphaned "Routine started". Best-effort.
180
+ */
181
+ export function notifyRoutineStartFailed(config, error) {
182
+ notifyDesktop(routineStartFailedNotification(config, error));
183
+ }
184
+ /** Daemon glue: fire the FINISH notification for a completed run. Best-effort. */
185
+ export function notifyRoutineFinish(meta) {
186
+ const { report, artifactPath } = loadRunArtifacts(meta);
187
+ const n = routineFinishNotification(meta, { report, artifactPath });
188
+ if (n)
189
+ notifyDesktop(n);
190
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Runtime placement resolution for routines: local / host / fleet / cloud.
3
+ *
4
+ * The scheduler only decides *whether* this machine may fire the job
5
+ * (`devices` + `jobRunsOnThisDevice`). This module decides *where the job
6
+ * body runs* once it has been fired, using `hostStrategy`.
7
+ *
8
+ * Fleet semantics (RUSH-2035 / RUSH-1980): pick exactly one online device per
9
+ * fire. Cross-device double-fire is prevented by requiring a `devices` firing
10
+ * pin for fleet/host/cloud strategies (applied at add/sync time).
11
+ */
12
+ import type { JobConfig } from './routines.js';
13
+ export type PlacementTarget = {
14
+ mode: 'local';
15
+ } | {
16
+ mode: 'host';
17
+ host: string;
18
+ } | {
19
+ mode: 'cloud';
20
+ };
21
+ /**
22
+ * Pick one online fleet device for `hostStrategy: fleet`.
23
+ *
24
+ * Preference order:
25
+ * 1. This machine, if online and eligible (avoids needless SSH)
26
+ * 2. First eligible online device by name (stable / deterministic)
27
+ *
28
+ * `config.devices` is the *firing* allowlist only (which daemon may fire the
29
+ * job). It is intentionally NOT used as an execution pool filter — otherwise
30
+ * the double-fire pin (`devices: [self]`) would collapse fleet placement to
31
+ * always-local. Control / offline / no-address devices are never chosen.
32
+ */
33
+ export declare function pickFleetDevice(_config?: Pick<JobConfig, 'devices'>): string | null;
34
+ /**
35
+ * Resolve where a fired job's body should execute.
36
+ * Throws a human-readable Error when placement cannot be satisfied.
37
+ */
38
+ export declare function resolvePlacementTarget(config: JobConfig): PlacementTarget;