@phnx-labs/agents-cli 1.20.89 → 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 (58) hide show
  1. package/CHANGELOG.md +240 -0
  2. package/README.md +6 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/exec.js +7 -1
  5. package/dist/commands/harness.d.ts +27 -0
  6. package/dist/commands/harness.js +120 -13
  7. package/dist/commands/profiles.d.ts +3 -0
  8. package/dist/commands/profiles.js +1 -1
  9. package/dist/commands/routines.d.ts +19 -0
  10. package/dist/commands/routines.js +28 -6
  11. package/dist/commands/secrets.d.ts +10 -1
  12. package/dist/commands/secrets.js +18 -6
  13. package/dist/commands/sessions-browser.d.ts +4 -0
  14. package/dist/commands/sessions-browser.js +51 -9
  15. package/dist/commands/sessions-favorite.d.ts +20 -0
  16. package/dist/commands/sessions-favorite.js +120 -0
  17. package/dist/commands/sessions.d.ts +103 -20
  18. package/dist/commands/sessions.js +356 -62
  19. package/dist/commands/setup-secrets.d.ts +7 -0
  20. package/dist/commands/setup-secrets.js +12 -9
  21. package/dist/commands/versions.js +12 -4
  22. package/dist/commands/view.d.ts +14 -1
  23. package/dist/commands/view.js +103 -128
  24. package/dist/lib/agents.d.ts +4 -2
  25. package/dist/lib/agents.js +21 -6
  26. package/dist/lib/hosts/dispatch.js +19 -1
  27. package/dist/lib/hq/floor.js +12 -0
  28. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  29. package/dist/lib/picker.d.ts +27 -2
  30. package/dist/lib/picker.js +71 -7
  31. package/dist/lib/profiles.d.ts +48 -0
  32. package/dist/lib/profiles.js +67 -0
  33. package/dist/lib/rotate.d.ts +24 -2
  34. package/dist/lib/rotate.js +63 -6
  35. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  36. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  37. package/dist/lib/session/active.d.ts +109 -3
  38. package/dist/lib/session/active.js +269 -13
  39. package/dist/lib/session/db.d.ts +14 -0
  40. package/dist/lib/session/db.js +35 -0
  41. package/dist/lib/session/favorites.d.ts +39 -0
  42. package/dist/lib/session/favorites.js +101 -0
  43. package/dist/lib/session/host-link.d.ts +68 -0
  44. package/dist/lib/session/host-link.js +64 -0
  45. package/dist/lib/session/presence.d.ts +85 -0
  46. package/dist/lib/session/presence.js +150 -0
  47. package/dist/lib/session/remote-list.d.ts +10 -0
  48. package/dist/lib/session/remote-list.js +47 -9
  49. package/dist/lib/tmux/binary.d.ts +7 -0
  50. package/dist/lib/tmux/binary.js +11 -1
  51. package/dist/lib/types.d.ts +4 -3
  52. package/dist/lib/usage-backoff.d.ts +29 -0
  53. package/dist/lib/usage-backoff.js +165 -0
  54. package/dist/lib/usage.d.ts +112 -5
  55. package/dist/lib/usage.js +464 -46
  56. package/dist/lib/watchdog/runner.d.ts +13 -0
  57. package/dist/lib/watchdog/runner.js +16 -1
  58. package/package.json +1 -1
@@ -93,7 +93,25 @@ export function remoteCdPrefix(remoteCwd, opts = {}) {
93
93
  * collision, mirroring `buildExecEnv`'s `...options.env` precedence (exec.ts).
94
94
  */
95
95
  export function withActorEnv(env) {
96
- return { ...actorEnv(resolveActor()), ...(env ?? {}) };
96
+ return { ...actorEnv(resolveActor()), ...terminalIdEnv(), ...(env ?? {}) };
97
+ }
98
+ /**
99
+ * Forward the launching editor tab's `AGENT_TERMINAL_ID` across the SSH hop.
100
+ *
101
+ * Factory stamps it on every terminal it spawns, and the remote `agents run`
102
+ * records it in its pid registry (`writePidSessionEntry`) — which is what lets a
103
+ * tab ask the device "which session is MY terminal running?" instead of guessing
104
+ * from local state. Without the forward the remote registry has no terminal id,
105
+ * that question is unanswerable, and the tab is stuck with its spawn-time id even
106
+ * after the agent has moved to a different session (a `/clear`, or an exit and
107
+ * rerun in the same tab).
108
+ *
109
+ * Same shape as the actor provenance above: absent when the launch did not come
110
+ * from a tracked terminal, never fabricated.
111
+ */
112
+ function terminalIdEnv() {
113
+ const terminalId = process.env.AGENT_TERMINAL_ID?.trim();
114
+ return terminalId ? { AGENT_TERMINAL_ID: terminalId } : {};
97
115
  }
98
116
  /**
99
117
  * Launch a detached login-shell command in its own Unix session/process group.
@@ -30,6 +30,18 @@ function teammateName(session, teammatesById) {
30
30
  function moodForSession(session, hasOpenBlock) {
31
31
  if (session.status === 'abandoned')
32
32
  return 'blocked';
33
+ // A crashed session is NOT `done` — it stopped without finishing, and its last
34
+ // parsed turn often still says `working`, which would otherwise reach the Floor
35
+ // as a happily-running agent. Both lost-host states need a human.
36
+ if (session.status === 'crashed')
37
+ return 'blocked';
38
+ // An orphaned session that is genuinely mid-question needs an answer; one that
39
+ // is merely idle-and-unwatched needs someone to reattach or clean it up. Same
40
+ // distinction `isAwaitingUser` draws — giving both the high-intensity "needs
41
+ // input" alert would train the operator to ignore it.
42
+ if (session.status === 'orphaned') {
43
+ return session.activity === 'waiting_input' ? 'waiting' : 'blocked';
44
+ }
33
45
  if (session.status === 'closed')
34
46
  return 'done';
35
47
  if (session.status === 'input_required' || session.activity === 'waiting_input' || hasOpenBlock)
@@ -77,9 +77,14 @@ export interface DynamicPickerConfig<T, F> {
77
77
  /**
78
78
  * Side-effecting keys that don't change the filter (e.g. `y` copies a command).
79
79
  * Receives the live search `query` so the effect can be search-aware. Return a
80
- * short string to flash under the list.
80
+ * short string to flash under the list, or `{ flash, reload }` when the effect
81
+ * changed something the rows RENDER (a star, a mark) and the list has to be
82
+ * rebuilt — the row labels are memoized, so a flash alone leaves them stale.
81
83
  */
82
- onKey?: (name: string, filter: F, active: T | undefined, query: string) => string | void;
84
+ onKey?: (name: string, filter: F, active: T | undefined, query: string) => string | void | {
85
+ flash?: string;
86
+ reload?: boolean;
87
+ };
83
88
  /** Key that enters search mode (default `s`). */
84
89
  searchKey?: string;
85
90
  /** Key that toggles the preview pane (default `tab`). */
@@ -89,6 +94,26 @@ export interface DynamicPickerConfig<T, F> {
89
94
  loadingMessage?: string;
90
95
  enterHint?: string;
91
96
  }
97
+ /**
98
+ * The lookup token for a hotkey: the literal character the key produced, else
99
+ * readline's key name (`tab`, `escape`, arrows).
100
+ *
101
+ * readline reports both `f` and `F` as name `f` — only `sequence` tells them
102
+ * apart — and gives a punctuation key like `*` no name at all. Keying on the
103
+ * character makes shifted letters and punctuation bindable, and is a no-op for
104
+ * every existing binding: for a plain lowercase letter, sequence === name.
105
+ *
106
+ * Callers receiving this in `onKey` see the literal character, so a handler that
107
+ * wants to accept both cases of a letter must say so (`'y'` and `'Y'`); the
108
+ * keyBindings lookup falls back to the key NAME, which keeps the shifted form of
109
+ * an existing single-letter binding working without each caller restating it.
110
+ */
111
+ export declare function hotkeyToken(key: {
112
+ name?: string;
113
+ sequence?: string;
114
+ ctrl?: boolean;
115
+ meta?: boolean;
116
+ }): string;
92
117
  /** The result returned when the user selects a row: the item plus the live filter. */
93
118
  export interface DynamicPicked<T, F> {
94
119
  item: T;
@@ -324,6 +324,28 @@ export function multiItemPicker(config) {
324
324
  });
325
325
  return prompt(config);
326
326
  }
327
+ /**
328
+ * The lookup token for a hotkey: the literal character the key produced, else
329
+ * readline's key name (`tab`, `escape`, arrows).
330
+ *
331
+ * readline reports both `f` and `F` as name `f` — only `sequence` tells them
332
+ * apart — and gives a punctuation key like `*` no name at all. Keying on the
333
+ * character makes shifted letters and punctuation bindable, and is a no-op for
334
+ * every existing binding: for a plain lowercase letter, sequence === name.
335
+ *
336
+ * Callers receiving this in `onKey` see the literal character, so a handler that
337
+ * wants to accept both cases of a letter must say so (`'y'` and `'Y'`); the
338
+ * keyBindings lookup falls back to the key NAME, which keeps the shifted form of
339
+ * an existing single-letter binding working without each caller restating it.
340
+ */
341
+ export function hotkeyToken(key) {
342
+ const seq = key.sequence;
343
+ // Printable single characters only — a control code's sequence (`\t`, `\r`,
344
+ // `\x7f`) must keep resolving to its name.
345
+ if (!key.ctrl && !key.meta && seq && seq.length === 1 && seq > ' ' && seq !== '\x7f')
346
+ return seq;
347
+ return key.name ?? '';
348
+ }
327
349
  /**
328
350
  * Async-refetch variant of {@link itemPicker}. Holds a `filter` object in state and
329
351
  * re-runs `load(filter)` whenever a keybinding mutates it (with a loading placeholder
@@ -349,11 +371,32 @@ export function dynamicPicker(config) {
349
371
  const [previewOpen, setPreviewOpen] = useState(Boolean(cfg.buildPreview));
350
372
  const [active, setActive] = useState(0);
351
373
  const [flash, setFlash] = useState('');
374
+ // Bumped by an `onKey` that asks for a reload; a dep of the load effect, so a
375
+ // side effect that changed the rows can rebuild them without a filter change.
376
+ const [reloadNonce, setReloadNonce] = useState(0);
377
+ // The counter lives in a ref, not in the state read back from the keypress
378
+ // closure: that closure can hold a STALE `reloadNonce`, so a second reload
379
+ // would recompute the same value, the state would not change, and the
380
+ // repaint would silently never happen (the first star appeared, the second
381
+ // did not). A ref is always current.
382
+ const reloadCount = useRef(0);
383
+ // Bumped when a load RESOLVES. `load` is async, so the render that follows
384
+ // `setReloadNonce` still sees the pre-load data — memoizing the row labels on
385
+ // the nonce alone rendered each press's result one press late. Keying them on
386
+ // load COMPLETION is what actually makes them current.
387
+ const [loadedSeq, setLoadedSeq] = useState(0);
388
+ const loadedCount = useRef(0);
352
389
  const prefix = usePrefix({ status, theme });
353
390
  // Guards against a slow load resolving after a newer filter superseded it.
354
391
  const gen = useRef(0);
392
+ // The filter the last load ran on, so a nonce-only reload (a row's own state
393
+ // changed) can keep the cursor where the user left it. Snapping back to the
394
+ // top every time you star a row would make the key unusable for a second one.
395
+ const loadedFilter = useRef(undefined);
355
396
  useEffect(() => {
356
397
  const my = ++gen.current;
398
+ const filterChanged = loadedFilter.current !== filter;
399
+ loadedFilter.current = filter;
357
400
  setLoading(true);
358
401
  Promise.resolve(cfg.load(filter))
359
402
  .then((rows) => {
@@ -361,7 +404,9 @@ export function dynamicPicker(config) {
361
404
  return;
362
405
  setItems(rows);
363
406
  setLoading(false);
364
- setActive(0);
407
+ setLoadedSeq((loadedCount.current += 1));
408
+ if (filterChanged)
409
+ setActive(0);
365
410
  })
366
411
  .catch(() => {
367
412
  if (my !== gen.current)
@@ -369,7 +414,7 @@ export function dynamicPicker(config) {
369
414
  setItems([]);
370
415
  setLoading(false);
371
416
  });
372
- }, [filter]);
417
+ }, [filter, reloadNonce]);
373
418
  const results = useMemo(() => {
374
419
  const q = query.trim();
375
420
  const pool = q && cfg.matches ? items.filter((it) => cfg.matches(it, q)) : items;
@@ -377,7 +422,12 @@ export function dynamicPicker(config) {
377
422
  value: item,
378
423
  label: cfg.labelFor(item, q),
379
424
  }));
380
- }, [items, query]);
425
+ // `loadedSeq` is a dep because a reload can legitimately return the SAME
426
+ // array — `load` hands back its cached pool unchanged when no filter is
427
+ // active — while what a row RENDERS has changed underneath it. Without this
428
+ // the labels stay memoized on the old state and the side effect looks like
429
+ // it silently did nothing (starring a row left the star invisible).
430
+ }, [items, query, loadedSeq]);
381
431
  useEffect(() => {
382
432
  if (active >= results.length)
383
433
  setActive(0);
@@ -462,7 +512,13 @@ export function dynamicPicker(config) {
462
512
  setPreviewOpen(!previewOpen);
463
513
  return;
464
514
  }
465
- const binding = cfg.keyBindings?.[key.name ?? ''];
515
+ const token = hotkeyToken(key);
516
+ // Exact character first (so `*` and a shifted `F` are addressable), then
517
+ // the readline name. The fallback is what preserves the shifted form of an
518
+ // existing single-letter hotkey: `R`/`C`/`A` used to reach their bindings
519
+ // via `key.name`, and keying on the character alone would silently retire
520
+ // them for anyone with caps lock on.
521
+ const binding = cfg.keyBindings?.[token] ?? cfg.keyBindings?.[key.name ?? ''];
466
522
  if (binding) {
467
523
  const next = binding(filter);
468
524
  if (!Object.is(next, filter))
@@ -470,9 +526,17 @@ export function dynamicPicker(config) {
470
526
  return;
471
527
  }
472
528
  if (cfg.onKey) {
473
- const msg = cfg.onKey(key.name ?? '', filter, selected?.value, query);
474
- if (msg)
475
- setFlash(msg);
529
+ const res = cfg.onKey(token, filter, selected?.value, query);
530
+ if (typeof res === 'string')
531
+ setFlash(res);
532
+ else if (res) {
533
+ if (res.flash)
534
+ setFlash(res.flash);
535
+ // The rows themselves changed — force the load effect to re-run so the
536
+ // memoized labels are rebuilt.
537
+ if (res.reload)
538
+ setReloadNonce((reloadCount.current += 1));
539
+ }
476
540
  }
477
541
  });
478
542
  const message = theme.style.message(cfg.message, status);
@@ -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
@@ -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;