@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
package/CHANGELOG.md CHANGED
@@ -1,5 +1,234 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.22.51
4
+
5
+ - **`publish-computer-helper-mac.sh` cuts the helper's own tag (PHNX-3228).**
6
+ It published `ComputerHelper.app.zip` to the CLI's `v<version>` release, but since the
7
+ client was repointed at per-helper tags the resolver asks for `computer-mac/v<x.y.z>`
8
+ (`helperTag` in `cli/src/lib/helper-versions.ts`). The asset therefore landed at an
9
+ address nothing requests, and there was **no way to cut a new computer-mac release at
10
+ all** — the last publisher still coupled to the CLI's version line. It now cuts
11
+ `computer-mac/v<x.y.z>`, requires the helper's version explicitly instead of defaulting
12
+ to `cli/package.json` (that default was the coupling), and refuses an existing tag, since
13
+ the upload uses `--clobber` and would otherwise replace a binary an installed CLI already
14
+ pins. Symmetric with `publish-computer-win.sh`. Source:
15
+ `cli/scripts/publish-computer-helper-mac.sh`.
16
+ It also cuts the tag itself now. `gh release create --verify-tag` refuses to invent a
17
+ tag absent from the remote, and nothing else pushed `computer-mac/v<x.y.z>` — `release.sh`
18
+ delegates helper tagging to "where the helper is released", which is this script. So it
19
+ ran green through build + notarize and then failed at the release step, meaning no new
20
+ helper version could be cut. The tag is pushed AFTER a successful build, deliberately:
21
+ pushing first would leave a published, immutable address with nothing behind it whenever
22
+ notarization failed.
23
+ Origin, not the local ref, is what makes a helper release immutable. Adding tag creation
24
+ introduced a wedge: a run that tagged and then failed to push left a local tag, and the
25
+ guard read that as "published" and told the operator to cut the next patch — burning a
26
+ version over a transient network error, and (since the guard precedes the build) making
27
+ that version uncuttable from the checkout without a manual `git tag -d`. A local-only tag
28
+ now resumes the interrupted publish instead.
29
+ Immutability keys on the published RELEASE, not the tag. A tag with no release is an
30
+ interrupted run — whether the push failed or the release creation did — and both now
31
+ resume rather than burning the version. The asset is what an installed CLI downloads, so
32
+ the release is the thing that must never be replaced.
33
+ The published-release check fails CLOSED. A non-zero `gh` is not evidence of absence —
34
+ unauthenticated, offline, and rate-limited all exit non-zero — so reading that as "not
35
+ published" would have carried on into `gh release upload --clobber` over a live binary,
36
+ the exact outcome the guard exists to prevent. Only an explicit not-found means not-found;
37
+ anything else stops the release and prints what `gh` actually said.
38
+
39
+ - **`agents traces sync` retains and reports failed sessions instead of stranding them (PHNX-3267).**
40
+ The sync watermark advanced to the max mtime of *successful* uploads, so a later
41
+ success moved it past an earlier failed session and the next run's
42
+ `file_mtime_ms > watermark` filter skipped that session forever — the "failures are
43
+ retried" comment was false. A live 1.22.49 sync on a real device reported 11,073
44
+ uploaded and 6,050 errors with no way to tell a transcript cleaned off disk from a
45
+ genuine upload failure. Sync now records each failed session's identity and typed,
46
+ redacted evidence in the ledger and unions those retry-worthy ids back into the row
47
+ query regardless of the watermark, so a stranded session is re-attempted until it
48
+ succeeds. Failures are classified `transcript-unavailable` (the file is gone —
49
+ expected history, not re-read, aged out after 14 days), `parse-failed`, or
50
+ `upload-failed` (both retried); `agents traces sync` prints the breakdown
51
+ (`… errors (N transcripts no longer on disk · M parse/upload failures — will retry)`)
52
+ and `agents traces status` lists the outstanding retry set with example detail.
53
+ Source: `cli/src/lib/traces/sync.ts`, `cli/src/commands/traces.ts`.
54
+
55
+ - **Helper binaries no longer download through a repository-rename redirect.**
56
+ `HELPER_RELEASE_REPO` still named `phnx-labs/agents-cli` after the repository was renamed
57
+ to `phnx-labs/agi-cli`. Nothing was broken — GitHub redirects a renamed repo, and both
58
+ slugs returned HTTP 200 — but every signed helper asset (`MenubarHelper.app.zip`,
59
+ `Agents_CLI.app.zip`, `ComputerHelper.app.zip`, `computer-helper-win.exe`) was resolving
60
+ through that redirect, which is one re-created repository away from pointing elsewhere.
61
+ What actually protects the download is the sha256 + `codesign` + designated-requirement +
62
+ Team ID verification in `helper-download.ts`; this removes the reliance on the redirect.
63
+ The `agents.yaml` `$schema` URL, the CHANGELOG link, the star nudge, and the
64
+ `package.json` repository/issues metadata moved with it.
65
+ **The npm package name is deliberately unchanged** — it is still
66
+ `@phnx-labs/agents-cli`, and renaming it would orphan every installed CLI. That
67
+ distinction is now pinned by a test. Source: `cli/src/lib/helper-download.ts`.
68
+ `publish-computer-helper-mac.sh` carried the old slug too, and that one is a **write**
69
+ path: `REPO_SLUG` reaches `gh release view` (the immutability guard), `gh release create`,
70
+ and `gh release upload --clobber`. Publishing signed helper binaries was going through the
71
+ rename redirect. Found by sweeping `scripts/` and `.github/` after the source tree was
72
+ already clean — the source sweep alone would have missed it.
73
+ Three further live paths carried the old slug and were missed by the first sweep:
74
+ `ssh-tunnel.ts:170` `WIN_HELPER_RELEASE_REPO` (a **separately hardcoded** constant — the
75
+ download URL for `computer-helper-win.exe`, the fourth signed asset, which the first
76
+ version of this change claimed to cover and did not); `commands/feedback.ts:14`, which
77
+ opens real GitHub issues; and `factory/snapshot.ts:30`, which polls this repo's PRs.
78
+ `installations/migrate.ts` also wrote the old URL into the `agents.yaml` header it
79
+ generates. Separately, `.github/workflows/tests-windows-host-e2e.yml:54` gated on
80
+ `github.repository == 'phnx-labs/agents-cli'`, which is permanently false after a rename
81
+ — that job had silently stopped running on every push to main and on its daily cron.
82
+ The JSON Schema's own `$id` moved with it — `state.ts` and `migrate.ts` write that URL as
83
+ the `$schema` hint into every user's `agents.yaml`, so a mismatched `$id` would make
84
+ editors reject their own config.
85
+
86
+ - **The menu-bar helper's staleness check compares the HELPER's version, not the CLI's
87
+ (RUSH-3230).** `installAndStartService` stamped `getCliVersion()` as the installed
88
+ helper's version, and `menubarSetupStale()` compared against `getCliVersion()` too. Once
89
+ helpers gained their own version line that was wrong in both directions: every CLI
90
+ release made an unchanged helper look stale and reinstalled it — recopying the bundle
91
+ under the running helper, which `KeepAlive` then restarts (the #2109 storm: a new pid
92
+ every 5-15s, 578 launches in one log) — while a genuinely newer helper at the same CLI
93
+ version never looked stale at all, so it could never install.
94
+ The stamp is now JSON recording what the helper actually IS: `release` with its helper
95
+ version, or `local` with the source path + mtime for a dev build (menubar's build.sh
96
+ hardcodes `CFBundleShortVersionString`, so a local build has no version to compare).
97
+ Staleness compares like with like, treats a kind change (local <-> release) as stale, and
98
+ never downgrades when the installed helper is ahead of the floor. A pre-JSON stamp is
99
+ stale exactly once and is re-stamped in the new format, so the migration cannot loop.
100
+ `agents menubar status` / `doctor` now print three labelled lines — helper installed,
101
+ helper available, CLI version — instead of conflating two axes into one, and the
102
+ permanent false "(mismatch — `agents menubar setup` updates it)" hint is gone.
103
+ Source: `cli/src/lib/menubar/install-menubar.ts`.
104
+
105
+ - **The Windows helper's "asset missing" error names a tag that exists (RUSH-3230).**
106
+ `downloadWinHelperExe` built its error message from `` `v${version}` `` — the CLI's tag
107
+ shape — while the URL it had just tried came from `helperTag('computer-win', version)`.
108
+ So a genuine 404 sent the reader looking for `v1.0.0`, which does not exist, instead of
109
+ `computer-win/v1.0.0`, which does. The mac path was corrected when helpers moved to their
110
+ own tags; the Windows path was left behind. Also drops a `getCliVersion` import that had
111
+ no call site — the last trace of the old CLI-version coupling in that file.
112
+ Source: `cli/src/lib/computer/ssh-tunnel.ts`.
113
+
114
+ - **Claude status-line delegate no longer fork-bombs the machine.** The delegate
115
+ self-reference guard compared the saved command against the exact literal
116
+ `agents __claude-statusline`, so a delegate seeded with the same private
117
+ subcommand under a *different* binary name (e.g. `agents-dev __claude-statusline`
118
+ from a dev install, or an absolute path) was not recognized as us. Every
119
+ status-line render then `spawnSync`ed that command, which read the same delegate
120
+ and spawned another — unbounded recursion. Observed on a real box: ~4,900 live
121
+ `node __claude-statusline` processes accumulated over a 2-day uptime, exhausting
122
+ 96 GB of swap and driving load past 300, so keystrokes lagged. The guard now
123
+ matches the `__claude-statusline` subcommand under any binary name or path
124
+ (`isStatusLineSelfReference`), `installClaudeStatusLine` refuses to persist such
125
+ a command as a delegate (and deletes an already-poisoned delegate on re-install),
126
+ a `AGENTS_CLAUDE_STATUSLINE_DELEGATED` env marker hard-caps delegation at one hop,
127
+ and the delegate `spawnSync` now carries a 5 s timeout. Source:
128
+ `cli/src/lib/claude-statusline.ts`.
129
+
130
+ ## 1.22.50
131
+
132
+ ### Fixed
133
+
134
+ - **Teams now persist observed teammate failure evidence and keep independent DAG branches moving.** Placement, local/remote launch, cloud dispatch, dependency, and process-exit failures carry a stable code, sanitized message, exit code, retryability, and observation time in `teams status` text/JSON. A runnable node with a durable placement failure fails independently; a node blocked only by pool capacity or load stays pending with retryable evidence so a later wave can launch it. Descendants name failed or missing `--after` blockers instead of spinning until `--max-waves`. `teams start` (no `--watch`) reports teammates that failed during the wave in a `Failed this wave` section (JSON: `failed[]` with evidence) and exits non-zero when a wave produced only failures. Because a failed launch now keeps its record (evidence) instead of deleting it, re-running the identical `teams add --name <name>` after a failure requires `teams remove <team> <name>` first.
135
+ Successfully resuming a failed teammate clears the prior attempt's evidence as
136
+ the replacement enters `running`; a replacement that fails to launch restores
137
+ the original terminal state and evidence intact.
138
+
139
+ - **Register the Phoenix session with Prix after sync (PHNX-3257).** A successful
140
+ (non-dry-run) `agents traces sync` now fire-and-forget POSTs the Phoenix bearer
141
+ to `api.prix.dev/api/v1/traces/link`, so the Prix web console can mint a token
142
+ and serve live trajectories instead of fixtures. Scoped to the managed Phoenix
143
+ backend only — a BYO/self-hosted `AGENTS_TRACES_WRITE_TOKEN` is never sent to
144
+ Prix. Source: `cli/src/lib/traces/sync.ts`.
145
+
146
+ - **Ship `traces-daily-sync` routine (PHNX-3258).** Daily `agents traces sync` at 02:00 UTC
147
+ to accumulate `bucketHistory` for drift signals; requires a device pin
148
+ (`agents routines devices traces-daily-sync --set <name>`) before it fires.
149
+ Source: `cli/routines/traces-daily-sync.yml`.
150
+
151
+ - **`--strategy balanced` now spreads across your provider accounts, not just
152
+ native logins (RUSH-3182).** A setup-token or API-key account added with
153
+ `agents accounts add` is now a first-class balancing candidate for every harness
154
+ its provider can authenticate — claude, codex, grok, cursor, opencode (a harness
155
+ with only a native login and no provider adapter, like kimi, keeps balancing its
156
+ native logins). Before, balanced only rotated across accounts that sat in a version
157
+ home, so a worker's shared setup-tokens never participated and `--account`
158
+ couldn't select them. The run path folds those provider accounts into the
159
+ candidate list and injects the picked one through the existing `--account` path
160
+ (a setup-token still authenticates via `CLAUDE_CODE_OAUTH_TOKEN`); the other
161
+ candidate consumers — the watchdog, session recovery, teams placement — keep the
162
+ native-only list, so nothing else changes. On a box dominated by verified native
163
+ logins a usage-less provider account is deprioritized until the daemon fetches
164
+ its usage; on a worker (setup-token majority) it participates immediately.
165
+ Source: `cli/src/lib/accounting/account-pool.ts`,
166
+ `cli/src/lib/accounting/account-pool-collect.ts`,
167
+ `cli/src/lib/accounting/rotate.ts`, `cli/src/commands/exec.ts`.
168
+
169
+ - **The attestation producer skips the helper manifest by default too (RUSH-3216).**
170
+ `release.sh` gained `--with-helpers` so an ordinary release does no helper work, but
171
+ `release-attestation-produce.sh` kept the same unconditional
172
+ `release-manifest.sh` verification — so the coupling survived one step upstream. Found
173
+ live: a one-line **comment** fix in `native/computer-mac/scripts/build.sh` (an
174
+ `apps/cli/` → `cli/` path in prose) changed that helper's input digest and aborted an
175
+ otherwise-clean 1.22.49 attestation, for a helper the tarball no longer ships and the
176
+ CLI resolves from its own tag. The producer now takes the same `--with-helpers` flag,
177
+ default off. Source: `cli/scripts/release-attestation-produce.sh`.
178
+
179
+ - **`scripts/publish-computer-win.sh` — a publish path for the Windows helper (RUSH-3228).**
180
+ Its release now triggers on `computer-win/v<x.y.z>` rather than the CLI's `v*` tag, but
181
+ nothing in the repo cut such a tag, so the trigger would have been dead — trading a
182
+ wasteful 165 MB rebuild on every CLI release for no rebuild at all. The tag *is* the
183
+ publish action (`release-exe` builds, smokes on a real windows-latest runner, and
184
+ uploads the exe + sha256), so a mis-shaped tag is a silent no-op: the script refuses a
185
+ `v`-prefixed or non-semver version, refuses an existing tag because the upload uses
186
+ `--clobber` and an installed CLI may already pin it, and is dry-run by default.
187
+ Symmetric with `publish-computer-helper-mac.sh`. Source: `cli/scripts/publish-computer-win.sh`.
188
+
189
+ - **`scripts/test.sh --shard <n>` — fan the suite across n fleet workers (RUSH-3230).**
190
+ Uses vitest's own `--shard=i/n`, drawing workers from the same auto pool a single
191
+ `--device auto` run uses, so `role=worker`/`role=personal` marks govern the fan-out too.
192
+ This is the change that moves release time, and the reason is arithmetic: a measured full
193
+ run is 3,079s of CPU at 11.5× parallelism on one box, so wall equals CPU/workers (269s) —
194
+ the suite is **throughput-bound**, not bound by any single slow file. Adding boxes divides
195
+ the CPU: 3 ≈ 93s, 6 ≈ 47s, 9 ≈ 31s. Shards run concurrently and every one is waited on
196
+ before reporting, so a failure in one does not hide the others. Requires `agents` ≥ 1.22.49
197
+ for `devices pick --json`; an older CLI fails naming the version and the fix rather than
198
+ passing through a commander error. Source: `cli/scripts/test.sh`.
199
+ - **`scripts/test.sh --devices a,b,c` — name the shard workers explicitly (RUSH-3230).**
200
+ Pins the fan-out to known-idle boxes instead of auto-picking, and skips the
201
+ `devices pick --json` dependency, so sharding also works from a machine whose installed
202
+ CLI predates 1.22.49. Source: `cli/scripts/test.sh`.
203
+ - **`--shard` fails loud on a count below 2 and on a conflicting target flag (RUSH-3230).**
204
+ `--shard 0` previously passed the numeric check, ran zero shards, and still printed
205
+ `All 0 shards passed.` with exit 0 — a false green that reported success having run no
206
+ tests. It now requires at least 2 (a single worker is `--device auto`). Separately, `MODE`
207
+ was last-write-wins with no cross-flag validation, so `--shard 6 --device box` silently
208
+ dropped one of the two purely on argument order; conflicting target flags now die naming
209
+ both. Source: `cli/scripts/test.sh`.
210
+ - **`--devices` obeys the same 2-worker floor, and is validated before prerequisites (RUSH-3230).**
211
+ `--devices onebox` derived the shard count from the list length and skipped the floor
212
+ entirely, running a one-shard fan-out. The list is now resolved and checked immediately
213
+ after argument parsing rather than inside the dispatch branch, so a bad invocation reports
214
+ its own problem instead of dying on a missing `rsync` first. Source: `cli/scripts/test.sh`.
215
+
216
+ - **Split `ssh.device-config.test.ts`, the second-slowest file (RUSH-3230).** 18 subprocess
217
+ tests in one file at ~44s locally (151s on a loaded worker) — 8.4s *per test*. Now three
218
+ files along its existing `describe` boundaries (per-device config / fleet-wide defaults +
219
+ role + describe / retired-subcommand tombstones) over a shared
220
+ `device-config-test-harness.ts`: **45s → 19s wall, all 18 tests still passing**, none
221
+ changed, skipped, or dropped. Source: `cli/src/commands/device-config-test-harness.ts`.
222
+
223
+ - **Split `daemon.test.ts`, the suite's floor (RUSH-3230).** It was 35 subprocess tests in
224
+ one file at ~53s locally (159s on a loaded worker) — the slowest file in the repo, and
225
+ therefore the whole suite's floor, because vitest parallelises across *files* and runs
226
+ one file's tests sequentially in a single worker. Now three files by theme (command
227
+ surface / services + webhooks / doctor + logs) over a shared
228
+ `daemon-test-harness.ts`, so they run concurrently: **53.5s → 26s wall, all 35 tests
229
+ still passing**. No test was changed, skipped, or dropped. Source:
230
+ `cli/src/commands/daemon-test-harness.ts`.
231
+
3
232
  ## 1.22.49
4
233
 
5
234
  - **Check CLI benchmark numbers into `docs/benchmarks.md` (RUSH-2385).** Commander-bootstrap and audit-hook means from the yosemite-s1 vitest bench (PR #2349) plus pointers to OPT-01/OPT-02 live in git. Linear is not the ledger. Source: `cli/docs/benchmarks.md`.
package/README.md CHANGED
@@ -741,6 +741,16 @@ Teammates run detached -- close your terminal, they keep working. Check in with
741
741
 
742
742
  Team state is observable via `agents teams list --json` / `agents teams status --json` (compact by default; add `--verbose` for the full per-teammate shape). External tools join it with `sessions --json` (teammates get `isTeamOrigin: true`) and `cloud list --json` (for `--cloud` teammates) to build a unified fleet view. See [docs/observability.md](cli/docs/observability.md).
743
743
 
744
+ Placement, spawn, dependency, and non-zero-exit failures are persisted as sanitized
745
+ evidence on the teammate record and shown by `teams status`. A failed root does not stop
746
+ independent DAG branches; descendants name the failed or missing `--after` dependency
747
+ that blocked them instead of remaining pending indefinitely. Capacity- or load-blocked
748
+ placement stays pending with retryable evidence and is reconsidered on the next wave.
749
+ `teams start` reports
750
+ teammates that failed during the wave (`Failed this wave` / JSON `failed[]`) and exits
751
+ non-zero when a wave produced only failures. A failed teammate keeps its record as
752
+ evidence, so re-adding the same name needs `teams remove <team> <name>` first.
753
+
744
754
  ---
745
755
 
746
756
  ## Cloud
package/dist/bootstrap.js CHANGED
@@ -322,7 +322,7 @@ async function showWhatsNew(fromVersion, toVersion) {
322
322
  for (const line of relevantChanges) {
323
323
  console.log(line);
324
324
  }
325
- console.log(chalk.gray('\nFull notes: https://github.com/phnx-labs/agents-cli/blob/main/CHANGELOG.md'));
325
+ console.log(chalk.gray('\nFull notes: https://github.com/phnx-labs/agi-cli/blob/main/CHANGELOG.md'));
326
326
  console.log();
327
327
  }
328
328
  }
@@ -18,7 +18,7 @@ import { resolveRemoteOsSync } from '../lib/hosts/remote-os.js';
18
18
  import { runDevicesAccounts } from './ssh.js';
19
19
  import { discoverNativeAccounts } from '../lib/account-catalog.js';
20
20
  import { readAndResolveBundleEnv } from '../lib/secrets/bundles.js';
21
- import { getAccountProvider, listAccountProviders } from '../lib/account-provider-registry.js';
21
+ import { getAccountProvider, listAccountProviders, providerAuthenticatesHarness } from '../lib/account-provider-registry.js';
22
22
  import { accountBindings, addAccount, addNativeAccount, bindAccount, findAccount, findUnifiedAccount, inspectAccount, labelNativeAccount, listNativeAccounts, readAccountRegistry, removeAccount, renameAccount, setAccountSecret, unbindAccount } from '../lib/account-registry.js';
23
23
  function cleanCommandError(command, err) {
24
24
  command.error(err instanceof Error ? err.message : String(err), { exitCode: 1, code: 'accounts.error' });
@@ -288,18 +288,6 @@ async function pickLabelIdentity(agent, identities) {
288
288
  throw err;
289
289
  }
290
290
  }
291
- function providerAuthenticatesHarness(provider, auth, agent) {
292
- try {
293
- getAccountProvider(provider).envFor(agent, auth);
294
- return true;
295
- }
296
- catch (err) {
297
- const message = err instanceof Error ? err.message : String(err);
298
- if (message.includes('cannot authenticate'))
299
- return false;
300
- throw err;
301
- }
302
- }
303
291
  /** Every account (native + provider) registered/usable for this harness, oldest-first by name. */
304
292
  function accountsForHarness(agent) {
305
293
  const meta = readMeta();
@@ -1,6 +1,16 @@
1
1
  import { Command } from 'commander';
2
2
  import { type BrowserProfile } from '../lib/browser/profiles.js';
3
3
  import { listInstalledBrowsers } from '../lib/browser/chrome.js';
4
+ /**
5
+ * The fleet browser hub this box drives by default (`browser.device`), or
6
+ * undefined to drive locally. It is one fleet-synced value in the central
7
+ * agents.yaml, so every box reads the same hub name — but the hub itself resolves
8
+ * to a self-host, and a fleet-dispatched invocation is already ON its target, so
9
+ * both return undefined. That self short-circuit is what lets a single synced
10
+ * value be safe: only the boxes that are NOT the hub forward to it, and the hub
11
+ * never forwards to itself.
12
+ */
13
+ export declare function defaultBrowserHub(): string | undefined;
4
14
  export declare function registerBrowserCommand(program: Command): void;
5
15
  export declare function registerBrowserSubcommands(program: Command): void;
6
16
  interface BrowserUseOptions {
@@ -248,6 +248,23 @@ function assertDeviceDeclaresProfile(device, profileName) {
248
248
  throw new Error(`Device "${device}" does not declare browser profile "${profileName}". ${where}\n` +
249
249
  `Next: run \`agents browser profiles add ${profileName} --browser <b>\` on ${device}.`);
250
250
  }
251
+ /**
252
+ * The fleet browser hub this box drives by default (`browser.device`), or
253
+ * undefined to drive locally. It is one fleet-synced value in the central
254
+ * agents.yaml, so every box reads the same hub name — but the hub itself resolves
255
+ * to a self-host, and a fleet-dispatched invocation is already ON its target, so
256
+ * both return undefined. That self short-circuit is what lets a single synced
257
+ * value be safe: only the boxes that are NOT the hub forward to it, and the hub
258
+ * never forwards to itself.
259
+ */
260
+ export function defaultBrowserHub() {
261
+ if (isFleetRemoteInvocation())
262
+ return undefined;
263
+ const hub = getConfigValue('browser.device').value;
264
+ if (!hub || isSelfHost(hub))
265
+ return undefined;
266
+ return hub;
267
+ }
251
268
  // Help groups — surfaces the actual mental model an agent follows
252
269
  // ("open a session / drive the page / capture evidence / rare extras")
253
270
  // instead of an alphabetical dump. Everything not listed falls into a
@@ -356,7 +373,14 @@ export async function runBrowserUse(name, opts, interactive = isInteractiveTermi
356
373
  console.log(current
357
374
  ? `Default browser profile (this machine): ${current}`
358
375
  : 'Default browser profile (this machine): auto-detect');
376
+ const hub = getConfigValue('browser.device').value;
377
+ if (hub) {
378
+ console.log(isSelfHost(hub)
379
+ ? `Browser hub (browser.device): ${hub} — this machine, so drives locally`
380
+ : `Browser hub (browser.device): ${hub} — bare \`agents browser start\` drives ${hub}'s browser`);
381
+ }
359
382
  console.log('Usage: agents browser use <name> (or --unset)');
383
+ console.log('Fleet hub: agents config set browser.device <device> (drive that box\'s browser from every machine)');
360
384
  return true;
361
385
  }
362
386
  const choices = buildBrowserUseChoices(await listProfiles(), listInstalledBrowsers(), current);
@@ -1353,7 +1377,7 @@ function registerTaskCommands(browser) {
1353
1377
  .option('--title <label>', 'Human label shown in `browser status` (defaults to first navigated host)')
1354
1378
  .option('-e, --endpoint <name>', 'Endpoint preset (defaults to the profile\'s default)')
1355
1379
  .option('-u, --url <url>', 'Open URL in first tab')
1356
- .option('--device <name>', 'Device that hosts this task; later verbs resolve it from --task (not valid on page verbs)')
1380
+ .option('--device <name>', 'Device that hosts this task (defaults to the browser.device hub when set; use `local` to force this machine). Later verbs resolve it from --task; not valid on page verbs')
1357
1381
  .option('--fresh', 'Always open a new tab, skipping the reclaim of a tab an abandoned task is holding on that URL')
1358
1382
  .option('--no-skills', 'Skip auto-discovery of site-specific SKILL.md from ~/.agents/skills/browser/domain-skills/')
1359
1383
  .option('--record', 'Start recording right after the tab opens (shorthand for `agents browser record start` as a follow-up)')
@@ -1373,6 +1397,54 @@ function registerTaskCommands(browser) {
1373
1397
  console.error(err instanceof Error ? err.message : String(err));
1374
1398
  process.exit(1);
1375
1399
  }
1400
+ // Thin-client hub (PHNX-2010): when this box has a fleet browser hub
1401
+ // (`browser.device`) and the caller gave no explicit --device, forward the
1402
+ // WHOLE start there — bare OR --profile — and skip the local profile/browser
1403
+ // resolution below. The hub owns its profiles, browser, and consent, so this
1404
+ // box needs no local browser and need not carry the named profile; forwarding
1405
+ // the local pre-checks here would reject a profile that lives only on the hub.
1406
+ // Later page verbs follow the task to the hub via the task→device index bound
1407
+ // here. `--device local`/`--device self` (below) force a local run instead;
1408
+ // `defaultBrowserHub()` is undefined on the hub itself and on a fleet-remote
1409
+ // re-exec, so neither forwards to itself.
1410
+ if (!opts.device) {
1411
+ const hub = defaultBrowserHub();
1412
+ if (hub) {
1413
+ // No local profile validation: the hub is the authority on its own
1414
+ // profiles (including fleet-scoped ones this box can't see in
1415
+ // `declaringDevices`), so a bad `--profile` is caught by the hub's own
1416
+ // start and streamed back with its exit code, rather than second-guessed
1417
+ // here against a registry that doesn't carry the hub's profiles.
1418
+ try {
1419
+ const result = await dispatchBrowserToDevice(hub, browserForwardedArgv(), 'capture');
1420
+ process.stdout.write(result.stdout);
1421
+ process.stderr.write(result.stderr);
1422
+ if (result.code !== 0)
1423
+ process.exit(result.code);
1424
+ const taskName = opts.task || result.stdout.trim().split('\n').find((line) => line.length > 0);
1425
+ if (!taskName) {
1426
+ console.error(`Remote start on ${hub} produced no task name.`);
1427
+ process.exit(1);
1428
+ }
1429
+ bindTask(taskName, {
1430
+ device: hub,
1431
+ // Only what the caller named; the hub's own default (bare start) is
1432
+ // opaque here. Name-based `done`/`stop --task` and the post-stop
1433
+ // unbind GC these regardless.
1434
+ profile: opts.profile,
1435
+ url: opts.url,
1436
+ sessionId: callerSessionId(),
1437
+ launchId: callerLaunchId(),
1438
+ createdAt: Date.now(),
1439
+ });
1440
+ }
1441
+ catch (err) {
1442
+ console.error(err instanceof Error ? err.message : String(err));
1443
+ process.exit(1);
1444
+ }
1445
+ return;
1446
+ }
1447
+ }
1376
1448
  // One resolution order for every command (RUSH-2709): `--profile default`
1377
1449
  // means the same profile here as it does in stop / status / navigate.
1378
1450
  // `start` is the one command that LAUNCHES, so its implicit path goes
@@ -1409,7 +1481,10 @@ function registerTaskCommands(browser) {
1409
1481
  process.exit(1);
1410
1482
  }
1411
1483
  }
1412
- const deviceName = opts.device;
1484
+ // The `browser.device` hub default is applied above (bare/--profile, no
1485
+ // --device). Here only an EXPLICIT --device remains — with `local`/`self` as
1486
+ // the escape that forces a local run when a hub is configured.
1487
+ const deviceName = opts.device === 'local' || opts.device === 'self' ? undefined : opts.device;
1413
1488
  if (deviceName) {
1414
1489
  const lowered = deviceName.toLowerCase();
1415
1490
  if (lowered === 'all' || lowered === 'auto') {
@@ -111,6 +111,11 @@ function setConfig(parsed, value) {
111
111
  return;
112
112
  }
113
113
  case 'browser': {
114
+ if (parsed.property === 'device') {
115
+ // Fleet hub: user scope, one central value, never peer-targeted.
116
+ setConfigValue('browser.device', value);
117
+ return;
118
+ }
114
119
  // Device-local default lives in the per-device doc's config: block
115
120
  // (same store `agents devices config` / getConfigValue use). Bare
116
121
  // browser.profile targets this machine; devices.<name>.browser.profile
@@ -155,6 +160,11 @@ function unsetConfig(parsed) {
155
160
  return had;
156
161
  }
157
162
  case 'browser': {
163
+ if (parsed.property === 'device') {
164
+ const had = getConfigValue('browser.device').value !== undefined;
165
+ unsetConfigValue('browser.device');
166
+ return had;
167
+ }
158
168
  const target = parsed.device ? { device: parsed.device } : undefined;
159
169
  // Must follow parsed.property. Hardcoding 'browser.profile' here meant
160
170
  // `config unset browser.viewer` deleted the user's browser.profile while
@@ -195,6 +205,9 @@ function getConfig(parsed) {
195
205
  case 'auto':
196
206
  return getConfigValue('auto.pool').value;
197
207
  case 'browser': {
208
+ if (parsed.property === 'device') {
209
+ return getConfigValue('browser.device').value;
210
+ }
198
211
  return getConfigValue(parsed.property === 'viewer' ? 'browser.viewer' : 'browser.profile', parsed.device ? { device: parsed.device } : undefined).value;
199
212
  }
200
213
  case 'project':
@@ -267,6 +280,13 @@ function* listCentralConfigEntries() {
267
280
  const key = 'browser.viewer';
268
281
  yield { key, value: browserViewer, hint: configKeyStorageHint(parseConfigKey(key)) };
269
282
  }
283
+ // Fleet browser hub — user scope, so it belongs in the central listing next to
284
+ // its siblings. Omitting it repeats the browser.viewer invisibility bug.
285
+ const browserDevice = getConfigValue('browser.device').value;
286
+ if (browserDevice !== undefined) {
287
+ const key = 'browser.device';
288
+ yield { key, value: browserDevice, hint: configKeyStorageHint(parseConfigKey(key)) };
289
+ }
270
290
  }
271
291
  /** Collect device-scope config entries. */
272
292
  function* listDeviceConfigEntries(device) {
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Shared harness for the `agents daemon` CLI subprocess suites (RUSH-2354).
3
+ *
4
+ * Every test spawns the real CLI against an isolated mkdtemp HOME with no
5
+ * daemon running — no mocks. Modeled on routines.test.ts.
6
+ *
7
+ * EXTRACTED so the suite can live in several files. `daemon.test.ts` was 35
8
+ * tests in ONE file at 159s — the slowest file in the repo and therefore the
9
+ * SUITE'S FLOOR, because vitest parallelises across files and runs the tests
10
+ * inside one file sequentially in a single worker. Splitting the tests across
11
+ * files lets them run concurrently; sharing the harness is what makes that
12
+ * possible without duplicating the spawn plumbing.
13
+ */
14
+ import { spawnSync, type ChildProcess } from 'child_process';
15
+ export declare const REPO_ROOT: string;
16
+ export declare const TSX_IMPORT: string;
17
+ export declare const CLI_ENTRYPOINT: string;
18
+ /**
19
+ * win32: subprocess CLI + process-group signals / path spawn assumptions
20
+ * (RUSH-2215). Exported as a PREDICATE, not as a pre-bound `describe.skip` —
21
+ * vitest's suite type is not nameable across a module boundary (TS4023), so
22
+ * each suite builds its own `describe` from this.
23
+ */
24
+ export declare const DAEMON_TESTS_SUPPORTED: boolean;
25
+ /** Provision an isolated HOME with just enough scaffolding for the CLI to boot. */
26
+ export declare function makeHome(): string;
27
+ /** Run `agents daemon <args>` against an isolated HOME — no daemon process ever started. */
28
+ export declare function run(home: string, args: string[]): ReturnType<typeof spawnSync>;
29
+ /**
30
+ * Spawn a real, long-lived process whose command line ends in `__daemon-run`
31
+ * (so `isDaemonRunProcess`'s `ps` check accepts it — see
32
+ * `lib/daemon.test.ts`'s "reaps a live __daemon-run registrant" test, same
33
+ * technique) and register it in `home`'s OWN instance registry, exactly the
34
+ * marker `registerDaemonInstance` would write. A real live process, not a
35
+ * mock — `agents daemon status` reads it through the actual registry +
36
+ * `ps`-liveness path, the same one the reaper and `stopDaemon`'s postcondition
37
+ * use.
38
+ */
39
+ export declare function spawnFakeRegisteredDaemon(home: string): Promise<ChildProcess>;
40
+ /** Register a pid in `home`'s instance registry — the scope stale/duplicate reporting uses. */
41
+ export declare function registerInstance(home: string, pid: number): void;
42
+ export declare function killFakeDaemon(child: ChildProcess): void;
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Shared harness for the `agents daemon` CLI subprocess suites (RUSH-2354).
3
+ *
4
+ * Every test spawns the real CLI against an isolated mkdtemp HOME with no
5
+ * daemon running — no mocks. Modeled on routines.test.ts.
6
+ *
7
+ * EXTRACTED so the suite can live in several files. `daemon.test.ts` was 35
8
+ * tests in ONE file at 159s — the slowest file in the repo and therefore the
9
+ * SUITE'S FLOOR, because vitest parallelises across files and runs the tests
10
+ * inside one file sequentially in a single worker. Splitting the tests across
11
+ * files lets them run concurrently; sharing the harness is what makes that
12
+ * possible without duplicating the spawn plumbing.
13
+ */
14
+ import { spawnSync, spawn } from 'child_process';
15
+ import * as fs from 'fs';
16
+ import * as os from 'os';
17
+ import * as path from 'path';
18
+ import { fileURLToPath, pathToFileURL } from 'url';
19
+ import { createRequire } from 'module';
20
+ export const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
21
+ const require = createRequire(import.meta.url);
22
+ export const TSX_IMPORT = pathToFileURL(require.resolve('tsx')).href;
23
+ export const CLI_ENTRYPOINT = path.join(REPO_ROOT, 'src', 'index.ts');
24
+ /**
25
+ * win32: subprocess CLI + process-group signals / path spawn assumptions
26
+ * (RUSH-2215). Exported as a PREDICATE, not as a pre-bound `describe.skip` —
27
+ * vitest's suite type is not nameable across a module boundary (TS4023), so
28
+ * each suite builds its own `describe` from this.
29
+ */
30
+ export const DAEMON_TESTS_SUPPORTED = process.platform !== 'win32';
31
+ /** Provision an isolated HOME with just enough scaffolding for the CLI to boot. */
32
+ export function makeHome() {
33
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'agents-daemon-test-'));
34
+ fs.mkdirSync(path.join(home, '.agents', '.system', '.git'), { recursive: true });
35
+ fs.writeFileSync(path.join(home, '.agents', 'agents.yaml'), 'agents: {}\n');
36
+ return home;
37
+ }
38
+ /** Run `agents daemon <args>` against an isolated HOME — no daemon process ever started. */
39
+ export function run(home, args) {
40
+ return spawnSync('node', ['--import', TSX_IMPORT, CLI_ENTRYPOINT, 'daemon', ...args], {
41
+ cwd: REPO_ROOT,
42
+ env: {
43
+ ...process.env,
44
+ HOME: home,
45
+ USERPROFILE: home,
46
+ AGENTS_SKIP_MIGRATION: '1',
47
+ AGENTS_NO_AUTOPULL: '1',
48
+ AGENTS_CLI_DISABLE_AUTO_UPDATE: '1',
49
+ AGENTS_DAEMON_DIR: path.join(home, '.agents', '.cache', 'helpers', 'daemon'),
50
+ },
51
+ encoding: 'utf-8',
52
+ timeout: 30_000,
53
+ });
54
+ }
55
+ /**
56
+ * Spawn a real, long-lived process whose command line ends in `__daemon-run`
57
+ * (so `isDaemonRunProcess`'s `ps` check accepts it — see
58
+ * `lib/daemon.test.ts`'s "reaps a live __daemon-run registrant" test, same
59
+ * technique) and register it in `home`'s OWN instance registry, exactly the
60
+ * marker `registerDaemonInstance` would write. A real live process, not a
61
+ * mock — `agents daemon status` reads it through the actual registry +
62
+ * `ps`-liveness path, the same one the reaper and `stopDaemon`'s postcondition
63
+ * use.
64
+ */
65
+ export async function spawnFakeRegisteredDaemon(home) {
66
+ const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1e9)', '__daemon-run'], {
67
+ stdio: 'ignore',
68
+ });
69
+ // Give the exec a moment to land before `ps` (read by the status command's
70
+ // isDaemonRunProcess check) is asked to see its real argv — mirrors
71
+ // lib/daemon.test.ts's identical fake-daemon technique.
72
+ await new Promise((r) => setTimeout(r, 150));
73
+ const instancesDir = path.join(home, '.agents', '.cache', 'helpers', 'daemon', 'instances');
74
+ fs.mkdirSync(instancesDir, { recursive: true });
75
+ fs.writeFileSync(path.join(instancesDir, String(child.pid)), '__daemon-run', 'utf-8');
76
+ return child;
77
+ }
78
+ /** Register a pid in `home`'s instance registry — the scope stale/duplicate reporting uses. */
79
+ export function registerInstance(home, pid) {
80
+ const dir = path.join(home, '.agents', '.cache', 'helpers', 'daemon', 'instances');
81
+ fs.mkdirSync(dir, { recursive: true });
82
+ fs.writeFileSync(path.join(dir, String(pid)), '__daemon-run', 'utf-8');
83
+ }
84
+ export function killFakeDaemon(child) {
85
+ try {
86
+ if (child.pid)
87
+ process.kill(child.pid, 'SIGKILL');
88
+ }
89
+ catch { /* already gone */ }
90
+ }
@@ -0,0 +1,11 @@
1
+ export declare const REPO_ROOT: string;
2
+ export declare const INDEX: string;
3
+ export declare function guardedHome(): void;
4
+ export declare function run(args: string[], extraEnv?: Record<string, string>): {
5
+ stdout: string;
6
+ stderr: string;
7
+ status: number | null;
8
+ };
9
+ export declare function centralDoc(): string;
10
+ export declare function deviceDoc(name: string): string;
11
+ export declare function addDevice(name: string, target?: string): void;