@phnx-labs/agents-cli 1.20.88 → 1.20.90

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 (102) hide show
  1. package/CHANGELOG.md +503 -0
  2. package/README.md +15 -1
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/commands.js +7 -7
  5. package/dist/commands/exec.js +7 -1
  6. package/dist/commands/factory.js +26 -2
  7. package/dist/commands/funnel.js +16 -1
  8. package/dist/commands/harness.d.ts +27 -0
  9. package/dist/commands/harness.js +120 -13
  10. package/dist/commands/menubar.js +117 -34
  11. package/dist/commands/profiles.d.ts +3 -0
  12. package/dist/commands/profiles.js +1 -1
  13. package/dist/commands/routines.d.ts +19 -0
  14. package/dist/commands/routines.js +51 -7
  15. package/dist/commands/secrets-rotate-passphrase.d.ts +17 -0
  16. package/dist/commands/secrets-rotate-passphrase.js +96 -0
  17. package/dist/commands/secrets.d.ts +10 -1
  18. package/dist/commands/secrets.js +20 -6
  19. package/dist/commands/sessions-browser.d.ts +4 -0
  20. package/dist/commands/sessions-browser.js +51 -9
  21. package/dist/commands/sessions-favorite.d.ts +20 -0
  22. package/dist/commands/sessions-favorite.js +120 -0
  23. package/dist/commands/sessions.d.ts +110 -21
  24. package/dist/commands/sessions.js +395 -74
  25. package/dist/commands/setup-secrets.d.ts +7 -0
  26. package/dist/commands/setup-secrets.js +12 -9
  27. package/dist/commands/versions.js +12 -4
  28. package/dist/commands/view.d.ts +14 -1
  29. package/dist/commands/view.js +103 -128
  30. package/dist/commands/webhook.js +7 -2
  31. package/dist/lib/agents.d.ts +4 -2
  32. package/dist/lib/agents.js +21 -6
  33. package/dist/lib/commands.js +9 -1
  34. package/dist/lib/daemon.d.ts +29 -0
  35. package/dist/lib/daemon.js +58 -4
  36. package/dist/lib/events.d.ts +1 -1
  37. package/dist/lib/factory/snapshot.d.ts +78 -0
  38. package/dist/lib/factory/snapshot.js +209 -0
  39. package/dist/lib/fs-atomic.d.ts +14 -1
  40. package/dist/lib/fs-atomic.js +35 -3
  41. package/dist/lib/funnel.d.ts +1 -0
  42. package/dist/lib/funnel.js +8 -0
  43. package/dist/lib/hosts/dispatch.js +19 -1
  44. package/dist/lib/hq/floor.js +12 -0
  45. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  46. package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
  47. package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +2 -2
  48. package/dist/lib/menubar/install-menubar.d.ts +53 -2
  49. package/dist/lib/menubar/install-menubar.js +183 -28
  50. package/dist/lib/picker.d.ts +27 -2
  51. package/dist/lib/picker.js +71 -7
  52. package/dist/lib/platform/process.d.ts +2 -0
  53. package/dist/lib/platform/process.js +5 -3
  54. package/dist/lib/profiles.d.ts +48 -0
  55. package/dist/lib/profiles.js +67 -0
  56. package/dist/lib/resources.d.ts +8 -0
  57. package/dist/lib/resources.js +34 -1
  58. package/dist/lib/rotate.d.ts +24 -2
  59. package/dist/lib/rotate.js +63 -6
  60. package/dist/lib/routines-placement.d.ts +2 -1
  61. package/dist/lib/routines-placement.js +8 -4
  62. package/dist/lib/routines.d.ts +57 -1
  63. package/dist/lib/routines.js +74 -1
  64. package/dist/lib/runner.d.ts +2 -0
  65. package/dist/lib/runner.js +21 -8
  66. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  67. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  68. package/dist/lib/secrets/bundles.js +9 -34
  69. package/dist/lib/secrets/filestore.d.ts +152 -34
  70. package/dist/lib/secrets/filestore.js +676 -123
  71. package/dist/lib/session/active.d.ts +109 -3
  72. package/dist/lib/session/active.js +269 -13
  73. package/dist/lib/session/db.d.ts +14 -0
  74. package/dist/lib/session/db.js +35 -0
  75. package/dist/lib/session/favorites.d.ts +39 -0
  76. package/dist/lib/session/favorites.js +101 -0
  77. package/dist/lib/session/host-link.d.ts +68 -0
  78. package/dist/lib/session/host-link.js +64 -0
  79. package/dist/lib/session/presence.d.ts +85 -0
  80. package/dist/lib/session/presence.js +150 -0
  81. package/dist/lib/session/remote-active.d.ts +4 -1
  82. package/dist/lib/session/remote-active.js +8 -2
  83. package/dist/lib/session/remote-list.d.ts +10 -0
  84. package/dist/lib/session/remote-list.js +47 -9
  85. package/dist/lib/session/viewing-in.d.ts +31 -0
  86. package/dist/lib/session/viewing-in.js +47 -0
  87. package/dist/lib/state.d.ts +17 -0
  88. package/dist/lib/state.js +30 -2
  89. package/dist/lib/tmux/binary.d.ts +7 -0
  90. package/dist/lib/tmux/binary.js +11 -1
  91. package/dist/lib/triggers/handlers.d.ts +95 -0
  92. package/dist/lib/triggers/handlers.js +384 -0
  93. package/dist/lib/triggers/webhook.d.ts +10 -2
  94. package/dist/lib/triggers/webhook.js +65 -11
  95. package/dist/lib/types.d.ts +4 -3
  96. package/dist/lib/usage-backoff.d.ts +29 -0
  97. package/dist/lib/usage-backoff.js +165 -0
  98. package/dist/lib/usage.d.ts +112 -5
  99. package/dist/lib/usage.js +464 -46
  100. package/dist/lib/watchdog/runner.d.ts +13 -0
  101. package/dist/lib/watchdog/runner.js +16 -1
  102. package/package.json +1 -1
@@ -29,6 +29,18 @@ export interface Profile {
29
29
  description?: string;
30
30
  preset?: string;
31
31
  provider?: string;
32
+ /**
33
+ * Human-facing label for the harness — what `agents view` prints as the
34
+ * agent-type header, the same slot `AGENTS[id].name` fills for a native
35
+ * harness. Defaults to the profile name when unset.
36
+ */
37
+ label?: string;
38
+ /**
39
+ * Name of the harness this one was forked from — either a native agent id
40
+ * (`claude`, `opencode`) or another custom harness. Display-only lineage:
41
+ * the fork is a full copy, so deleting the source never affects it.
42
+ */
43
+ forkedFrom?: string;
32
44
  /**
33
45
  * Optional secondary model retried on the same host when the primary model
34
46
  * env value hits a rate limit. Reuses the `--fallback` cascade in
@@ -45,12 +57,19 @@ export interface Profile {
45
57
  */
46
58
  export interface ProfileSummary {
47
59
  name: string;
60
+ /** Human-facing header label — `label` when set, else the profile name. */
61
+ label: string;
48
62
  agent: AgentId;
49
63
  host: string;
64
+ /** Host version pin, or null when the harness follows the host's default. */
65
+ hostVersion: string | null;
50
66
  provider: string;
51
67
  model: string;
52
68
  auth: string;
53
69
  path: string;
70
+ description: string | null;
71
+ /** Native agent id or custom harness this one was forked from, if recorded. */
72
+ forkedFrom: string | null;
54
73
  }
55
74
  /** Get the directory where profile YAML files are stored. */
56
75
  export declare function getProfilesDir(): string;
@@ -91,6 +110,11 @@ export declare function profileModelEnvKey(profile: Profile): string | null;
91
110
  * - No auth at all: provider only.
92
111
  */
93
112
  export declare function profileAuthLabel(profile: Profile): string;
113
+ /**
114
+ * Header label for the harness — the slot `AGENTS[id].name` fills for a native
115
+ * harness, so `agents view` can print custom and native harnesses the same way.
116
+ */
117
+ export declare function profileLabel(profile: Profile): string;
94
118
  /** Build a stable, machine-readable summary for list and view surfaces. */
95
119
  export declare function profileSummary(profile: Profile): ProfileSummary;
96
120
  /**
@@ -115,6 +139,8 @@ export interface HostModelOptions {
115
139
  /** Env var the host reads its auth token from; pair with `provider` to attach keychain auth. */
116
140
  authEnvVar?: string;
117
141
  description?: string;
142
+ /** Human-facing header label; defaults to the harness name. */
143
+ label?: string;
118
144
  }
119
145
  /**
120
146
  * Build a custom-harness profile from a host CLI + model in one shot, without a
@@ -123,6 +149,28 @@ export interface HostModelOptions {
123
149
  * (hosts that manage their own login — e.g. opencode — need neither).
124
150
  */
125
151
  export declare function profileFromHostModel(name: string, host: AgentId, model: string, opts?: HostModelOptions): Profile;
152
+ /** Overrides applied on top of the source when forking a harness. */
153
+ export interface ForkProfileOptions {
154
+ /** Swap the pinned model. Written onto the source's model env key when it has
155
+ * one, else onto the host's canonical model var. */
156
+ model?: string;
157
+ /** Swap the endpoint. Only applied for hosts with a known base-URL var. */
158
+ baseUrl?: string;
159
+ /** Repoint auth at a different provider's keychain item. */
160
+ provider?: string;
161
+ /** Env var the host reads its token from; pair with `provider`. */
162
+ authEnvVar?: string;
163
+ /** Re-pin (or unpin, with an empty string) the host CLI version. */
164
+ version?: string;
165
+ label?: string;
166
+ description?: string;
167
+ }
168
+ /**
169
+ * Copy an existing harness under a new name, applying overrides. The fork is a
170
+ * full copy — env, auth binding, and fallback model all carry over — so the two
171
+ * diverge from here and deleting the source never affects the fork.
172
+ */
173
+ export declare function forkProfile(source: Profile, name: string, opts?: ForkProfileOptions): Profile;
126
174
  /**
127
175
  * Resolve a profile into the env block that should be injected into the
128
176
  * spawned agent process. Reads the token from keychain at exec time so the
@@ -197,16 +197,27 @@ export function profileAuthLabel(profile) {
197
197
  }
198
198
  return provider;
199
199
  }
200
+ /**
201
+ * Header label for the harness — the slot `AGENTS[id].name` fills for a native
202
+ * harness, so `agents view` can print custom and native harnesses the same way.
203
+ */
204
+ export function profileLabel(profile) {
205
+ return profile.label || profile.name;
206
+ }
200
207
  /** Build a stable, machine-readable summary for list and view surfaces. */
201
208
  export function profileSummary(profile) {
202
209
  return {
203
210
  name: profile.name,
211
+ label: profileLabel(profile),
204
212
  agent: profile.host.agent,
205
213
  host: profileHostLabel(profile),
214
+ hostVersion: profile.host.version ?? null,
206
215
  provider: profileProviderLabel(profile),
207
216
  model: profileModelLabel(profile),
208
217
  auth: profileAuthLabel(profile),
209
218
  path: getProfilePath(profile.name),
219
+ description: profile.description ?? null,
220
+ forkedFrom: profile.forkedFrom ?? null,
210
221
  };
211
222
  }
212
223
  /**
@@ -227,6 +238,7 @@ export function profileFromPreset(profileName, preset, version) {
227
238
  description: preset.description,
228
239
  preset: preset.name,
229
240
  provider: preset.provider,
241
+ forkedFrom: preset.host,
230
242
  };
231
243
  }
232
244
  /**
@@ -285,13 +297,68 @@ export function profileFromHostModel(name, host, model, opts = {}) {
285
297
  env,
286
298
  description: opts.description ?? `Custom harness: ${host} + ${model}`,
287
299
  provider: opts.provider ?? host,
300
+ forkedFrom: host,
288
301
  };
302
+ if (opts.label)
303
+ profile.label = opts.label;
289
304
  if (opts.provider && opts.authEnvVar) {
290
305
  profile.auth = { envVar: opts.authEnvVar, keychainItem: keychainItemName(opts.provider) };
291
306
  profile.authOptional = false;
292
307
  }
293
308
  return profile;
294
309
  }
310
+ /**
311
+ * Copy an existing harness under a new name, applying overrides. The fork is a
312
+ * full copy — env, auth binding, and fallback model all carry over — so the two
313
+ * diverge from here and deleting the source never affects the fork.
314
+ */
315
+ export function forkProfile(source, name, opts = {}) {
316
+ validateProfileName(name);
317
+ const host = source.host.agent;
318
+ const env = { ...source.env };
319
+ if (opts.model) {
320
+ env[profileModelEnvKey(source) ?? modelEnvKeyForHost(host)] = opts.model;
321
+ }
322
+ if (opts.baseUrl) {
323
+ const key = baseUrlEnvKeyForHost(host);
324
+ if (!key) {
325
+ throw new Error(`Host '${host}' has no known base-URL env var; drop --base-url or fork onto a claude/codex host.`);
326
+ }
327
+ env[key] = opts.baseUrl;
328
+ }
329
+ const forked = {
330
+ ...source,
331
+ name,
332
+ host: { agent: host, ...(opts.version ? { version: opts.version } : source.host.version ? { version: source.host.version } : {}) },
333
+ env,
334
+ // The source's description names the source's model, so inheriting it
335
+ // across a model swap would describe the fork wrongly.
336
+ description: opts.description ?? (opts.model ? `Forked from ${source.name}: ${opts.model}` : source.description),
337
+ forkedFrom: source.name,
338
+ };
339
+ // `label` is the header `agents view` prints, so an inherited one would make
340
+ // the fork and its source visually identical — the ambiguity a per-harness
341
+ // block exists to remove. A fork carries a label only when it is given one;
342
+ // otherwise `profileLabel` falls back to the fork's own name.
343
+ if (opts.label)
344
+ forked.label = opts.label;
345
+ else
346
+ delete forked.label;
347
+ // A fork that repoints the model or endpoint is no longer that preset — keep
348
+ // the preset link only while the fork still matches what the preset defines.
349
+ if (opts.model || opts.baseUrl)
350
+ delete forked.preset;
351
+ if (opts.provider) {
352
+ const envVar = opts.authEnvVar ?? source.auth?.envVar ?? authEnvKeyForHost(host);
353
+ if (!envVar) {
354
+ throw new Error(`Host '${host}' has no known auth env var; --provider cannot be attached to this fork.`);
355
+ }
356
+ forked.provider = opts.provider;
357
+ forked.auth = { envVar, keychainItem: keychainItemName(opts.provider) };
358
+ forked.authOptional = source.authOptional ?? false;
359
+ }
360
+ return forked;
361
+ }
295
362
  /**
296
363
  * Resolve a profile into the env block that should be injected into the
297
364
  * spawned agent process. Reads the token from keychain at exec time so the
@@ -17,6 +17,14 @@ export interface ResolvedResource {
17
17
  */
18
18
  source: string;
19
19
  }
20
+ /**
21
+ * True when `rawName` (a filename with its extension already stripped) names a
22
+ * directory doc rather than a resource of `kind`. Exported so every enumerator
23
+ * shares one definition — `listCentralCommands` and `discoverCommands` in
24
+ * `commands.ts` do their own `readdirSync` scans, and without this they would
25
+ * list a `README` that `resolveResource` then refuses to open.
26
+ */
27
+ export declare function isDirectoryDoc(kind: ResourceKind, rawName: string): boolean;
20
28
  /**
21
29
  * Resolve a single resource by kind + name using project > user > system precedence.
22
30
  * For file-based resources the path ends in `.md`, `.yaml`, or `.yml` as appropriate.
@@ -35,6 +35,30 @@ function resourceIsActive(kind, name, source) {
35
35
  const activeKind = profiledKind(kind);
36
36
  return activeKind ? isNameActiveInResourceProfile(activeKind, name, source) : true;
37
37
  }
38
+ /**
39
+ * Documentation filenames that live *beside* resources, describing the directory
40
+ * rather than being a resource in it. A DotAgents repo keeps a `README.md` (for
41
+ * humans) and an `AGENTS.md` (for agents) in each resource dir, with
42
+ * `CLAUDE.md`/`GEMINI.md` symlinked to the latter. Without this filter every one
43
+ * of them materializes as a resource — `commands/README.md` installs a bogus
44
+ * `/README` slash command into every agent home.
45
+ *
46
+ * `rules` is exempt: there `AGENTS.md` IS the resource (the composed ruleset that
47
+ * syncs as each agent's memory file), not documentation about the directory.
48
+ */
49
+ const DOC_BASENAMES = new Set(['readme', 'agents', 'claude', 'gemini']);
50
+ /**
51
+ * True when `rawName` (a filename with its extension already stripped) names a
52
+ * directory doc rather than a resource of `kind`. Exported so every enumerator
53
+ * shares one definition — `listCentralCommands` and `discoverCommands` in
54
+ * `commands.ts` do their own `readdirSync` scans, and without this they would
55
+ * list a `README` that `resolveResource` then refuses to open.
56
+ */
57
+ export function isDirectoryDoc(kind, rawName) {
58
+ if (kind === 'rules')
59
+ return false;
60
+ return DOC_BASENAMES.has(rawName.toLowerCase());
61
+ }
38
62
  /**
39
63
  * Resolve a single resource by kind + name using project > user > system precedence.
40
64
  * For file-based resources the path ends in `.md`, `.yaml`, or `.yml` as appropriate.
@@ -62,7 +86,10 @@ export function resolveResource(kind, name, cwd) {
62
86
  }
63
87
  continue;
64
88
  }
65
- // Try with common file extensions
89
+ // Try with common file extensions. A directory doc (README/AGENTS/CLAUDE/
90
+ // GEMINI) describes the directory and is never itself a resource.
91
+ if (isDirectoryDoc(kind, name))
92
+ continue;
66
93
  for (const ext of ['.md', '.yaml', '.yml']) {
67
94
  const withExt = exactPath + ext;
68
95
  if (fs.existsSync(withExt)) {
@@ -105,6 +132,12 @@ export function listResources(kind, cwd) {
105
132
  if (entry.name.startsWith('.'))
106
133
  continue;
107
134
  const rawName = entry.name.replace(/\.(md|yaml|yml)$/, '');
135
+ // Not isFile(): a Dirent for a symlink reports isFile() === false, and
136
+ // CLAUDE.md/GEMINI.md are symlinks to AGENTS.md by convention. Anything
137
+ // that is not a directory is a candidate doc; a resource directory that
138
+ // happens to be named `agents/` is still a real resource.
139
+ if (!entry.isDirectory() && isDirectoryDoc(kind, rawName))
140
+ continue;
108
141
  if (seen.has(rawName))
109
142
  continue;
110
143
  if (!resourceIsActive(kind, rawName, source))
@@ -35,6 +35,12 @@ export interface RotateResult {
35
35
  healthy: RotateCandidate[];
36
36
  /** Candidates excluded (not signed in, or out of credits). */
37
37
  excluded: RotateCandidate[];
38
+ /**
39
+ * True when NO candidate on this machine had usage data fresh enough to decide
40
+ * on, so the pick was made from unverified snapshots. Callers surface it —
41
+ * routing blind is a fact the operator needs, not an internal detail.
42
+ */
43
+ usageUnverified?: boolean;
38
44
  }
39
45
  export declare const RUN_STRATEGIES: RunStrategy[];
40
46
  /**
@@ -60,6 +66,22 @@ export declare function getProjectRunStrategy(agent: AgentId, startPath: string)
60
66
  export declare function getConfiguredRunStrategy(agent: AgentId, startPath?: string): RunStrategy;
61
67
  /** Persist the global run strategy used by bare `agents run <agent>`. */
62
68
  export declare function setGlobalRunStrategy(agent: AgentId, strategy: RunStrategy): void;
69
+ /**
70
+ * How old a usage snapshot may be and still settle a routing DECISION.
71
+ *
72
+ * Deliberately far tighter than the 24h stale-while-revalidate window the
73
+ * display paths use (`USAGE_CACHE_SWR_MS`): `agents view` rendering a slightly
74
+ * old bar costs nothing, but the router choosing an account from one costs the
75
+ * whole run. Measured case — `yosemite-s1` held snapshots 26h to 2.7 days old
76
+ * with a failing refresh, so balanced read `muqsit@getrush.ai` as 48% used and
77
+ * launched into it while the account was actually at its weekly cap.
78
+ */
79
+ export declare const USAGE_DECISION_MAX_AGE_MS: number;
80
+ /**
81
+ * Whether this candidate's usage number is recent enough to route on. A missing
82
+ * snapshot is unverified by definition — there is no number to trust.
83
+ */
84
+ export declare function isUsageVerified(candidate: RotateCandidate, nowMs?: number): boolean;
63
85
  /**
64
86
  * Whether a specific account can serve a run right now, and — when it can't —
65
87
  * why. `signed_out` covers a missing usable credential; `rate_limited` and
@@ -118,13 +140,13 @@ export declare function checkRunAccountReadiness(agent: AgentId, version: string
118
140
  * Returns null if no candidate is eligible — callers fall back to the pinned
119
141
  * version so behavior stays predictable.
120
142
  */
121
- export declare function pickBalancedCandidate(candidates: RotateCandidate[]): RotateResult | null;
143
+ export declare function pickBalancedCandidate(candidates: RotateCandidate[], nowMs?: number): RotateResult | null;
122
144
  /**
123
145
  * Pick an available candidate. Prefers the configured pinned version when that
124
146
  * version has usage available; otherwise routes to the candidate with the most
125
147
  * usage headroom.
126
148
  */
127
- export declare function pickAvailableCandidate(candidates: RotateCandidate[], preferredVersion?: string | null): RotateResult | null;
149
+ export declare function pickAvailableCandidate(candidates: RotateCandidate[], preferredVersion?: string | null, nowMs?: number): RotateResult | null;
128
150
  export declare function collectRunCandidates(agent: AgentId): Promise<RotateCandidate[]>;
129
151
  /**
130
152
  * Resolve an account identity to the installed version slot that holds it, over
@@ -70,6 +70,27 @@ function isRotationEligible(candidate) {
70
70
  function isAvailableEligible(candidate) {
71
71
  return isRotationEligible(candidate);
72
72
  }
73
+ /**
74
+ * How old a usage snapshot may be and still settle a routing DECISION.
75
+ *
76
+ * Deliberately far tighter than the 24h stale-while-revalidate window the
77
+ * display paths use (`USAGE_CACHE_SWR_MS`): `agents view` rendering a slightly
78
+ * old bar costs nothing, but the router choosing an account from one costs the
79
+ * whole run. Measured case — `yosemite-s1` held snapshots 26h to 2.7 days old
80
+ * with a failing refresh, so balanced read `muqsit@getrush.ai` as 48% used and
81
+ * launched into it while the account was actually at its weekly cap.
82
+ */
83
+ export const USAGE_DECISION_MAX_AGE_MS = 5 * 60 * 1000;
84
+ /**
85
+ * Whether this candidate's usage number is recent enough to route on. A missing
86
+ * snapshot is unverified by definition — there is no number to trust.
87
+ */
88
+ export function isUsageVerified(candidate, nowMs = Date.now()) {
89
+ const capturedAt = candidate.usageSnapshot?.capturedAt;
90
+ if (!capturedAt)
91
+ return false;
92
+ return nowMs - capturedAt.getTime() <= USAGE_DECISION_MAX_AGE_MS;
93
+ }
73
94
  function hasUsageAvailable(candidate) {
74
95
  const snapshot = candidate.usageSnapshot;
75
96
  if (snapshot && snapshot.windows.length > 0) {
@@ -199,7 +220,7 @@ function dedupeAndSortCandidates(candidates) {
199
220
  * Returns null if no candidate is eligible — callers fall back to the pinned
200
221
  * version so behavior stays predictable.
201
222
  */
202
- export function pickBalancedCandidate(candidates) {
223
+ export function pickBalancedCandidate(candidates, nowMs = Date.now()) {
203
224
  const healthy = [];
204
225
  const excluded = [];
205
226
  for (const c of candidates) {
@@ -217,8 +238,31 @@ export function pickBalancedCandidate(candidates) {
217
238
  if (!deduped.has(c))
218
239
  excluded.push(c);
219
240
  }
220
- const picked = weightedRandomByCapacity(sorted);
221
- return { picked, healthy: sorted, excluded };
241
+ const { picked, usageUnverified } = preferVerified(sorted, nowMs, weightedRandomByCapacity);
242
+ return { picked, healthy: sorted, excluded, usageUnverified };
243
+ }
244
+ /**
245
+ * Choose from the VERIFIED candidates when any exist, else from the whole pool.
246
+ *
247
+ * An eligible account whose usage we could not confirm is a guess, not a green
248
+ * light: the snapshot reads "48% used" with equal confidence whether it was
249
+ * captured a minute or three days ago, and a box whose refresh is failing stays
250
+ * wrong indefinitely. Confirmed headroom therefore beats apparent headroom, even
251
+ * when the unconfirmed number looks better.
252
+ *
253
+ * `healthy` deliberately keeps every eligible candidate rather than just the
254
+ * verified ones. Declining to *pick* an account on stale data and declining to
255
+ * *fail over to* it after the primary has already hit a 429 are different risks:
256
+ * by then the alternative is not launching at all, so the failover chain
257
+ * (rotationFailoverChain, which reads `healthy`) keeps its full safety net —
258
+ * exactly on the machines this guard is protecting.
259
+ */
260
+ function preferVerified(pool, nowMs, choose) {
261
+ const verified = pool.filter((c) => isUsageVerified(c, nowMs));
262
+ return {
263
+ picked: choose(verified.length > 0 ? verified : pool),
264
+ usageUnverified: verified.length === 0,
265
+ };
222
266
  }
223
267
  /**
224
268
  * Pick one candidate from `sorted` using weights proportional to remaining
@@ -250,7 +294,7 @@ function weightedRandomByCapacity(sorted) {
250
294
  * version has usage available; otherwise routes to the candidate with the most
251
295
  * usage headroom.
252
296
  */
253
- export function pickAvailableCandidate(candidates, preferredVersion) {
297
+ export function pickAvailableCandidate(candidates, preferredVersion, nowMs = Date.now()) {
254
298
  const healthy = [];
255
299
  const excluded = [];
256
300
  for (const c of candidates) {
@@ -268,10 +312,17 @@ export function pickAvailableCandidate(candidates, preferredVersion) {
268
312
  if (!deduped.has(c))
269
313
  excluded.push(c);
270
314
  }
315
+ // `available` sorts by apparent headroom and takes the front of the list, so an
316
+ // unconfirmed "48% used" outranks an accurate "90% used" — the same inversion
317
+ // that put a launch on an exhausted account under `balanced`. It routes on the
318
+ // same cache, so it gets the same rule: confirmed headroom first.
319
+ const { picked: bestVerified, usageUnverified } = preferVerified(sorted, nowMs, (from) => from[0]);
320
+ // An explicit version preference is an instruction, not a ranking signal, so it
321
+ // still wins — but only while that version is actually eligible.
271
322
  const preferred = preferredVersion
272
323
  ? sorted.find((candidate) => candidate.version === preferredVersion)
273
324
  : undefined;
274
- return { picked: preferred ?? sorted[0], healthy: sorted, excluded };
325
+ return { picked: preferred ?? bestVerified, healthy: sorted, excluded, usageUnverified };
275
326
  }
276
327
  export async function collectRunCandidates(agent) {
277
328
  const versions = listInstalledVersions(agent);
@@ -299,12 +350,18 @@ export async function collectRunCandidates(agent) {
299
350
  lastActive: info.lastActive,
300
351
  };
301
352
  }));
353
+ // These candidates feed a routing decision, so cap how stale their usage may
354
+ // be (see USAGE_DECISION_MAX_AGE_MS). Past that the fetch blocks on a live
355
+ // read instead of serving the cache — one bounded, parallel round trip per
356
+ // account, and none at all inside the 2-minute fresh window that back-to-back
357
+ // launches hit. A failed read still falls back to the cache; the pick then
358
+ // routes around it via isUsageVerified rather than trusting the old number.
302
359
  const { usageByKey } = await getUsageInfoByIdentity(rows.map(({ home, info, version }) => ({
303
360
  agentId: agent,
304
361
  home,
305
362
  cliVersion: version,
306
363
  info,
307
- })));
364
+ })), { maxAgeMs: USAGE_DECISION_MAX_AGE_MS });
308
365
  return rows.map(({ home: _home, info, ...candidate }) => {
309
366
  const usageKey = getUsageLookupKey(info);
310
367
  const usage = usageKey ? usageByKey.get(usageKey) : undefined;
@@ -10,6 +10,7 @@
10
10
  * pin for fleet/host/cloud strategies (applied at add/sync time).
11
11
  */
12
12
  import type { JobConfig } from './routines.js';
13
+ import { type DevicePlatform } from './devices/registry.js';
13
14
  export type PlacementTarget = {
14
15
  mode: 'local';
15
16
  } | {
@@ -30,7 +31,7 @@ export type PlacementTarget = {
30
31
  * the double-fire pin (`devices: [self]`) would collapse fleet placement to
31
32
  * always-local. Control / offline / no-address devices are never chosen.
32
33
  */
33
- export declare function pickFleetDevice(_config?: Pick<JobConfig, 'devices'>): string | null;
34
+ export declare function pickFleetDevice(_config?: Pick<JobConfig, 'devices'>, platform?: DevicePlatform): string | null;
34
35
  /**
35
36
  * Resolve where a fired job's body should execute.
36
37
  * Throws a human-readable Error when placement cannot be satisfied.
@@ -25,7 +25,7 @@ import { planFleetTargets } from './devices/fleet.js';
25
25
  * the double-fire pin (`devices: [self]`) would collapse fleet placement to
26
26
  * always-local. Control / offline / no-address devices are never chosen.
27
27
  */
28
- export function pickFleetDevice(_config) {
28
+ export function pickFleetDevice(_config, platform) {
29
29
  let reg;
30
30
  try {
31
31
  reg = loadDevicesSync();
@@ -34,11 +34,15 @@ export function pickFleetDevice(_config) {
34
34
  return null;
35
35
  }
36
36
  const planned = planFleetTargets(reg);
37
- const candidates = planned.filter((t) => !t.skip).map((t) => t.device.name);
37
+ const candidates = planned
38
+ .filter((t) => !t.skip && (!platform || t.device.platform === platform))
39
+ .map((t) => t.device.name);
38
40
  if (candidates.length === 0) {
39
41
  // No registry / nothing online: fall back to self so a single-box fleet
40
- // without a registry entry still runs locally.
41
- return machineId();
42
+ // without a registry entry still runs locally. Only when no platform filter
43
+ // was requested — an unmet filter must fail loud so e.g. `fleet/linux` never
44
+ // silently lands on a macOS box.
45
+ return platform ? null : machineId();
42
46
  }
43
47
  const self = machineId();
44
48
  const selfMatch = candidates.find((n) => normalizeHost(n) === self);
@@ -78,6 +78,10 @@ export interface LinearJobTrigger {
78
78
  teamKey?: string;
79
79
  /** Required issue label name. */
80
80
  label?: string;
81
+ /** Current Linear state name that must match (e.g. `Plan`). */
82
+ stateTo?: string;
83
+ /** Previous Linear state name that must match (e.g. `Triage`). */
84
+ stateFrom?: string;
81
85
  }
82
86
  export type JobTrigger = GithubJobTrigger | LinearJobTrigger;
83
87
  /**
@@ -118,6 +122,12 @@ export interface JobConfig {
118
122
  * overdue; everywhere else it is inert and `run` refuses with a pointer.
119
123
  */
120
124
  devices?: string[];
125
+ /**
126
+ * Environment variables injected into the spawned run, on top of the sandbox
127
+ * overlay's own. Merged by `buildSpawnEnv`, so it applies to both the
128
+ * foreground and detached execution paths.
129
+ */
130
+ env?: Record<string, string>;
121
131
  /**
122
132
  * Execution placement — run the job body on this machine over SSH (a
123
133
  * registered host, device, capability tag, or user@host) instead of locally.
@@ -360,8 +370,54 @@ export declare function oneShotScheduleFireDate(schedule: string | undefined | n
360
370
  export declare function isPastOneShotRoutine(config: Pick<JobConfig, 'schedule' | 'runOnce' | 'timezone'>, now?: Date): boolean;
361
371
  export declare function hasCompletedOneShotRun(config: Pick<JobConfig, 'name' | 'schedule' | 'runOnce' | 'timezone'>, now?: Date): boolean;
362
372
  export declare function shouldPurgeCompletedOneShotRoutine(config: Pick<JobConfig, 'name' | 'schedule' | 'runOnce' | 'timezone'>, now?: Date): boolean;
373
+ /**
374
+ * Context passed to `resolveJobPrompt` when a job is fired by a webhook. Lets
375
+ * prompts use `{{issue.identifier}}`, `{{updatedFrom.state.name}}`, etc.
376
+ */
377
+ export interface WebhookContext {
378
+ source: string;
379
+ event: string;
380
+ action?: string;
381
+ issue?: unknown;
382
+ updatedFrom?: unknown;
383
+ pull_request?: unknown;
384
+ repository?: unknown;
385
+ }
386
+ /**
387
+ * Substitute `{{dotted.path}}` placeholders in a string using a webhook context.
388
+ * Missing values are replaced with an empty string.
389
+ */
390
+ export declare function substituteWebhookPrompt(prompt: string, context: WebhookContext): string;
391
+ /**
392
+ * Substitute `{{dotted.path}}` placeholders in a string destined for a SHELL,
393
+ * quoting every substituted value so payload content cannot break out of it.
394
+ *
395
+ * `run.command` is executed through a shell, and its context is built from an
396
+ * external webhook payload — `issue.title`, `issue.description`, and the GitHub
397
+ * `pull_request` fields are free text any outside contributor can set. Pasting
398
+ * those in raw (as {@link substituteWebhookPrompt} does, correctly, for prompts)
399
+ * turns an operator's `echo {{issue.title}}` into a command-injection sink.
400
+ *
401
+ * The template itself is operator-authored and stays unquoted, so pipes,
402
+ * redirects, and `&&` in the configured command keep working. Only the
403
+ * interpolated values are quoted.
404
+ *
405
+ * POSIX `sh` quoting: wrap in single quotes and close/escape/reopen for any
406
+ * embedded single quote. `exec` uses `cmd.exe` on Windows, which does not
407
+ * honour these rules — see `assertShellSubstitutionSupported`.
408
+ */
409
+ export declare function substituteWebhookCommand(command: string, context: WebhookContext): string;
410
+ /**
411
+ * Refuse a `run.command` carrying placeholders on a platform whose shell we
412
+ * cannot safely quote for. `child_process.exec` runs through `cmd.exe` on
413
+ * Windows, where POSIX single-quoting is not a quoting mechanism at all, so
414
+ * {@link substituteWebhookCommand} would not contain a hostile value.
415
+ *
416
+ * Fail loud rather than execute something we cannot prove is safe.
417
+ */
418
+ export declare function assertShellSubstitutionSupported(command: string, platform?: NodeJS.Platform): void;
363
419
  /** Expand built-in and user-defined template variables in a job's prompt string. */
364
- export declare function resolveJobPrompt(config: JobConfig): string;
420
+ export declare function resolveJobPrompt(config: JobConfig, context?: WebhookContext): string;
365
421
  /** Parse a human-readable timeout string (e.g. "10m", "2h", "1h30m", "3d", "1w") into milliseconds.
366
422
  * Accepts combinations of w (weeks), d (days), h (hours), m (minutes).
367
423
  * Returns null if the string is empty, matches nothing, totals zero, or exceeds 1 week.
@@ -569,6 +569,12 @@ export function validateTrigger(trigger) {
569
569
  if (linear.label !== undefined && typeof linear.label !== 'string') {
570
570
  errors.push('trigger.label must be a string');
571
571
  }
572
+ if (linear.stateTo !== undefined && typeof linear.stateTo !== 'string') {
573
+ errors.push('trigger.stateTo must be a string');
574
+ }
575
+ if (linear.stateFrom !== undefined && typeof linear.stateFrom !== 'string') {
576
+ errors.push('trigger.stateFrom must be a string');
577
+ }
572
578
  return errors;
573
579
  }
574
580
  function isParseableDate(value) {
@@ -702,8 +708,71 @@ export function hasCompletedOneShotRun(config, now = new Date()) {
702
708
  export function shouldPurgeCompletedOneShotRoutine(config, now = new Date()) {
703
709
  return hasCompletedOneShotRun(config, now);
704
710
  }
711
+ function getPath(obj, path) {
712
+ const parts = path.split('.');
713
+ let current = obj;
714
+ for (const part of parts) {
715
+ if (current === null || current === undefined)
716
+ return undefined;
717
+ current = current[part];
718
+ }
719
+ return current;
720
+ }
721
+ /**
722
+ * Substitute `{{dotted.path}}` placeholders in a string using a webhook context.
723
+ * Missing values are replaced with an empty string.
724
+ */
725
+ export function substituteWebhookPrompt(prompt, context) {
726
+ return prompt.replace(/\{\{([^{}]+)\}\}/g, (_, rawPath) => {
727
+ const value = getPath(context, rawPath.trim());
728
+ if (value === undefined || value === null)
729
+ return '';
730
+ return String(value);
731
+ });
732
+ }
733
+ /**
734
+ * Substitute `{{dotted.path}}` placeholders in a string destined for a SHELL,
735
+ * quoting every substituted value so payload content cannot break out of it.
736
+ *
737
+ * `run.command` is executed through a shell, and its context is built from an
738
+ * external webhook payload — `issue.title`, `issue.description`, and the GitHub
739
+ * `pull_request` fields are free text any outside contributor can set. Pasting
740
+ * those in raw (as {@link substituteWebhookPrompt} does, correctly, for prompts)
741
+ * turns an operator's `echo {{issue.title}}` into a command-injection sink.
742
+ *
743
+ * The template itself is operator-authored and stays unquoted, so pipes,
744
+ * redirects, and `&&` in the configured command keep working. Only the
745
+ * interpolated values are quoted.
746
+ *
747
+ * POSIX `sh` quoting: wrap in single quotes and close/escape/reopen for any
748
+ * embedded single quote. `exec` uses `cmd.exe` on Windows, which does not
749
+ * honour these rules — see `assertShellSubstitutionSupported`.
750
+ */
751
+ export function substituteWebhookCommand(command, context) {
752
+ return command.replace(/\{\{([^{}]+)\}\}/g, (_, rawPath) => {
753
+ const value = getPath(context, rawPath.trim());
754
+ if (value === undefined || value === null)
755
+ return "''";
756
+ return `'${String(value).replace(/'/g, `'\\''`)}'`;
757
+ });
758
+ }
759
+ /**
760
+ * Refuse a `run.command` carrying placeholders on a platform whose shell we
761
+ * cannot safely quote for. `child_process.exec` runs through `cmd.exe` on
762
+ * Windows, where POSIX single-quoting is not a quoting mechanism at all, so
763
+ * {@link substituteWebhookCommand} would not contain a hostile value.
764
+ *
765
+ * Fail loud rather than execute something we cannot prove is safe.
766
+ */
767
+ export function assertShellSubstitutionSupported(command, platform = process.platform) {
768
+ if (platform === 'win32' && /\{\{[^{}]+\}\}/.test(command)) {
769
+ throw new Error('run.command with {{…}} placeholders is not supported on Windows: the values come from an ' +
770
+ 'untrusted webhook payload and cmd.exe cannot be quoted safely. Use run.prompt, or a ' +
771
+ 'command with no placeholders.');
772
+ }
773
+ }
705
774
  /** Expand built-in and user-defined template variables in a job's prompt string. */
706
- export function resolveJobPrompt(config) {
775
+ export function resolveJobPrompt(config, context) {
707
776
  const now = new Date();
708
777
  const tz = config.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone;
709
778
  const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
@@ -725,6 +794,10 @@ export function resolveJobPrompt(config) {
725
794
  prompt = prompt.replace(new RegExp(`\\{${key}\\}`, 'g'), value);
726
795
  }
727
796
  }
797
+ // Webhook-driven variables ({{issue.identifier}}, {{updatedFrom.state.name}}, ...)
798
+ if (context) {
799
+ prompt = substituteWebhookPrompt(prompt, context);
800
+ }
728
801
  // Last report (special handling). Only a COMPLETED run's report is injected —
729
802
  // a failed run's report.md is the agent's error text (e.g. a login prompt on
730
803
  // an auth failure), and feeding that into the next prompt poisons every
@@ -26,6 +26,8 @@ export interface RunResult {
26
26
  /** Agents the daemon can actually run, derived from the command table above
27
27
  * so the `--agent` help and any validation can never drift from it. */
28
28
  export declare const ROUTINE_AGENT_IDS: readonly string[];
29
+ /** Stable working directory for routine children, independent of the daemon's launch cwd. */
30
+ export declare function routineSpawnCwd(config: Pick<JobConfig, 'repo'>, configuredRoot?: string | undefined): string;
29
31
  /** Build the full CLI argv for executing a job, applying mode, model, and permission flags. */
30
32
  export declare function buildJobCommand(config: JobConfig, resolvedPrompt: string): string[];
31
33
  export declare function archiveRoutineTranscripts(meta: Pick<RunMeta, 'jobName' | 'runId' | 'agent'>, runDir: string, overlayHome?: string): void;