@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.
- package/CHANGELOG.md +240 -0
- package/README.md +6 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/exec.js +7 -1
- package/dist/commands/harness.d.ts +27 -0
- package/dist/commands/harness.js +120 -13
- package/dist/commands/profiles.d.ts +3 -0
- package/dist/commands/profiles.js +1 -1
- package/dist/commands/routines.d.ts +19 -0
- package/dist/commands/routines.js +28 -6
- package/dist/commands/secrets.d.ts +10 -1
- package/dist/commands/secrets.js +18 -6
- package/dist/commands/sessions-browser.d.ts +4 -0
- package/dist/commands/sessions-browser.js +51 -9
- package/dist/commands/sessions-favorite.d.ts +20 -0
- package/dist/commands/sessions-favorite.js +120 -0
- package/dist/commands/sessions.d.ts +103 -20
- package/dist/commands/sessions.js +356 -62
- package/dist/commands/setup-secrets.d.ts +7 -0
- package/dist/commands/setup-secrets.js +12 -9
- package/dist/commands/versions.js +12 -4
- package/dist/commands/view.d.ts +14 -1
- package/dist/commands/view.js +103 -128
- package/dist/lib/agents.d.ts +4 -2
- package/dist/lib/agents.js +21 -6
- package/dist/lib/hosts/dispatch.js +19 -1
- package/dist/lib/hq/floor.js +12 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/picker.d.ts +27 -2
- package/dist/lib/picker.js +71 -7
- package/dist/lib/profiles.d.ts +48 -0
- package/dist/lib/profiles.js +67 -0
- package/dist/lib/rotate.d.ts +24 -2
- package/dist/lib/rotate.js +63 -6
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/session/active.d.ts +109 -3
- package/dist/lib/session/active.js +269 -13
- package/dist/lib/session/db.d.ts +14 -0
- package/dist/lib/session/db.js +35 -0
- package/dist/lib/session/favorites.d.ts +39 -0
- package/dist/lib/session/favorites.js +101 -0
- package/dist/lib/session/host-link.d.ts +68 -0
- package/dist/lib/session/host-link.js +64 -0
- package/dist/lib/session/presence.d.ts +85 -0
- package/dist/lib/session/presence.js +150 -0
- package/dist/lib/session/remote-list.d.ts +10 -0
- package/dist/lib/session/remote-list.js +47 -9
- package/dist/lib/tmux/binary.d.ts +7 -0
- package/dist/lib/tmux/binary.js +11 -1
- package/dist/lib/types.d.ts +4 -3
- package/dist/lib/usage-backoff.d.ts +29 -0
- package/dist/lib/usage-backoff.js +165 -0
- package/dist/lib/usage.d.ts +112 -5
- package/dist/lib/usage.js +464 -46
- package/dist/lib/watchdog/runner.d.ts +13 -0
- package/dist/lib/watchdog/runner.js +16 -1
- 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.
|
package/dist/lib/hq/floor.js
CHANGED
|
@@ -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)
|
|
Binary file
|
package/dist/lib/picker.d.ts
CHANGED
|
@@ -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;
|
package/dist/lib/picker.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
474
|
-
if (
|
|
475
|
-
setFlash(
|
|
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);
|
package/dist/lib/profiles.d.ts
CHANGED
|
@@ -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
|
package/dist/lib/profiles.js
CHANGED
|
@@ -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
|
package/dist/lib/rotate.d.ts
CHANGED
|
@@ -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
|
package/dist/lib/rotate.js
CHANGED
|
@@ -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 =
|
|
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 ??
|
|
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;
|
|
Binary file
|
|
Binary file
|