@phnx-labs/agents-cli 1.22.49 → 1.22.51

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 (60) hide show
  1. package/CHANGELOG.md +229 -0
  2. package/README.md +10 -0
  3. package/dist/bootstrap.js +1 -1
  4. package/dist/commands/accounts.js +1 -13
  5. package/dist/commands/browser.d.ts +10 -0
  6. package/dist/commands/browser.js +77 -2
  7. package/dist/commands/config.js +20 -0
  8. package/dist/commands/daemon-test-harness.d.ts +42 -0
  9. package/dist/commands/daemon-test-harness.js +90 -0
  10. package/dist/commands/device-config-test-harness.d.ts +11 -0
  11. package/dist/commands/device-config-test-harness.js +59 -0
  12. package/dist/commands/exec.js +24 -1
  13. package/dist/commands/feedback.js +1 -1
  14. package/dist/commands/menubar.js +6 -4
  15. package/dist/commands/share.js +9 -4
  16. package/dist/commands/teams.d.ts +12 -0
  17. package/dist/commands/teams.js +44 -67
  18. package/dist/commands/traces.js +36 -4
  19. package/dist/lib/account-provider-registry.d.ts +9 -0
  20. package/dist/lib/account-provider-registry.js +20 -0
  21. package/dist/lib/accounting/account-pool-collect.d.ts +34 -0
  22. package/dist/lib/accounting/account-pool-collect.js +71 -0
  23. package/dist/lib/accounting/account-pool.d.ts +46 -0
  24. package/dist/lib/accounting/account-pool.js +33 -0
  25. package/dist/lib/accounting/capacity.d.ts +24 -0
  26. package/dist/lib/accounting/capacity.js +30 -0
  27. package/dist/lib/accounting/rotate.d.ts +11 -18
  28. package/dist/lib/accounting/rotate.js +6 -24
  29. package/dist/lib/claude-statusline.d.ts +9 -0
  30. package/dist/lib/claude-statusline.js +45 -4
  31. package/dist/lib/computer/ssh-tunnel.d.ts +7 -6
  32. package/dist/lib/computer/ssh-tunnel.js +13 -8
  33. package/dist/lib/config-keys.d.ts +4 -3
  34. package/dist/lib/config-keys.js +9 -2
  35. package/dist/lib/device-config.js +23 -0
  36. package/dist/lib/factory/snapshot.d.ts +1 -1
  37. package/dist/lib/factory/snapshot.js +1 -1
  38. package/dist/lib/helper-download.d.ts +12 -2
  39. package/dist/lib/helper-download.js +12 -2
  40. package/dist/lib/installations/migrate.js +4 -4
  41. package/dist/lib/installations/store.js +6 -3
  42. package/dist/lib/installations/versions.js +48 -46
  43. package/dist/lib/menubar/install-menubar.d.ts +71 -8
  44. package/dist/lib/menubar/install-menubar.js +183 -24
  45. package/dist/lib/share/publish.d.ts +8 -3
  46. package/dist/lib/share/publish.js +6 -3
  47. package/dist/lib/share/worker-template.js +239 -22
  48. package/dist/lib/star-nudge.d.ts +2 -2
  49. package/dist/lib/star-nudge.js +2 -2
  50. package/dist/lib/state.d.ts +1 -1
  51. package/dist/lib/state.js +2 -2
  52. package/dist/lib/teams/agents.d.ts +14 -12
  53. package/dist/lib/teams/agents.js +188 -73
  54. package/dist/lib/teams/api.d.ts +6 -1
  55. package/dist/lib/teams/api.js +2 -0
  56. package/dist/lib/teams/scheduler.d.ts +7 -0
  57. package/dist/lib/teams/scheduler.js +26 -8
  58. package/dist/lib/traces/sync.d.ts +30 -0
  59. package/dist/lib/traces/sync.js +103 -10
  60. package/package.json +3 -3
@@ -0,0 +1,33 @@
1
+ import { providerAuthenticatesHarness } from '../account-provider-registry.js';
2
+ /**
3
+ * Which provider accounts can authenticate `agent`. An account is a candidate for
4
+ * harness H only when its provider adapter has an `envFor(H, kind)` mapping
5
+ * (`providerAuthenticatesHarness`, the same check the account UI uses) — so a
6
+ * Cursor key never enters Claude's pool and a Claude setup-token never enters
7
+ * Codex's. This is registry-driven, no per-harness `else if`: a harness gets a
8
+ * provider-account pool exactly when the adapter table maps a provider to it —
9
+ * today claude, codex, grok, cursor, opencode (plus gemini/antigravity). A harness
10
+ * with only a native OAuth login and no provider adapter — kimi — returns `[]`
11
+ * here and keeps balancing its native logins only.
12
+ *
13
+ * `accountKey` is synthetic here (`${agent}:name=${name}`) — a stable per-account
14
+ * key so balancing can include it immediately; the real agent-scoped identity
15
+ * (email / org uuid) is backfilled by identity capture and replaces it. `email`
16
+ * is null until then, which routes the account as usage-unverified but still a
17
+ * candidate, never excluded.
18
+ */
19
+ export function registryPoolCandidates(records, agent) {
20
+ const out = [];
21
+ for (const r of records) {
22
+ if (!providerAuthenticatesHarness(r.provider, r.auth, agent))
23
+ continue;
24
+ out.push({
25
+ accountKey: `${agent}:name=${r.name}`,
26
+ email: null,
27
+ name: r.name,
28
+ provider: r.provider,
29
+ auth: r.auth,
30
+ });
31
+ }
32
+ return out;
33
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Remaining-capacity weighting for account selection — a pure, dependency-free
3
+ * primitive shared by the version-home router (`rotate.ts`) and the account pool
4
+ * (`account-pool.ts`). Kept in its own module so either can import it without
5
+ * dragging in the heavy usage/secrets graph.
6
+ */
7
+ /**
8
+ * How far from its projected cap an account must be to keep its FULL headroom
9
+ * weight. Inside this horizon the weight is scaled down linearly toward the
10
+ * floor, so an account racing toward its 5h cap loses priority before it maxes.
11
+ */
12
+ export declare const PROJECTION_HORIZON_MIN = 30;
13
+ /**
14
+ * Weight one candidate by remaining routing capacity, deprioritized by how soon
15
+ * it is projected to cap. The base is weekly headroom (`max(1, 100 - used)`);
16
+ * an account with no live snapshot is treated as full-capacity (100) since there
17
+ * is no signal to deprioritize it. `minutesToLimit` (the daemon's burn-rate
18
+ * projection on the 5h session window) then scales that base: >= horizon (or
19
+ * unknown) keeps full weight, and closer-to-cap scales toward the floor of 1 —
20
+ * so a launch avoids an account projected to cap soon, not just a 100%-maxed
21
+ * one. Pure + exported so the deprioritization is unit-tested directly (a
22
+ * weighted-random draw is not).
23
+ */
24
+ export declare function capacityWeight(usedPercent: number | null, minutesToLimit: number | null): number;
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Remaining-capacity weighting for account selection — a pure, dependency-free
3
+ * primitive shared by the version-home router (`rotate.ts`) and the account pool
4
+ * (`account-pool.ts`). Kept in its own module so either can import it without
5
+ * dragging in the heavy usage/secrets graph.
6
+ */
7
+ /**
8
+ * How far from its projected cap an account must be to keep its FULL headroom
9
+ * weight. Inside this horizon the weight is scaled down linearly toward the
10
+ * floor, so an account racing toward its 5h cap loses priority before it maxes.
11
+ */
12
+ export const PROJECTION_HORIZON_MIN = 30;
13
+ /**
14
+ * Weight one candidate by remaining routing capacity, deprioritized by how soon
15
+ * it is projected to cap. The base is weekly headroom (`max(1, 100 - used)`);
16
+ * an account with no live snapshot is treated as full-capacity (100) since there
17
+ * is no signal to deprioritize it. `minutesToLimit` (the daemon's burn-rate
18
+ * projection on the 5h session window) then scales that base: >= horizon (or
19
+ * unknown) keeps full weight, and closer-to-cap scales toward the floor of 1 —
20
+ * so a launch avoids an account projected to cap soon, not just a 100%-maxed
21
+ * one. Pure + exported so the deprioritization is unit-tested directly (a
22
+ * weighted-random draw is not).
23
+ */
24
+ export function capacityWeight(usedPercent, minutesToLimit) {
25
+ const base = usedPercent === null ? 100 : Math.max(1, 100 - usedPercent);
26
+ if (minutesToLimit === null || !Number.isFinite(minutesToLimit))
27
+ return base;
28
+ const factor = Math.max(0, Math.min(1, minutesToLimit / PROJECTION_HORIZON_MIN));
29
+ return Math.max(1, base * factor);
30
+ }
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import type { AgentId, RunStrategy } from '../types.js';
8
8
  import type { FallbackEntry } from '../exec.js';
9
+ import { PROJECTION_HORIZON_MIN, capacityWeight } from './capacity.js';
9
10
  import { type AccountInfo, type CredentialPresence } from '../agents.js';
10
11
  import { type UsageSnapshot } from './usage.js';
11
12
  import { type AuthVerdict } from '../auth-health.js';
@@ -49,6 +50,15 @@ export interface RotateCandidate {
49
50
  */
50
51
  authVerdict: AuthVerdict | null;
51
52
  lastActive: Date | null;
53
+ /**
54
+ * Set only for a candidate that comes from a provider account bundle rather
55
+ * than a native version-home login (RUSH-3182): the account name to inject via
56
+ * the `--account` spawn path (`resolveSpawnAccount` → `accountEnv`). Undefined
57
+ * for a native login, whose credential already lives in `version`'s home. The
58
+ * run path routes on this so a balanced pick of a setup-token / API-key account
59
+ * authenticates through the existing provider-account injection.
60
+ */
61
+ providerAccount?: string;
52
62
  }
53
63
  export interface RotateResult {
54
64
  /** The version picked for this run. */
@@ -212,24 +222,7 @@ export declare function checkRunAccountReadiness(agent: AgentId, version: string
212
222
  * version so behavior stays predictable.
213
223
  */
214
224
  export declare function pickBalancedCandidate(candidates: RotateCandidate[], nowMs?: number): RotateResult | null;
215
- /**
216
- * How far from its projected cap an account must be to keep its FULL headroom
217
- * weight. Inside this horizon the weight is scaled down linearly toward the
218
- * floor, so an account racing toward its 5h cap loses priority before it maxes.
219
- */
220
- export declare const PROJECTION_HORIZON_MIN = 30;
221
- /**
222
- * Weight one candidate by remaining routing capacity, deprioritized by how soon
223
- * it is projected to cap. The base is weekly headroom (`max(1, 100 - used)`);
224
- * an account with no live snapshot is treated as full-capacity (100) since there
225
- * is no signal to deprioritize it. `minutesToLimit` (the daemon's burn-rate
226
- * projection on the 5h session window) then scales that base: >= horizon (or
227
- * unknown) keeps full weight, and closer-to-cap scales toward the floor of 1 —
228
- * so a launch avoids an account projected to cap soon, not just a 100%-maxed
229
- * one. Pure + exported so the deprioritization is unit-tested directly (a
230
- * weighted-random draw is not).
231
- */
232
- export declare function capacityWeight(usedPercent: number | null, minutesToLimit: number | null): number;
225
+ export { PROJECTION_HORIZON_MIN, capacityWeight };
233
226
  /**
234
227
  * Pick an available candidate. Prefers the configured pinned version when that
235
228
  * version has usage available; otherwise routes to the candidate with the most
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import * as fs from 'fs';
8
8
  import * as path from 'path';
9
+ import { PROJECTION_HORIZON_MIN, capacityWeight } from './capacity.js';
9
10
  import { accountDisplayLabel, getAccountInfo, credentialPresence, ALL_AGENT_IDS, } from '../agents.js';
10
11
  import { readMeta, writeMeta, getHelpersDir } from '../state.js';
11
12
  import { listInstalledVersions, getVersionHomePath, resolveVersion } from '../installations/versions.js';
@@ -365,30 +366,11 @@ function preferVerified(pool, nowMs, choose, narrowing = 'any-verified') {
365
366
  usageUnverified: !isUsageVerified(picked, nowMs),
366
367
  };
367
368
  }
368
- /**
369
- * How far from its projected cap an account must be to keep its FULL headroom
370
- * weight. Inside this horizon the weight is scaled down linearly toward the
371
- * floor, so an account racing toward its 5h cap loses priority before it maxes.
372
- */
373
- export const PROJECTION_HORIZON_MIN = 30;
374
- /**
375
- * Weight one candidate by remaining routing capacity, deprioritized by how soon
376
- * it is projected to cap. The base is weekly headroom (`max(1, 100 - used)`);
377
- * an account with no live snapshot is treated as full-capacity (100) since there
378
- * is no signal to deprioritize it. `minutesToLimit` (the daemon's burn-rate
379
- * projection on the 5h session window) then scales that base: >= horizon (or
380
- * unknown) keeps full weight, and closer-to-cap scales toward the floor of 1 —
381
- * so a launch avoids an account projected to cap soon, not just a 100%-maxed
382
- * one. Pure + exported so the deprioritization is unit-tested directly (a
383
- * weighted-random draw is not).
384
- */
385
- export function capacityWeight(usedPercent, minutesToLimit) {
386
- const base = usedPercent === null ? 100 : Math.max(1, 100 - usedPercent);
387
- if (minutesToLimit === null || !Number.isFinite(minutesToLimit))
388
- return base;
389
- const factor = Math.max(0, Math.min(1, minutesToLimit / PROJECTION_HORIZON_MIN));
390
- return Math.max(1, base * factor);
391
- }
369
+ // capacityWeight + PROJECTION_HORIZON_MIN moved to ./capacity.js (a pure,
370
+ // dependency-free module the account pool can import without this file's heavy
371
+ // graph). Imported at the top for internal use; re-exported here so existing
372
+ // importers keep resolving them from rotate.
373
+ export { PROJECTION_HORIZON_MIN, capacityWeight };
392
374
  /**
393
375
  * Pick one candidate from `sorted` using weights proportional to remaining
394
376
  * routing capacity (see {@link capacityWeight}). Floor each weight at 1 so a
@@ -1,4 +1,12 @@
1
1
  export declare const CLAUDE_STATUSLINE_COMMAND = "agents __claude-statusline";
2
+ /**
3
+ * True when `command` re-invokes THIS status-line producer (our private
4
+ * `__claude-statusline` subcommand) under any binary name or path. Delegating to
5
+ * such a command is the fork bomb, so both the read side (renderDelegate) and the
6
+ * write side (installClaudeStatusLine) treat it as "not a real external
7
+ * producer" and never chain to it.
8
+ */
9
+ export declare function isStatusLineSelfReference(command: string): boolean;
2
10
  interface ClaudeStatusLinePayload {
3
11
  cwd?: string;
4
12
  workspace?: {
@@ -20,6 +28,7 @@ interface ClaudeStatusLinePayload {
20
28
  };
21
29
  }
22
30
  export declare function ingestClaudeStatusLineUsage(payload: ClaudeStatusLinePayload, versionHome: string): boolean;
31
+ export declare function renderDelegate(payload: string, versionHome: string): string;
23
32
  export declare function renderClaudeStatusLine(payload: ClaudeStatusLinePayload, host?: string, delegated?: string): string;
24
33
  export declare function runClaudeStatusLine(): Promise<number>;
25
34
  export declare function installClaudeStatusLine(versionHome: string): {
@@ -7,6 +7,39 @@ import { atomicWriteFileSync } from './fs-atomic.js';
7
7
  import { mergeClaudeUsageCacheWindows } from './accounting/usage.js';
8
8
  export const CLAUDE_STATUSLINE_COMMAND = 'agents __claude-statusline';
9
9
  const DELEGATE_FILE = path.join('.agents', 'claude-statusline-delegate');
10
+ // The private subcommand this feature runs. It is only ever invoked internally,
11
+ // so ANY command that contains it — under any binary name or path (`agents`,
12
+ // `agents-dev`, `ag`, an absolute path, a wrapper) — IS this status-line
13
+ // producer, and delegating to it recurses without bound. Match the subcommand,
14
+ // not the exact `agents __claude-statusline` string, or a delegate seeded with a
15
+ // differently-named binary (e.g. `agents-dev __claude-statusline`) fork-bombs the
16
+ // machine: each render spawns a copy that reads the same delegate and spawns
17
+ // another, forever.
18
+ const STATUSLINE_SUBCOMMAND = '__claude-statusline';
19
+ // Set on the child env before spawning a delegate. If it is already present we
20
+ // are ourselves running as someone's delegate, so we refuse to delegate again —
21
+ // a hard depth-1 backstop that bounds the blast radius even if a self-reference
22
+ // somehow slips past isStatusLineSelfReference(). One hop is the contract:
23
+ // installClaudeStatusLine only ever preserves a single prior command.
24
+ const DELEGATE_GUARD_ENV = 'AGENTS_CLAUDE_STATUSLINE_DELEGATED';
25
+ // A hung or slow delegate must never pin a status-line render open — the render
26
+ // is re-invoked on every refresh, so an unbounded delegate accumulates processes.
27
+ const DELEGATE_TIMEOUT_MS = 5_000;
28
+ /**
29
+ * True when `command` re-invokes THIS status-line producer (our private
30
+ * `__claude-statusline` subcommand) under any binary name or path. Delegating to
31
+ * such a command is the fork bomb, so both the read side (renderDelegate) and the
32
+ * write side (installClaudeStatusLine) treat it as "not a real external
33
+ * producer" and never chain to it.
34
+ */
35
+ export function isStatusLineSelfReference(command) {
36
+ const trimmed = command.trim();
37
+ if (!trimmed)
38
+ return false;
39
+ if (trimmed === CLAUDE_STATUSLINE_COMMAND)
40
+ return true;
41
+ return new RegExp(`(^|\\s)${STATUSLINE_SUBCOMMAND}(\\s|$)`).test(trimmed);
42
+ }
10
43
  function isRecord(value) {
11
44
  return typeof value === 'object' && value !== null && !Array.isArray(value);
12
45
  }
@@ -48,7 +81,10 @@ export function ingestClaudeStatusLineUsage(payload, versionHome) {
48
81
  function delegatePath(versionHome) {
49
82
  return path.join(versionHome, DELEGATE_FILE);
50
83
  }
51
- function renderDelegate(payload, versionHome) {
84
+ export function renderDelegate(payload, versionHome) {
85
+ // Already running as a delegate hop — never spawn another. Hard recursion stop.
86
+ if (process.env[DELEGATE_GUARD_ENV])
87
+ return '';
52
88
  let command = '';
53
89
  try {
54
90
  command = fs.readFileSync(delegatePath(versionHome), 'utf8').trim();
@@ -56,13 +92,14 @@ function renderDelegate(payload, versionHome) {
56
92
  catch {
57
93
  return '';
58
94
  }
59
- if (!command || command === CLAUDE_STATUSLINE_COMMAND)
95
+ if (!command || isStatusLineSelfReference(command))
60
96
  return '';
61
97
  const result = spawnSync(command, {
62
98
  shell: true,
63
99
  input: payload,
64
100
  encoding: 'utf8',
65
- env: process.env,
101
+ env: { ...process.env, [DELEGATE_GUARD_ENV]: '1' },
102
+ timeout: DELEGATE_TIMEOUT_MS,
66
103
  });
67
104
  return result.status === 0 ? result.stdout.trim() : '';
68
105
  }
@@ -117,11 +154,15 @@ export function installClaudeStatusLine(versionHome) {
117
154
  : '';
118
155
  if (existing === CLAUDE_STATUSLINE_COMMAND)
119
156
  return { changed: false };
120
- if (existing) {
157
+ if (existing && !isStatusLineSelfReference(existing)) {
158
+ // A genuine third-party status-line command → preserve it as a delegate.
121
159
  fs.mkdirSync(path.dirname(delegatePath(versionHome)), { recursive: true });
122
160
  atomicWriteFileSync(delegatePath(versionHome), `${existing}\n`);
123
161
  }
124
162
  else {
163
+ // Empty, or our own subcommand under a different binary name
164
+ // (`agents-dev __claude-statusline`, an absolute path, …). Saving that as a
165
+ // delegate is the fork bomb — never persist it. Drop any prior delegate.
125
166
  fs.rmSync(delegatePath(versionHome), { force: true });
126
167
  }
127
168
  settings.statusLine = {
@@ -59,11 +59,11 @@ export declare const WIN_HELPER_TOKEN_FILE = "helper-token";
59
59
  * the release-asset download (`ensureWinHelperExe`).
60
60
  */
61
61
  export declare function resolveWinHelperExe(): string | null;
62
- /** GitHub repo whose `v<version>` releases carry the exe as an asset. */
63
- export declare const WIN_HELPER_RELEASE_REPO = "phnx-labs/agents-cli";
62
+ /** GitHub repo whose `computer-win/v<version>` releases carry the exe as an asset. */
63
+ export declare const WIN_HELPER_RELEASE_REPO = "phnx-labs/agi-cli";
64
64
  /** Cache dir for downloaded helper exes, one subdir per release tag. */
65
65
  export declare function winHelperCacheDir(version: string): string;
66
- /** Release-asset URLs for the exe + its checksum at one exact `v<version>` tag. */
66
+ /** Release-asset URLs for the exe + its checksum at one exact `computer-win/v<version>` tag. */
67
67
  export declare function winHelperAssetUrls(version: string): {
68
68
  exe: string;
69
69
  sha256: string;
@@ -73,10 +73,11 @@ export declare function winHelperAssetUrls(version: string): {
73
73
  * a bare hex digest. Throws on anything that does not lead with 64 hex chars.
74
74
  */
75
75
  /**
76
- * Download the exe release asset for this CLI version, verify its sha256
76
+ * Download the exe release asset for this HELPER version, verify its sha256
77
77
  * against the published `.sha256` asset, and cache it under the agents cache
78
- * dir. Only the exact `v<version>` tag is consulted — a missing asset is a
79
- * hard error naming that tag, never a silent fallback to another release.
78
+ * dir. Only the exact `computer-win/v<version>` tag is consulted — a missing
79
+ * asset is a hard error naming that tag, never a silent fallback to another
80
+ * release. The version is the HELPER's own, not the CLI's.
80
81
  */
81
82
  export declare function downloadWinHelperExe(version: string): Promise<string>;
82
83
  /**
@@ -127,13 +127,13 @@ export function resolveWinHelperExe() {
127
127
  }
128
128
  return null;
129
129
  }
130
- /** GitHub repo whose `v<version>` releases carry the exe as an asset. */
131
- export const WIN_HELPER_RELEASE_REPO = 'phnx-labs/agents-cli';
130
+ /** GitHub repo whose `computer-win/v<version>` releases carry the exe as an asset. */
131
+ export const WIN_HELPER_RELEASE_REPO = 'phnx-labs/agi-cli';
132
132
  /** Cache dir for downloaded helper exes, one subdir per release tag. */
133
133
  export function winHelperCacheDir(version) {
134
134
  return path.join(getCacheDir(), 'computer', 'win-helper', `v${version}`);
135
135
  }
136
- /** Release-asset URLs for the exe + its checksum at one exact `v<version>` tag. */
136
+ /** Release-asset URLs for the exe + its checksum at one exact `computer-win/v<version>` tag. */
137
137
  export function winHelperAssetUrls(version) {
138
138
  // The helper's OWN tag, not the CLI's. This exe is not an .app bundle so it
139
139
  // cannot share helper-download.ts's zip/codesign machinery, but it had the
@@ -148,19 +148,24 @@ export function winHelperAssetUrls(version) {
148
148
  * a bare hex digest. Throws on anything that does not lead with 64 hex chars.
149
149
  */
150
150
  /**
151
- * Download the exe release asset for this CLI version, verify its sha256
151
+ * Download the exe release asset for this HELPER version, verify its sha256
152
152
  * against the published `.sha256` asset, and cache it under the agents cache
153
- * dir. Only the exact `v<version>` tag is consulted — a missing asset is a
154
- * hard error naming that tag, never a silent fallback to another release.
153
+ * dir. Only the exact `computer-win/v<version>` tag is consulted — a missing
154
+ * asset is a hard error naming that tag, never a silent fallback to another
155
+ * release. The version is the HELPER's own, not the CLI's.
155
156
  */
156
157
  export async function downloadWinHelperExe(version) {
157
158
  const cached = path.join(winHelperCacheDir(version), WIN_HELPER_EXE);
158
159
  if (fs.existsSync(cached))
159
160
  return cached;
160
- const tag = `v${version}`;
161
+ // The SAME tag the URL is built from. This said `v${version}` — the CLI's tag
162
+ // shape — while winHelperAssetUrls uses helperTag('computer-win', ...), so a
163
+ // missing asset sent you looking for a tag that does not exist. The mac path
164
+ // had this fixed already; the Windows path was left behind by the tag split.
165
+ const tag = helperTag('computer-win', version);
161
166
  const { exe: exeUrl, sha256: shaUrl } = winHelperAssetUrls(version);
162
167
  const missing = (status, url) => new Error(`no ${WIN_HELPER_EXE} release asset for tag ${tag} (HTTP ${status} on ${url}). ` +
163
- `The Windows helper ships as a GitHub release asset per tagged CLI version; ` +
168
+ `The Windows helper ships as a GitHub release asset on its own helper tag; ` +
164
169
  `from a repo checkout you can build it locally instead: bash scripts/build-win.sh`);
165
170
  // Checksum first: it is tiny and 404s fast when the tag has no assets.
166
171
  const shaRes = await fetch(shaUrl, { signal: AbortSignal.timeout(30_000) });
@@ -28,11 +28,12 @@ export interface ParsedAutoConfigKey {
28
28
  scope: 'auto';
29
29
  property: 'pool';
30
30
  }
31
- /** A device-scope browser key (self or peer): the profile agents drive, or the
32
- * browser that shows the user a page. */
31
+ /** A browser key: the profile agents drive or the browser that shows the user a
32
+ * page (both device-scope, self or peer), or `device` — the user-scope fleet
33
+ * hub every box drives by default, which is central and never peer-targeted. */
33
34
  export interface ParsedBrowserConfigKey {
34
35
  scope: 'browser';
35
- property: 'profile' | 'viewer';
36
+ property: 'profile' | 'viewer' | 'device';
36
37
  device?: string;
37
38
  }
38
39
  export interface ParsedProjectConfigKey {
@@ -88,6 +88,9 @@ export function parseConfigKey(key) {
88
88
  if (raw === 'browser.viewer') {
89
89
  return { scope: 'browser', property: 'viewer' };
90
90
  }
91
+ if (raw === 'browser.device') {
92
+ return { scope: 'browser', property: 'device' };
93
+ }
91
94
  if (raw === 'project.root') {
92
95
  return { scope: 'project', property: 'root' };
93
96
  }
@@ -110,7 +113,7 @@ export function parseConfigKey(key) {
110
113
  throw new Error(`Invalid auto config key '${key}'. Use auto.pool.`);
111
114
  }
112
115
  if (raw.startsWith('browser.')) {
113
- throw new Error(`Invalid browser config key '${key}'. Use browser.profile or browser.viewer.`);
116
+ throw new Error(`Invalid browser config key '${key}'. Use browser.profile, browser.viewer, or browser.device.`);
114
117
  }
115
118
  if (raw.startsWith('project.')) {
116
119
  throw new Error(`Invalid project config key '${key}'. Use project.root.`);
@@ -149,7 +152,7 @@ export function listKnownConfigKeys() {
149
152
  for (const tier of MODEL_TIERS) {
150
153
  keys.push(`run.<agent@version>.tier.${tier}`);
151
154
  }
152
- keys.push('interactive.host', 'auto.pool', 'browser.profile', 'browser.viewer', 'project.root');
155
+ keys.push('interactive.host', 'auto.pool', 'browser.profile', 'browser.viewer', 'browser.device', 'project.root');
153
156
  for (const prop of DEVICE_CONFIG_PROPERTIES) {
154
157
  keys.push(`devices.<name>.${prop}`);
155
158
  }
@@ -202,6 +205,10 @@ export function configKeyStorageHint(parsed) {
202
205
  case 'auto':
203
206
  return 'config.autoPool';
204
207
  case 'browser': {
208
+ if (parsed.property === 'device') {
209
+ // User scope: one value in the central agents.yaml that syncs fleet-wide.
210
+ return 'config.defaultBrowserDevice (central agents.yaml; syncs fleet-wide)';
211
+ }
205
212
  const yamlKey = parsed.property === 'viewer' ? 'browserViewer' : 'defaultBrowserProfile';
206
213
  return parsed.device
207
214
  ? `devices/${parsed.device}/agents.yaml config.${yamlKey}`
@@ -94,6 +94,29 @@ export const CONFIG_KEYS = [
94
94
  type: 'string',
95
95
  description: 'Browser profile `agents browser start` resolves to without --profile (set via `agents browser use`).',
96
96
  },
97
+ {
98
+ name: 'browser.device',
99
+ yamlKey: 'defaultBrowserDevice',
100
+ // user scope, so a SINGLE value in the central agents.yaml syncs to every box:
101
+ // the fleet's browser hub. A worker with this set forwards its browser drives
102
+ // to the hub (as if `--device <hub>` was passed) with no per-command flag, so
103
+ // every agent shares the hub's one logged-in browser. The hub names itself, so
104
+ // there it resolves to a self-host and runs locally — which is why one synced
105
+ // value is safe. Unset = drive this box's own browser (today's behavior).
106
+ scope: 'user',
107
+ type: 'string',
108
+ description: 'Fleet browser hub: the device whose browser `agents browser` drive verbs target by default, with no --device. ' +
109
+ 'The hub itself runs locally; every other box forwards to it. Unset = each box drives its own browser.',
110
+ validate: (v) => {
111
+ try {
112
+ assertValidDeviceName(v);
113
+ return null;
114
+ }
115
+ catch (err) {
116
+ return err?.message ?? String(err);
117
+ }
118
+ },
119
+ },
97
120
  {
98
121
  name: 'agents.max-concurrent',
99
122
  yamlKey: 'maxAgents',
@@ -13,7 +13,7 @@ export declare const FACTORY_PROJECTS: readonly [{
13
13
  readonly repo: "phnx-labs/rush-cli";
14
14
  }, {
15
15
  readonly name: "Agents CLI";
16
- readonly repo: "phnx-labs/agents-cli";
16
+ readonly repo: "phnx-labs/agi-cli";
17
17
  }, {
18
18
  readonly name: "Linear CLI";
19
19
  readonly repo: "phnx-labs/linear-cli";
@@ -25,7 +25,7 @@ export const FACTORY_PROJECTS = [
25
25
  { name: 'Prix', repo: 'phnx-labs/prix' },
26
26
  { name: 'Rush App', repo: 'phnx-labs/rush' },
27
27
  { name: 'Rush CLI', repo: 'phnx-labs/rush-cli' },
28
- { name: 'Agents CLI', repo: 'phnx-labs/agents-cli' },
28
+ { name: 'Agents CLI', repo: 'phnx-labs/agi-cli' },
29
29
  { name: 'Linear CLI', repo: 'phnx-labs/linear-cli' },
30
30
  ];
31
31
  const defaults = () => ({
@@ -16,8 +16,18 @@
16
16
  * lands for both helpers at once.
17
17
  */
18
18
  import { type HelperName } from './helper-versions.js';
19
- /** GitHub repo whose `v<version>` releases carry the helper assets. */
20
- export declare const HELPER_RELEASE_REPO = "phnx-labs/agents-cli";
19
+ /**
20
+ * GitHub repo whose `<helper>/v<version>` releases carry the helper assets.
21
+ *
22
+ * This is the SLUG, which is not the npm package name: the package is still
23
+ * `@phnx-labs/agents-cli`, but the repository was renamed to `agi-cli`. The old
24
+ * slug kept working only because GitHub redirects a renamed repo, which is a
25
+ * poor thing to hang signed-binary delivery on — a redirect is one re-created
26
+ * repo away from resolving somewhere else. What actually protects the download
27
+ * is the sha256 + codesign + designated-requirement + Team ID verification
28
+ * below; this just stops relying on the redirect.
29
+ */
30
+ export declare const HELPER_RELEASE_REPO = "phnx-labs/agi-cli";
21
31
  /** Apple Developer ID Team every helper must be signed by ("Developer ID
22
32
  * Application: Muqit Nawaz"). Defense in depth on top of `spctl` notarization. */
23
33
  export declare const EXPECTED_TEAM_ID = "2HTP252L87";
@@ -23,8 +23,18 @@ import { pipeline } from 'node:stream/promises';
23
23
  import { getCacheDir } from './state.js';
24
24
  import { parseSha256Asset, sha256File } from './sha256-asset.js';
25
25
  import { helperTag } from './helper-versions.js';
26
- /** GitHub repo whose `v<version>` releases carry the helper assets. */
27
- export const HELPER_RELEASE_REPO = 'phnx-labs/agents-cli';
26
+ /**
27
+ * GitHub repo whose `<helper>/v<version>` releases carry the helper assets.
28
+ *
29
+ * This is the SLUG, which is not the npm package name: the package is still
30
+ * `@phnx-labs/agents-cli`, but the repository was renamed to `agi-cli`. The old
31
+ * slug kept working only because GitHub redirects a renamed repo, which is a
32
+ * poor thing to hang signed-binary delivery on — a redirect is one re-created
33
+ * repo away from resolving somewhere else. What actually protects the download
34
+ * is the sha256 + codesign + designated-requirement + Team ID verification
35
+ * below; this just stops relying on the redirect.
36
+ */
37
+ export const HELPER_RELEASE_REPO = 'phnx-labs/agi-cli';
28
38
  /** Apple Developer ID Team every helper must be signed by ("Developer ID
29
39
  * Application: Muqit Nawaz"). Defense in depth on top of `spctl` notarization. */
30
40
  export const EXPECTED_TEAM_ID = '2HTP252L87';
@@ -298,7 +298,7 @@ function foldUserHooksYamlIntoAgentsYaml() {
298
298
  meta.hooks = merged;
299
299
  const header = `# agents-cli metadata
300
300
  # Auto-generated - do not edit manually
301
- # https://github.com/phnx-labs/agents-cli
301
+ # https://github.com/phnx-labs/agi-cli
302
302
 
303
303
  `;
304
304
  try {
@@ -414,7 +414,7 @@ function foldBrowserProfilesIntoAgentsYaml() {
414
414
  meta.browser = merged;
415
415
  const header = `# agents-cli metadata
416
416
  # Auto-generated - do not edit manually
417
- # https://github.com/phnx-labs/agents-cli
417
+ # https://github.com/phnx-labs/agi-cli
418
418
 
419
419
  `;
420
420
  try {
@@ -1641,7 +1641,7 @@ function migrateVersionResourcesToPatterns() {
1641
1641
  }
1642
1642
  }
1643
1643
  if (changed) {
1644
- const META_HEADER = '# agents-cli metadata\n# Auto-generated - do not edit manually\n# https://github.com/phnx-labs/agents-cli\n# yaml-language-server: $schema=https://raw.githubusercontent.com/phnx-labs/agents-cli/main/cli/schema/agents-yaml.schema.json\n\n';
1644
+ const META_HEADER = '# agents-cli metadata\n# Auto-generated - do not edit manually\n# https://github.com/phnx-labs/agi-cli\n# yaml-language-server: $schema=https://raw.githubusercontent.com/phnx-labs/agi-cli/main/cli/schema/agents-yaml.schema.json\n\n';
1645
1645
  fs.writeFileSync(metaFile, META_HEADER + yaml.stringify(meta), 'utf-8');
1646
1646
  console.error('Migrated agents.yaml versions: entries to pattern format');
1647
1647
  }
@@ -1668,7 +1668,7 @@ function migrateSplitDeviceLocalMeta() {
1668
1668
  const agents = meta.agents;
1669
1669
  const versions = meta.versions;
1670
1670
  const hasLocal = (!!agents && Object.keys(agents).length > 0) || (!!versions && Object.keys(versions).length > 0);
1671
- const HEADER = '# agents-cli metadata\n# Auto-generated - do not edit manually\n# https://github.com/phnx-labs/agents-cli\n\n';
1671
+ const HEADER = '# agents-cli metadata\n# Auto-generated - do not edit manually\n# https://github.com/phnx-labs/agi-cli\n\n';
1672
1672
  // Only rewrite central when it actually carries machine-local fields — a
1673
1673
  // machine whose agents.yaml is already portable-only is left untouched.
1674
1674
  if (hasLocal) {
@@ -269,10 +269,13 @@ export function getBinaryPath(agent, version) {
269
269
  const agentConfig = AGENTS[agent];
270
270
  if (agent === 'grok') {
271
271
  const grokDownloads = path.join(getVersionHomePath(agent, version), '.grok', 'downloads');
272
- // Best effort: first matching file for this version
272
+ // The directory token is the stable installation/account label. A
273
+ // self-updating slot may carry a newer vendor release, whose binary keeps
274
+ // the release in its filename; resolve through the frozen install record.
275
+ const releaseVersion = readInstallation(agent, version)?.releaseVersion ?? version;
273
276
  try {
274
277
  const entries = fs.readdirSync(grokDownloads);
275
- const match = entries.find((e) => e.includes(version) && e.startsWith('grok-'));
278
+ const match = entries.find((e) => e.includes(releaseVersion) && e.startsWith('grok-'));
276
279
  if (match)
277
280
  return path.join(grokDownloads, match);
278
281
  }
@@ -280,7 +283,7 @@ export function getBinaryPath(agent, version) {
280
283
  const fallback = resolveGrokFallbackBinary(grokDownloads);
281
284
  if (fallback)
282
285
  return fallback;
283
- return path.join(grokDownloads, `grok-${version}`);
286
+ return path.join(grokDownloads, `grok-${releaseVersion}`);
284
287
  }
285
288
  if (agent === 'droid') {
286
289
  // Factory.ai's installer drops a standalone native binary (no npm package,