@phnx-labs/agents-cli 1.20.35 → 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 (225) 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.d.ts +71 -1
  34. package/dist/commands/sessions.js +365 -37
  35. package/dist/commands/setup.js +4 -2
  36. package/dist/commands/sync.d.ts +3 -1
  37. package/dist/commands/sync.js +156 -4
  38. package/dist/commands/teams.js +217 -0
  39. package/dist/commands/versions.js +2 -4
  40. package/dist/commands/watchdog.d.ts +18 -0
  41. package/dist/commands/watchdog.js +238 -0
  42. package/dist/index.js +25 -2
  43. package/dist/lib/audit/log.d.ts +92 -0
  44. package/dist/lib/audit/log.js +177 -0
  45. package/dist/lib/auto-pull.js +2 -1
  46. package/dist/lib/browser/chrome.d.ts +10 -0
  47. package/dist/lib/browser/chrome.js +18 -7
  48. package/dist/lib/browser/drivers/ssh.js +2 -1
  49. package/dist/lib/browser/har.d.ts +84 -0
  50. package/dist/lib/browser/har.js +77 -0
  51. package/dist/lib/browser/ipc.js +24 -3
  52. package/dist/lib/browser/profiles.d.ts +1 -1
  53. package/dist/lib/browser/profiles.js +8 -10
  54. package/dist/lib/browser/refs.d.ts +65 -0
  55. package/dist/lib/browser/refs.js +73 -1
  56. package/dist/lib/browser/runtime-state.js +1 -0
  57. package/dist/lib/browser/service.d.ts +38 -2
  58. package/dist/lib/browser/service.js +112 -8
  59. package/dist/lib/browser/types.d.ts +14 -1
  60. package/dist/lib/budget/live-cloud.d.ts +42 -0
  61. package/dist/lib/budget/live-cloud.js +79 -0
  62. package/dist/lib/budget/live-team.d.ts +31 -0
  63. package/dist/lib/budget/live-team.js +115 -0
  64. package/dist/lib/cloud/codex.js +4 -0
  65. package/dist/lib/cloud/rush.d.ts +12 -1
  66. package/dist/lib/cloud/rush.js +13 -3
  67. package/dist/lib/cloud/types.d.ts +9 -0
  68. package/dist/lib/computer/dispatch.d.ts +8 -0
  69. package/dist/lib/computer/dispatch.js +125 -0
  70. package/dist/lib/computer/loop.d.ts +62 -0
  71. package/dist/lib/computer/loop.js +98 -0
  72. package/dist/lib/computer/model.d.ts +44 -0
  73. package/dist/lib/computer/model.js +157 -0
  74. package/dist/lib/concurrency.d.ts +19 -0
  75. package/dist/lib/concurrency.js +33 -0
  76. package/dist/lib/daemon.d.ts +57 -0
  77. package/dist/lib/daemon.js +192 -16
  78. package/dist/lib/devices/registry.d.ts +7 -0
  79. package/dist/lib/devices/registry.js +24 -0
  80. package/dist/lib/devices/tailscale.js +1 -1
  81. package/dist/lib/drift.d.ts +52 -0
  82. package/dist/lib/drift.js +112 -0
  83. package/dist/lib/events.d.ts +1 -1
  84. package/dist/lib/events.js +31 -13
  85. package/dist/lib/exec.d.ts +17 -0
  86. package/dist/lib/exec.js +79 -13
  87. package/dist/lib/git.d.ts +27 -0
  88. package/dist/lib/git.js +56 -1
  89. package/dist/lib/hooks/cache.d.ts +6 -0
  90. package/dist/lib/hooks/cache.js +54 -12
  91. package/dist/lib/hooks.d.ts +27 -0
  92. package/dist/lib/hooks.js +127 -8
  93. package/dist/lib/hosts/dispatch.d.ts +15 -0
  94. package/dist/lib/hosts/dispatch.js +39 -6
  95. package/dist/lib/hosts/logs.js +30 -1
  96. package/dist/lib/hosts/option.js +1 -1
  97. package/dist/lib/hosts/passthrough.js +3 -1
  98. package/dist/lib/hosts/ready.d.ts +29 -6
  99. package/dist/lib/hosts/ready.js +66 -15
  100. package/dist/lib/hosts/registry.d.ts +19 -2
  101. package/dist/lib/hosts/registry.js +58 -2
  102. package/dist/lib/hosts/remote-cmd.d.ts +62 -1
  103. package/dist/lib/hosts/remote-cmd.js +70 -1
  104. package/dist/lib/hosts/remote-os.d.ts +17 -0
  105. package/dist/lib/hosts/remote-os.js +30 -0
  106. package/dist/lib/hosts/session-index.d.ts +34 -0
  107. package/dist/lib/hosts/session-index.js +56 -0
  108. package/dist/lib/hosts/tasks.d.ts +14 -0
  109. package/dist/lib/hosts/tasks.js +15 -0
  110. package/dist/lib/lock.d.ts +93 -0
  111. package/dist/lib/lock.js +207 -0
  112. package/dist/lib/loop.js +16 -1
  113. package/dist/lib/machine-id.d.ts +21 -0
  114. package/dist/lib/machine-id.js +26 -0
  115. package/dist/lib/mailbox-target.d.ts +36 -0
  116. package/dist/lib/mailbox-target.js +45 -0
  117. package/dist/lib/mailbox.d.ts +47 -0
  118. package/dist/lib/mailbox.js +194 -0
  119. package/dist/lib/mcp.d.ts +5 -0
  120. package/dist/lib/mcp.js +24 -8
  121. package/dist/lib/migrate.d.ts +19 -0
  122. package/dist/lib/migrate.js +134 -26
  123. package/dist/lib/overdue.js +3 -0
  124. package/dist/lib/picker.d.ts +2 -0
  125. package/dist/lib/picker.js +4 -1
  126. package/dist/lib/platform/exec.d.ts +46 -0
  127. package/dist/lib/platform/exec.js +74 -0
  128. package/dist/lib/platform/process.d.ts +31 -0
  129. package/dist/lib/platform/process.js +34 -1
  130. package/dist/lib/platform/winpath.js +2 -0
  131. package/dist/lib/plugins.js +16 -6
  132. package/dist/lib/profiles.d.ts +25 -0
  133. package/dist/lib/profiles.js +22 -6
  134. package/dist/lib/pty-client.js +2 -1
  135. package/dist/lib/rotate.d.ts +61 -0
  136. package/dist/lib/rotate.js +52 -0
  137. package/dist/lib/routines.d.ts +40 -2
  138. package/dist/lib/routines.js +66 -8
  139. package/dist/lib/runner.d.ts +11 -2
  140. package/dist/lib/runner.js +49 -7
  141. package/dist/lib/scheduler.js +6 -1
  142. package/dist/lib/secrets/bundles.d.ts +60 -4
  143. package/dist/lib/secrets/bundles.js +131 -12
  144. package/dist/lib/secrets/filestore.d.ts +3 -0
  145. package/dist/lib/secrets/filestore.js +42 -16
  146. package/dist/lib/secrets/index.d.ts +43 -2
  147. package/dist/lib/secrets/index.js +102 -3
  148. package/dist/lib/secrets/mcp.d.ts +93 -0
  149. package/dist/lib/secrets/mcp.js +205 -0
  150. package/dist/lib/secrets/remote.js +12 -5
  151. package/dist/lib/secrets/sync.js +83 -4
  152. package/dist/lib/secrets/windows.js +14 -3
  153. package/dist/lib/serve/data.d.ts +81 -0
  154. package/dist/lib/serve/data.js +91 -0
  155. package/dist/lib/serve/page.d.ts +7 -0
  156. package/dist/lib/serve/page.js +140 -0
  157. package/dist/lib/serve/server.d.ts +46 -0
  158. package/dist/lib/serve/server.js +115 -0
  159. package/dist/lib/session/active.d.ts +54 -0
  160. package/dist/lib/session/active.js +190 -19
  161. package/dist/lib/session/discover.d.ts +37 -0
  162. package/dist/lib/session/discover.js +111 -28
  163. package/dist/lib/session/inject.d.ts +18 -0
  164. package/dist/lib/session/inject.js +21 -0
  165. package/dist/lib/session/parse.js +23 -20
  166. package/dist/lib/session/pid-registry.d.ts +1 -0
  167. package/dist/lib/session/pid-registry.js +24 -0
  168. package/dist/lib/session/provenance.d.ts +14 -2
  169. package/dist/lib/session/provenance.js +39 -8
  170. package/dist/lib/session/remote-active.js +19 -7
  171. package/dist/lib/session/remote-list.d.ts +51 -0
  172. package/dist/lib/session/remote-list.js +213 -0
  173. package/dist/lib/session/remote.d.ts +7 -1
  174. package/dist/lib/session/remote.js +16 -2
  175. package/dist/lib/session/sync/config.d.ts +1 -15
  176. package/dist/lib/session/sync/config.js +4 -20
  177. package/dist/lib/session/types.d.ts +17 -0
  178. package/dist/lib/shims.d.ts +36 -6
  179. package/dist/lib/shims.js +91 -29
  180. package/dist/lib/ssh-exec.js +2 -0
  181. package/dist/lib/ssh-tunnel.js +2 -1
  182. package/dist/lib/startup/command-registry.d.ts +6 -0
  183. package/dist/lib/startup/command-registry.js +13 -1
  184. package/dist/lib/state.d.ts +13 -0
  185. package/dist/lib/state.js +103 -9
  186. package/dist/lib/sync-umbrella.d.ts +14 -7
  187. package/dist/lib/sync-umbrella.js +17 -9
  188. package/dist/lib/teams/forEach.d.ts +110 -0
  189. package/dist/lib/teams/forEach.js +186 -0
  190. package/dist/lib/teams/index.d.ts +1 -0
  191. package/dist/lib/teams/index.js +1 -0
  192. package/dist/lib/teams/pr-watch.d.ts +226 -0
  193. package/dist/lib/teams/pr-watch.js +371 -0
  194. package/dist/lib/teams/supervisor.d.ts +14 -1
  195. package/dist/lib/teams/supervisor.js +19 -0
  196. package/dist/lib/teams/worktree.d.ts +9 -0
  197. package/dist/lib/teams/worktree.js +32 -0
  198. package/dist/lib/terminal/backends/index.d.ts +2 -1
  199. package/dist/lib/terminal/backends/index.js +3 -1
  200. package/dist/lib/terminal/backends/vscodium-agent.d.ts +31 -0
  201. package/dist/lib/terminal/backends/vscodium-agent.js +72 -0
  202. package/dist/lib/terminal/index.d.ts +4 -1
  203. package/dist/lib/terminal/index.js +4 -1
  204. package/dist/lib/terminal/inject.d.ts +204 -0
  205. package/dist/lib/terminal/inject.js +247 -0
  206. package/dist/lib/terminal/resolve.d.ts +64 -0
  207. package/dist/lib/terminal/resolve.js +90 -0
  208. package/dist/lib/terminal/types.d.ts +1 -1
  209. package/dist/lib/triggers/webhook.d.ts +85 -0
  210. package/dist/lib/triggers/webhook.js +141 -0
  211. package/dist/lib/versions.d.ts +23 -0
  212. package/dist/lib/versions.js +119 -13
  213. package/dist/lib/watchdog/index.d.ts +3 -0
  214. package/dist/lib/watchdog/index.js +5 -0
  215. package/dist/lib/watchdog/read.d.ts +35 -0
  216. package/dist/lib/watchdog/read.js +149 -0
  217. package/dist/lib/watchdog/runner.d.ts +127 -0
  218. package/dist/lib/watchdog/runner.js +322 -0
  219. package/dist/lib/watchdog/watchdog.d.ts +40 -0
  220. package/dist/lib/watchdog/watchdog.js +166 -0
  221. package/dist/lib/watchdog/watchdogTail.d.ts +5 -0
  222. package/dist/lib/watchdog/watchdogTail.js +154 -0
  223. package/dist/lib/workflows.d.ts +166 -0
  224. package/dist/lib/workflows.js +193 -0
  225. package/package.json +5 -4
@@ -22,6 +22,14 @@ export interface Profile {
22
22
  description?: string;
23
23
  preset?: string;
24
24
  provider?: string;
25
+ /**
26
+ * Optional secondary model retried on the same host when the primary model
27
+ * env value hits a rate limit. Reuses the `--fallback` cascade in
28
+ * `runWithFallback` (src/lib/exec.ts) — the swap is expressed as an
29
+ * envOverride on a same-agent FallbackEntry, so only the model env var
30
+ * changes; auth, base URL, and every other profile env value are preserved.
31
+ */
32
+ fallback_model?: string;
25
33
  }
26
34
  /**
27
35
  * Stable, machine-readable summary used by `agents view` and `--json`.
@@ -59,6 +67,13 @@ export declare function profileHostLabel(profile: Profile): string;
59
67
  export declare function profileProviderLabel(profile: Profile): string;
60
68
  /** Return the configured model env value for display. */
61
69
  export declare function profileModelLabel(profile: Profile): string;
70
+ /**
71
+ * Return the env var key that carries this profile's model (e.g.
72
+ * `ANTHROPIC_MODEL`), or null when the profile has no recognizable model env.
73
+ * `fallback_model` swaps THIS key so provider selection, auth, and base URL
74
+ * are all preserved on retry.
75
+ */
76
+ export declare function profileModelEnvKey(profile: Profile): string | null;
62
77
  /**
63
78
  * Build a non-secret auth identity/status label for list surfaces.
64
79
  *
@@ -89,6 +104,16 @@ export interface ResolvedProfileRun {
89
104
  version?: string;
90
105
  env: Record<string, string>;
91
106
  profileName: string;
107
+ /**
108
+ * Same-host model swap for the `--fallback` cascade. Present only when the
109
+ * profile declares `fallback_model` AND the profile has an identifiable
110
+ * model env key to swap. `envKey` names the var (e.g. `ANTHROPIC_MODEL`),
111
+ * `model` is the value to write on the retry attempt.
112
+ */
113
+ fallbackModel?: {
114
+ envKey: string;
115
+ model: string;
116
+ };
92
117
  }
93
118
  /**
94
119
  * Resolve a name into (agent, version, env). Throws if the name is not a
@@ -111,16 +111,25 @@ const MODEL_ENV_KEYS = [
111
111
  ];
112
112
  /** Return the configured model env value for display. */
113
113
  export function profileModelLabel(profile) {
114
+ const key = profileModelEnvKey(profile);
115
+ return key ? profile.env[key] : '-';
116
+ }
117
+ /**
118
+ * Return the env var key that carries this profile's model (e.g.
119
+ * `ANTHROPIC_MODEL`), or null when the profile has no recognizable model env.
120
+ * `fallback_model` swaps THIS key so provider selection, auth, and base URL
121
+ * are all preserved on retry.
122
+ */
123
+ export function profileModelEnvKey(profile) {
114
124
  for (const key of MODEL_ENV_KEYS) {
115
- const value = profile.env[key];
116
- if (value)
117
- return value;
125
+ if (profile.env[key])
126
+ return key;
118
127
  }
119
128
  for (const [key, value] of Object.entries(profile.env)) {
120
129
  if ((key === 'MODEL' || key.endsWith('_MODEL')) && value)
121
- return value;
130
+ return key;
122
131
  }
123
- return '-';
132
+ return null;
124
133
  }
125
134
  function decodeJwtPayload(token) {
126
135
  const parts = token.split('.');
@@ -239,12 +248,19 @@ export function resolveProfileEnv(profile) {
239
248
  */
240
249
  export function resolveProfileForRun(name) {
241
250
  const profile = readProfile(name);
242
- return {
251
+ const resolved = {
243
252
  agent: profile.host.agent,
244
253
  version: profile.host.version,
245
254
  env: resolveProfileEnv(profile),
246
255
  profileName: profile.name,
247
256
  };
257
+ if (profile.fallback_model) {
258
+ const envKey = profileModelEnvKey(profile);
259
+ if (envKey) {
260
+ resolved.fallbackModel = { envKey, model: profile.fallback_model };
261
+ }
262
+ }
263
+ return resolved;
248
264
  }
249
265
  /**
250
266
  * Look up the preset a profile was created from, if any. Used by
@@ -10,6 +10,7 @@ import { spawn, execSync } from 'child_process';
10
10
  import { fileURLToPath } from 'url';
11
11
  import * as path from 'path';
12
12
  import { getSocketPath, getPtyLogPath, isPtyServerRunning } from './pty-server.js';
13
+ import { backgroundSpawnOptions } from './platform/process.js';
13
14
  const CONNECT_TIMEOUT_MS = 5000;
14
15
  const RESPONSE_TIMEOUT_MS = 30000;
15
16
  const IS_WINDOWS = process.platform === 'win32';
@@ -38,7 +39,7 @@ async function ensureServer() {
38
39
  const logFd = fs.openSync(logPath, 'a');
39
40
  const child = spawn(bin, args, {
40
41
  stdio: ['ignore', logFd, logFd],
41
- detached: true,
42
+ ...backgroundSpawnOptions({ fdStdio: true }),
42
43
  });
43
44
  child.unref();
44
45
  fs.closeSync(logFd);
@@ -5,6 +5,7 @@
5
5
  * authentication tokens so users maintain active sessions across version switches.
6
6
  */
7
7
  import type { AgentId, RunStrategy } from './types.js';
8
+ import type { FallbackEntry } from './exec.js';
8
9
  import { type AccountInfo } from './agents.js';
9
10
  import { type UsageSnapshot } from './usage.js';
10
11
  export interface RotateCandidate {
@@ -101,3 +102,63 @@ export declare function resolveRunVersion(agent: AgentId, strategy: RunStrategy,
101
102
  version: string | null;
102
103
  rotation: RotateResult | null;
103
104
  }>;
105
+ /**
106
+ * Cap on the number of healthy accounts a single run will re-dispatch through
107
+ * after a mid-run rate limit. Bounds the synthesized chain so a machine signed
108
+ * into many accounts can't turn one 429 into an unbounded cascade of retries.
109
+ */
110
+ export declare const DEFAULT_ROTATION_FAILOVER_LIMIT = 3;
111
+ /**
112
+ * Synthesize a same-agent, cross-account fallback chain from a pre-flight
113
+ * rotation result (issue #348: mid-run rate-limit failover).
114
+ *
115
+ * The account rotation picks ONE version pre-spawn; today a 429 mid-run kills
116
+ * the run with no recovery. `runWithFallback` + `detectRateLimit` already
117
+ * re-dispatch to the NEXT chain entry on a rate limit and hand off the session
118
+ * via `/continue <id>` — but only for explicit `--fallback` chains. This turns
119
+ * the OTHER healthy rotation candidates (every account except the one already
120
+ * picked as the primary) into `FallbackEntry`s so that SAME machinery re-runs
121
+ * the task on the next healthy account of the same agent when the primary 429s.
122
+ *
123
+ * Each account is a distinct installed version (its own home/auth), so the
124
+ * entries are same-agent, different-version — exactly what runWithFallback
125
+ * spawns and what buildFallbackPrompt continues (claude→claude via `/continue`).
126
+ * Candidates are consumed in `rotation.healthy` order, which is sorted by
127
+ * remaining capacity (most headroom first, see compareCandidates), so failover
128
+ * prefers the freshest account.
129
+ *
130
+ * Returns `[]` when there is no rotation (pinned strategy) or the picked account
131
+ * is the only healthy one — so single-account users and non-rotation runs are
132
+ * completely unchanged.
133
+ */
134
+ export declare function rotationFailoverChain(rotation: RotateResult | null, pickedVersion: string, limit?: number): FallbackEntry[];
135
+ /**
136
+ * Whether a run is eligible to have a mid-run rate-limit failover chain armed
137
+ * (issue #348). Failover injects synthesized `FallbackEntry`s into the same
138
+ * `fallback` array that `--fallback` uses — so it must NOT arm for run shapes
139
+ * that reject a non-empty fallback chain, or the run hard-exits on a flag the
140
+ * user never passed. Specifically:
141
+ *
142
+ * - `acp` and `loop` runs bail with "not compatible with --fallback yet" the
143
+ * moment `fallback.length > 0` (src/commands/exec.ts), so arming failover
144
+ * would break a previously-working `agents run … --loop` / `--acp`.
145
+ * - `resumeCheckpoint` runs take the loop path (same guard).
146
+ * - `interactive` / no-prompt runs can't be re-dispatched headlessly.
147
+ * - `explicitFallback` (a user `--fallback` chain OR a profile fallback already
148
+ * unshifted) defines its own recovery; don't layer rotation failover on top.
149
+ * - `hasRotation`/`hasVersion` gate on an actual pre-flight rotation having
150
+ * picked an account, so pinned and non-rotation runs are untouched.
151
+ *
152
+ * Pure so the arming matrix is unit-testable without invoking the run command.
153
+ */
154
+ export interface FailoverArmingContext {
155
+ hasRotation: boolean;
156
+ hasVersion: boolean;
157
+ hasPrompt: boolean;
158
+ explicitFallback: boolean;
159
+ interactive: boolean;
160
+ acp: boolean;
161
+ loop: boolean;
162
+ resumeCheckpoint: boolean;
163
+ }
164
+ export declare function shouldArmRotationFailover(ctx: FailoverArmingContext): boolean;
@@ -341,3 +341,55 @@ export async function resolveRunVersion(agent, strategy, cwd = process.cwd()) {
341
341
  }
342
342
  return { version: fallback, rotation: null };
343
343
  }
344
+ /**
345
+ * Cap on the number of healthy accounts a single run will re-dispatch through
346
+ * after a mid-run rate limit. Bounds the synthesized chain so a machine signed
347
+ * into many accounts can't turn one 429 into an unbounded cascade of retries.
348
+ */
349
+ export const DEFAULT_ROTATION_FAILOVER_LIMIT = 3;
350
+ /**
351
+ * Synthesize a same-agent, cross-account fallback chain from a pre-flight
352
+ * rotation result (issue #348: mid-run rate-limit failover).
353
+ *
354
+ * The account rotation picks ONE version pre-spawn; today a 429 mid-run kills
355
+ * the run with no recovery. `runWithFallback` + `detectRateLimit` already
356
+ * re-dispatch to the NEXT chain entry on a rate limit and hand off the session
357
+ * via `/continue <id>` — but only for explicit `--fallback` chains. This turns
358
+ * the OTHER healthy rotation candidates (every account except the one already
359
+ * picked as the primary) into `FallbackEntry`s so that SAME machinery re-runs
360
+ * the task on the next healthy account of the same agent when the primary 429s.
361
+ *
362
+ * Each account is a distinct installed version (its own home/auth), so the
363
+ * entries are same-agent, different-version — exactly what runWithFallback
364
+ * spawns and what buildFallbackPrompt continues (claude→claude via `/continue`).
365
+ * Candidates are consumed in `rotation.healthy` order, which is sorted by
366
+ * remaining capacity (most headroom first, see compareCandidates), so failover
367
+ * prefers the freshest account.
368
+ *
369
+ * Returns `[]` when there is no rotation (pinned strategy) or the picked account
370
+ * is the only healthy one — so single-account users and non-rotation runs are
371
+ * completely unchanged.
372
+ */
373
+ export function rotationFailoverChain(rotation, pickedVersion, limit = DEFAULT_ROTATION_FAILOVER_LIMIT) {
374
+ if (!rotation || limit <= 0)
375
+ return [];
376
+ const chain = [];
377
+ for (const candidate of rotation.healthy) {
378
+ if (candidate.version === pickedVersion)
379
+ continue; // the primary account
380
+ chain.push({ agent: candidate.agent, version: candidate.version });
381
+ if (chain.length >= limit)
382
+ break;
383
+ }
384
+ return chain;
385
+ }
386
+ export function shouldArmRotationFailover(ctx) {
387
+ return (ctx.hasRotation &&
388
+ ctx.hasVersion &&
389
+ ctx.hasPrompt &&
390
+ !ctx.explicitFallback &&
391
+ !ctx.interactive &&
392
+ !ctx.acp &&
393
+ !ctx.loop &&
394
+ !ctx.resumeCheckpoint);
395
+ }
@@ -7,16 +7,50 @@
7
7
  * scheduling.
8
8
  */
9
9
  import type { AgentId } from './types.js';
10
+ import type { LoopConfig } from './loop.js';
10
11
  /** Tool/site/directory allow-list for sandboxed job execution. */
11
12
  export interface JobAllowConfig {
12
13
  tools?: string[];
13
14
  sites?: string[];
14
15
  dirs?: string[];
15
16
  }
16
- /** Full configuration for a scheduled routine (persisted as YAML). */
17
+ /** GitHub webhook events a routine can be triggered by. */
18
+ export type GithubTriggerEvent = 'pull_request' | 'push' | 'issue_comment' | 'workflow_run';
19
+ /** Canonical set of accepted GitHub trigger events — single source for validation. */
20
+ export declare const GITHUB_TRIGGER_EVENTS: readonly GithubTriggerEvent[];
21
+ /**
22
+ * Map a user-facing `--on` alias to a canonical GitHub trigger event.
23
+ * Accepts the canonical names plus friendly shortcuts (e.g. `pr`, `pr_opened`
24
+ * → `pull_request`, `comment` → `issue_comment`). Returns null when unknown.
25
+ */
26
+ export declare function normalizeTriggerEvent(input: string): GithubTriggerEvent | null;
27
+ /**
28
+ * Event-based fire condition for a routine — an alternative (or complement) to
29
+ * `schedule`. Currently only `github_event`: an incoming GitHub webhook whose
30
+ * event (and optional repo/branch) match fires the job through the same
31
+ * dispatch path a cron fire uses. See `src/lib/triggers/webhook.ts`.
32
+ */
33
+ export interface JobTrigger {
34
+ type: 'github_event';
35
+ event: GithubTriggerEvent;
36
+ /** `owner/name` — when set, only payloads for this repo match. */
37
+ repo?: string;
38
+ /** git branch (ref short name) — when set, only payloads for this branch match. */
39
+ branch?: string;
40
+ }
41
+ /**
42
+ * Full configuration for a routine (persisted as YAML).
43
+ *
44
+ * A job fires on a `schedule` (cron), on a `trigger` (event/webhook), or both.
45
+ * `schedule` remains a first-class field; trigger-only jobs omit it and are
46
+ * skipped by the cron scheduler (they fire only via the webhook receiver).
47
+ */
17
48
  export interface JobConfig {
18
49
  name: string;
19
- schedule: string;
50
+ /** Cron expression. Optional when `trigger` is set (event-only routine). */
51
+ schedule?: string;
52
+ /** Event/webhook fire condition. Optional when `schedule` is set. */
53
+ trigger?: JobTrigger;
20
54
  agent: AgentId;
21
55
  workflow?: string;
22
56
  mode: 'plan' | 'edit' | 'auto' | 'skip' | 'full';
@@ -33,6 +67,8 @@ export interface JobConfig {
33
67
  version?: string;
34
68
  runOnce?: boolean;
35
69
  endAt?: string;
70
+ /** When set, executeJob runs this job through the loop driver instead of once. */
71
+ loop?: LoopConfig;
36
72
  }
37
73
  /** Metadata for a single job execution, persisted as JSON in the run directory. */
38
74
  export interface RunMeta {
@@ -67,6 +103,8 @@ export declare function deleteJob(name: string): boolean;
67
103
  export declare function setJobEnabled(name: string, enabled: boolean): void;
68
104
  /** Validate a partial job config, returning a list of human-readable errors. */
69
105
  export declare function validateJob(config: Partial<JobConfig>): string[];
106
+ /** Validate a job trigger block, returning a list of human-readable errors. */
107
+ export declare function validateTrigger(trigger: unknown): string[];
70
108
  /** True when a job's endAt has already elapsed. False when endAt is unset or in the future. */
71
109
  export declare function isPastEndAt(config: Pick<JobConfig, 'endAt'>, now?: Date): boolean;
72
110
  /** Expand built-in and user-defined template variables in a job's prompt string. */
@@ -13,6 +13,33 @@ import { Cron } from 'croner';
13
13
  import { getRoutinesDir, getRunsDir, ensureAgentsDir, getProjectRoutinesDir } from './state.js';
14
14
  import { safeJoin } from './paths.js';
15
15
  import { ALL_AGENT_IDS } from './agents.js';
16
+ /** Canonical set of accepted GitHub trigger events — single source for validation. */
17
+ export const GITHUB_TRIGGER_EVENTS = [
18
+ 'pull_request',
19
+ 'push',
20
+ 'issue_comment',
21
+ 'workflow_run',
22
+ ];
23
+ /**
24
+ * Map a user-facing `--on` alias to a canonical GitHub trigger event.
25
+ * Accepts the canonical names plus friendly shortcuts (e.g. `pr`, `pr_opened`
26
+ * → `pull_request`, `comment` → `issue_comment`). Returns null when unknown.
27
+ */
28
+ export function normalizeTriggerEvent(input) {
29
+ const key = input.trim().toLowerCase();
30
+ const aliases = {
31
+ pull_request: 'pull_request',
32
+ pr: 'pull_request',
33
+ pr_opened: 'pull_request',
34
+ pull: 'pull_request',
35
+ push: 'push',
36
+ issue_comment: 'issue_comment',
37
+ comment: 'issue_comment',
38
+ workflow_run: 'workflow_run',
39
+ workflow: 'workflow_run',
40
+ };
41
+ return aliases[key] ?? null;
42
+ }
16
43
  /** Default values applied to every job config when fields are omitted. */
17
44
  const JOB_DEFAULTS = {
18
45
  mode: 'plan',
@@ -137,18 +164,28 @@ export function validateJob(config) {
137
164
  if (!config.name || typeof config.name !== 'string') {
138
165
  errors.push('name is required');
139
166
  }
140
- if (!config.schedule || typeof config.schedule !== 'string') {
141
- errors.push('schedule (cron expression) is required');
167
+ const hasSchedule = Boolean(config.schedule && typeof config.schedule === 'string');
168
+ const hasTrigger = config.trigger !== undefined;
169
+ if (!hasSchedule && !hasTrigger) {
170
+ errors.push('schedule (cron expression) or trigger is required');
142
171
  }
143
- else {
144
- // Validate cron expression is parseable
145
- try {
146
- new Cron(config.schedule);
172
+ if (config.schedule !== undefined) {
173
+ if (typeof config.schedule !== 'string') {
174
+ errors.push('schedule must be a cron expression string');
147
175
  }
148
- catch {
149
- errors.push(`invalid cron expression: "${config.schedule}"`);
176
+ else {
177
+ // Validate cron expression is parseable
178
+ try {
179
+ new Cron(config.schedule);
180
+ }
181
+ catch {
182
+ errors.push(`invalid cron expression: "${config.schedule}"`);
183
+ }
150
184
  }
151
185
  }
186
+ if (config.trigger !== undefined) {
187
+ errors.push(...validateTrigger(config.trigger));
188
+ }
152
189
  const hasAgent = Boolean(config.agent && typeof config.agent === 'string');
153
190
  const hasWorkflow = Boolean(config.workflow && typeof config.workflow === 'string');
154
191
  if (!hasAgent && !hasWorkflow) {
@@ -184,6 +221,27 @@ export function validateJob(config) {
184
221
  }
185
222
  return errors;
186
223
  }
224
+ /** Validate a job trigger block, returning a list of human-readable errors. */
225
+ export function validateTrigger(trigger) {
226
+ const errors = [];
227
+ if (!trigger || typeof trigger !== 'object') {
228
+ return ['trigger must be an object'];
229
+ }
230
+ const t = trigger;
231
+ if (t.type !== 'github_event') {
232
+ errors.push("trigger.type must be 'github_event'");
233
+ }
234
+ if (!t.event || !GITHUB_TRIGGER_EVENTS.includes(t.event)) {
235
+ errors.push(`trigger.event must be one of: ${GITHUB_TRIGGER_EVENTS.join(', ')}`);
236
+ }
237
+ if (t.repo !== undefined && (typeof t.repo !== 'string' || !/^[^/\s]+\/[^/\s]+$/.test(t.repo))) {
238
+ errors.push('trigger.repo must be in owner/name form');
239
+ }
240
+ if (t.branch !== undefined && typeof t.branch !== 'string') {
241
+ errors.push('trigger.branch must be a string');
242
+ }
243
+ return errors;
244
+ }
187
245
  function isParseableDate(value) {
188
246
  if (!value.trim())
189
247
  return false;
@@ -8,6 +8,7 @@
8
8
  */
9
9
  import type { JobConfig, RunMeta } from './routines.js';
10
10
  import type { AgentId } from './types.js';
11
+ import type { LoopDeps } from './loop.js';
11
12
  /** Result of a completed job execution, including metadata and optional report. */
12
13
  export interface RunResult {
13
14
  meta: RunMeta;
@@ -15,8 +16,16 @@ export interface RunResult {
15
16
  }
16
17
  /** Build the full CLI argv for executing a job, applying mode, model, and permission flags. */
17
18
  export declare function buildJobCommand(config: JobConfig, resolvedPrompt: string): string[];
18
- /** Execute a job synchronously (waits for completion or timeout before resolving). */
19
- export declare function executeJob(config: JobConfig): Promise<RunResult>;
19
+ /**
20
+ * Execute a job synchronously (waits for completion or timeout before resolving).
21
+ *
22
+ * When `config.loop` is set the job is routed through the loop driver (`runLoop`
23
+ * from loop.ts) instead of a single spawn — same driver as `agents run --loop` and
24
+ * workflow `loop:` blocks (issue #400). The optional `deps` parameter provides
25
+ * injectable seams (runIteration, sleep, writeCheckpoint) used by tests; production
26
+ * callers omit it and get the defaults.
27
+ */
28
+ export declare function executeJob(config: JobConfig, deps?: LoopDeps): Promise<RunResult>;
20
29
  /** Spawn a job as a detached process and return immediately with run metadata. */
21
30
  export declare function executeJobDetached(config: JobConfig): Promise<RunMeta>;
22
31
  /** Extract the final assistant message from a stream-JSON log file as a markdown report. */
@@ -16,6 +16,7 @@ import { prepareJobHome, buildSpawnEnv } from './sandbox.js';
16
16
  import { resolveModel, buildReasoningFlags } from './models.js';
17
17
  import { createTimer, maybeRotate, redactPrompt } from './events.js';
18
18
  import { normalizeMode } from './exec.js';
19
+ import { backgroundSpawnOptions } from './platform/process.js';
19
20
  /** CLI command templates per agent, with {prompt} as a placeholder. */
20
21
  const AGENT_COMMANDS = {
21
22
  claude: ['claude', '-p', '--verbose', '{prompt}', '--output-format', 'stream-json', '--permission-mode', 'plan'],
@@ -152,8 +153,16 @@ function appendModelAndReasoning(cmd, config) {
152
153
  function generateRunId() {
153
154
  return new Date().toISOString().replace(/[:.]/g, '-');
154
155
  }
155
- /** Execute a job synchronously (waits for completion or timeout before resolving). */
156
- export async function executeJob(config) {
156
+ /**
157
+ * Execute a job synchronously (waits for completion or timeout before resolving).
158
+ *
159
+ * When `config.loop` is set the job is routed through the loop driver (`runLoop`
160
+ * from loop.ts) instead of a single spawn — same driver as `agents run --loop` and
161
+ * workflow `loop:` blocks (issue #400). The optional `deps` parameter provides
162
+ * injectable seams (runIteration, sleep, writeCheckpoint) used by tests; production
163
+ * callers omit it and get the defaults.
164
+ */
165
+ export async function executeJob(config, deps) {
157
166
  maybeRotate();
158
167
  const timer = createTimer('agent.run', {
159
168
  agent: config.agent,
@@ -164,14 +173,11 @@ export async function executeJob(config) {
164
173
  schedule: config.schedule,
165
174
  });
166
175
  const resolvedPrompt = resolveJobPrompt(config);
167
- const cmd = buildJobCommand(config, resolvedPrompt);
168
176
  const useSandbox = config.sandbox !== false;
169
177
  const overlayHome = useSandbox ? prepareJobHome(config) : undefined;
170
178
  const runId = generateRunId();
171
179
  const runDir = getRunDir(config.name, runId);
172
180
  fs.mkdirSync(runDir, { recursive: true });
173
- const stdoutPath = path.join(runDir, 'stdout.log');
174
- const stdoutFd = fs.openSync(stdoutPath, 'w', 0o600);
175
181
  let spawnEnv = useSandbox ? buildSpawnEnv(overlayHome) : { ...process.env };
176
182
  if (config.timezone) {
177
183
  spawnEnv.TZ = config.timezone;
@@ -192,10 +198,46 @@ export async function executeJob(config) {
192
198
  };
193
199
  writeRunMeta(meta);
194
200
  const timeoutMs = parseTimeout(config.timeout) || 10 * 60 * 1000;
201
+ // Loop path: delegate to runLoop (same driver as `agents run --loop` / workflow loop:).
202
+ if (config.loop) {
203
+ const execOptions = {
204
+ agent: effectiveAgent,
205
+ version: config.version,
206
+ prompt: resolvedPrompt,
207
+ mode: normalizeMode(config.mode),
208
+ effort: config.effort,
209
+ env: spawnEnv,
210
+ json: true,
211
+ headless: true,
212
+ ...(config.config?.model ? { model: config.config.model } : {}),
213
+ ...(config.allow?.dirs ? {
214
+ addDirs: config.allow.dirs
215
+ .filter((d) => !d.startsWith('-'))
216
+ .map((d) => d.replace(/^~/, os.homedir())),
217
+ } : {}),
218
+ };
219
+ const { runLoop } = await import('./loop.js');
220
+ const loopResult = await runLoop(execOptions, config.loop, {
221
+ runId,
222
+ runDir,
223
+ agent: effectiveAgent,
224
+ version: config.version,
225
+ }, deps);
226
+ meta.status = loopResult.stoppedBy === 'error' ? 'failed' : 'completed';
227
+ meta.completedAt = new Date().toISOString();
228
+ meta.exitCode = loopResult.stoppedBy === 'error' ? 1 : 0;
229
+ writeRunMeta(meta);
230
+ timer.end({ status: meta.status, exitCode: meta.exitCode ?? undefined, runId });
231
+ return { meta, reportPath: null };
232
+ }
233
+ // Single-shot path (no loop): build the command, open a log file, and spawn once.
234
+ const cmd = buildJobCommand(config, resolvedPrompt);
235
+ const stdoutPath = path.join(runDir, 'stdout.log');
236
+ const stdoutFd = fs.openSync(stdoutPath, 'w', 0o600);
195
237
  return new Promise((resolve) => {
196
238
  const child = spawn(cmd[0], cmd.slice(1), {
197
239
  stdio: ['ignore', stdoutFd, stdoutFd],
198
- detached: true,
240
+ ...backgroundSpawnOptions({ fdStdio: true }),
199
241
  env: spawnEnv,
200
242
  });
201
243
  // Mark startup time (time from function call to process spawn)
@@ -290,7 +332,7 @@ export async function executeJobDetached(config) {
290
332
  };
291
333
  const child = spawn(cmd[0], cmd.slice(1), {
292
334
  stdio: ['ignore', stdoutFd, stdoutFd],
293
- detached: true,
335
+ ...backgroundSpawnOptions({ fdStdio: true }),
294
336
  env: spawnEnv,
295
337
  });
296
338
  child.unref();
@@ -17,12 +17,17 @@ export class JobScheduler {
17
17
  loadAll() {
18
18
  const configs = listJobs();
19
19
  for (const config of configs) {
20
- if (config.enabled) {
20
+ // Trigger-only jobs (no cron schedule) fire via the webhook receiver,
21
+ // not the cron loop — skip them here.
22
+ if (config.enabled && config.schedule) {
21
23
  this.schedule(config);
22
24
  }
23
25
  }
24
26
  }
25
27
  schedule(config) {
28
+ // A schedule-less (trigger-only) job has nothing to hand to croner.
29
+ if (!config.schedule)
30
+ return;
26
31
  this.unschedule(config.name);
27
32
  // catch: true — a throw from one job's callback should not kill the
28
33
  // whole cron loop. Each invocation of onTrigger is already wrapped in
@@ -48,13 +48,20 @@ export interface VarMeta {
48
48
  * - `always`: asks every time. Never auto-held — only an explicit `agents
49
49
  * secrets unlock` ever holds it; every other read pops Touch ID. Opt a
50
50
  * high-value bundle into this when you want to confirm every single read.
51
+ * - `never`: stored WITHOUT the biometry access control — reads are fully
52
+ * silent (no Touch ID, no broker). The least-safe tier: any code running as
53
+ * the user reads it with no user-presence check. Reserved for low-sensitivity,
54
+ * automation-only credentials. Writing a `never` item needs the signed helper's
55
+ * `set-no-acl` path (see keychain-helper.swift); an older pinned helper rejects
56
+ * it loudly rather than silently downgrading to `always`.
51
57
  *
52
58
  * The default is configurable via `secrets.policy` in agents.yaml. Stored on disk
53
59
  * under the legacy `tier` key (`session` == `daily`, `biometry` == explicit
54
- * `always`, absent == inherit the default) so bundles stay readable across mixed
55
- * CLI versions on synced machines. The user-facing vocabulary is `policy`/`always`/`daily`.
60
+ * `always`, `none` == `never`, absent == inherit the default) so bundles stay
61
+ * readable across mixed CLI versions on synced machines. The user-facing
62
+ * vocabulary is `policy`/`always`/`daily`/`never`.
56
63
  */
57
- export type SecretsPolicy = 'always' | 'daily';
64
+ export type SecretsPolicy = 'always' | 'daily' | 'never';
58
65
  /** A named set of environment variable definitions backed by various secret providers. */
59
66
  export interface SecretsBundle {
60
67
  name: string;
@@ -131,7 +138,54 @@ export interface ResolveBundleOptions {
131
138
  * needs live values. Also honored via AGENTS_SECRETS_NO_AGENT=1.
132
139
  */
133
140
  noAgent?: boolean;
141
+ /**
142
+ * Inject only this subset of keys from the bundle. Keys not in this list are
143
+ * silently excluded from the returned env map. An error is thrown if any
144
+ * requested key is absent from the bundle (fail-loud, never silent skip).
145
+ * When absent or empty, all keys are injected (original behaviour).
146
+ */
147
+ keys?: string[];
148
+ /**
149
+ * When true, skip the pre-run expiry check and inject keys even if their
150
+ * `expires` date is in the past. By default any expired key (or a key whose
151
+ * bundle-level expiry has passed) aborts the run before Touch ID is popped.
152
+ */
153
+ allowExpired?: boolean;
134
154
  }
155
+ /**
156
+ * Apply the --keys subset + expiry gate to an already-resolved snapshot from
157
+ * the secrets-agent fast-path. The agent stores the FULL bundle env, so a
158
+ * naive fast-path return would silently defeat --keys and inject expired
159
+ * values. Mirrors the slow-path pre-checks in `resolveBundleEnv` /
160
+ * `readAndResolveBundleEnv` and returns a new env whose keys match the subset.
161
+ *
162
+ * Exported for tests; production callers reach it via the fast-path branch in
163
+ * `readAndResolveBundleEnv`.
164
+ */
165
+ export declare function filterAgentHitBySubsetAndExpiry(hit: {
166
+ bundle: SecretsBundle;
167
+ env: Record<string, string>;
168
+ }, opts: ResolveBundleOptions): {
169
+ bundle: SecretsBundle;
170
+ env: Record<string, string>;
171
+ };
172
+ /**
173
+ * Guard for remote-bundle callers (`bundle@host` / `--host`) — the SSH
174
+ * resolver in `remoteResolveEnv` does not thread --keys or --allow-expired
175
+ * yet. Silently applying them would inject the full remote env or an expired
176
+ * value, defeating the least-privilege intent, so we fail loud.
177
+ *
178
+ * Exported so `agents run --secrets bundle@host` and `agents secrets exec
179
+ * --host` share the exact same error text; the tests exercise this helper
180
+ * directly instead of driving the whole CLI.
181
+ */
182
+ export declare function assertRemoteBundleFlagsUnsupported(bundleName: string, host: string, opts: {
183
+ keys?: string[];
184
+ allowExpired?: boolean;
185
+ }, flagLabels: {
186
+ keysFlag: string;
187
+ allowExpiredFlag: string;
188
+ }): void;
135
189
  export declare function resolveBundleEnv(bundle: SecretsBundle, _opts?: ResolveBundleOptions): Record<string, string>;
136
190
  /**
137
191
  * Read a bundle's metadata AND resolve its env in a single Touch ID prompt.
@@ -193,7 +247,9 @@ export declare function renameBundle(oldName: string, newName: string, opts?: Re
193
247
  * `import` / `remove` / `delete`. Pass the bundle's resolved backend
194
248
  * (`bundle.backend ?? 'keychain'`).
195
249
  */
196
- export declare function bundleItemStore(backend: SecretsBackend | undefined): {
250
+ export declare function bundleItemStore(backend: SecretsBackend | undefined, opts?: {
251
+ noAcl?: boolean;
252
+ }): {
197
253
  set(item: string, value: string): void;
198
254
  delete(item: string): boolean;
199
255
  get(item: string): string;